1import {
2 DesktopServerExposureModeSchema,
3 DesktopUpdateChannelSchema,
4 type DesktopServerExposureMode,
5 type DesktopUpdateChannel,
6} from "@t3tools/contracts";
7import { fromLenientJson } from "@t3tools/shared/schemaJson";
8import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
9import * as Context from "effect/Context";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Path from "effect/Path";
16import * as Schema from "effect/Schema";
17import * as SynchronizedRef from "effect/SynchronizedRef";
18
19import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
20import {
21 DEFAULT_LINUX_PASSWORD_STORE,
22 normalizeLinuxPasswordStorePreference,
23 type LinuxPasswordStorePreference,
24} from "../linuxSecretStorage.ts";
25import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";
26import { isValidDistroName } from "../wsl/wslPathParsing.ts";
27
28export interface DesktopSettings {
29 readonly localEnvironmentEnabled: boolean;
30 readonly linuxPasswordStore: LinuxPasswordStorePreference;
31 readonly mainWindowBounds: DesktopWindowBounds | null;
32 readonly mainWindowMaximized: boolean;
33 readonly serverExposureMode: DesktopServerExposureMode;
34 readonly tailscaleServeEnabled: boolean;
35 readonly tailscaleServePort: number;
36 readonly updateChannel: DesktopUpdateChannel;
37 readonly updateChannelConfiguredByUser: boolean;
38 // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL
39 // integration. We now run Windows and WSL backends side by side, so the
40 // setting is just whether the WSL backend should be running alongside the
41 // primary. Persisted documents that still carry the legacy `wslMode: "wsl"`
42 // value are migrated to `wslBackendEnabled: true` on load.
43 readonly wslBackendEnabled: boolean;
44 readonly wslDistro: string | null;
45 // When true (and wslBackendEnabled is also true) the desktop runs only
46 // the WSL backend as the primary, and the Windows-side Node backend is
47 // not started. Designed for users who develop entirely inside WSL and
48 // don't want a second backend process running. Defaults to false so
49 // existing setups stay on the parallel-backends behavior. Changing
50 // this requires a desktop restart because the pool's primary spec is
51 // chosen once at layer init.
52 readonly wslOnly: boolean;
53}
54
55export interface DesktopSettingsChange {
56 readonly settings: DesktopSettings;
57 readonly changed: boolean;
58}
59
60const DEFAULT_TAILSCALE_SERVE_PORT = 443;
61const MIN_MAIN_WINDOW_SIZE = {
62 width: 840,
63 height: 620,
64} as const;
65export const DesktopWindowBoundsSchema = Schema.Struct({
66 x: Schema.Int,
67 y: Schema.Int,
68 width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)),
69 height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)),
70});
71export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type;
72export const DEFAULT_MAIN_WINDOW_SIZE = {
73 width: 1100,
74 height: 780,
75} as const;
76
77export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
78 localEnvironmentEnabled: true,
79 linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE,
80 mainWindowBounds: null,
81 mainWindowMaximized: false,
82 serverExposureMode: "local-only",
83 tailscaleServeEnabled: false,
84 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
85 updateChannel: "latest",
86 updateChannelConfiguredByUser: false,
87 wslBackendEnabled: false,
88 wslDistro: null,
89 wslOnly: false,
90};
91
92const DesktopWindowBoundsDocument = Schema.Struct({
93 x: Schema.Number,
94 y: Schema.Number,
95 width: Schema.Number,
96 height: Schema.Number,
97});
98
99const DesktopSettingsDocument = Schema.Struct({
100 localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean),
101 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
102 mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)),
103 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
104 serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema),
105 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
106 tailscaleServePort: Schema.optionalKey(Schema.Number),
107 updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema),
108 updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean),
109 // Newer form of the WSL toggle. `wslMode` is still accepted on load so
110 // existing on-disk settings keep working; on the next persist we write the
111 // new boolean and the legacy key drops out.
112 wslBackendEnabled: Schema.optionalKey(Schema.Boolean),
113 wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])),
114 wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)),
115 wslOnly: Schema.optionalKey(Schema.Boolean),
116});
117
118type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type;
119type Mutable<T> = { -readonly [K in keyof T]: T[K] };
120
121const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument);
122const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson);
123const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson);
124const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema);
125const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema);
126
127const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({
128 settings,
129 changed,
130});
131
132const DesktopSettingsWriteOperation = Schema.Literals([
133 "create-temporary-file-name",
134 "resolve-symlink",
135 "encode-document",
136 "create-directory",
137 "write-temporary-file",
138 "replace-settings-file",
139]);
140type DesktopSettingsWriteOperation = typeof DesktopSettingsWriteOperation.Type;
141
142export class DesktopSettingsWriteError extends Schema.TaggedError<DesktopSettingsWriteError>()(
143 "DesktopSettingsWriteError",
144 {
145 operation: DesktopSettingsWriteOperation,
146 path: Schema.String,
147 cause: Schema.Defect(),
148 },
149) {
150 override get message(): string {
151 return `Desktop settings write failed during ${this.operation} at ${this.path}.`;
152 }
153}
154
155export class DesktopAppSettings extends Context.Service<
156 DesktopAppSettings,
157 {
158 readonly load: Effect.Effect<DesktopSettings>;
159 readonly get: Effect.Effect<DesktopSettings>;
160 readonly setLocalEnvironmentEnabled: (
161 enabled: boolean,
162 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
163 readonly setMainWindowBounds: (
164 bounds: DesktopWindowBounds,
165 isMaximized: boolean,
166 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
167 readonly setServerExposureMode: (
168 mode: DesktopServerExposureMode,
169 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
170 readonly setTailscaleServe: (input: {
171 readonly enabled: boolean;
172 readonly port: Option.Option<number>;
173 }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
174 readonly setUpdateChannel: (
175 channel: DesktopUpdateChannel,
176 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
177 readonly setWslBackendEnabled: (
178 enabled: boolean,
179 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
180 readonly setWslDistro: (
181 distro: string | null,
182 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
183 readonly setWslOnly: (
184 enabled: boolean,
185 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
186 readonly applyWslWindowsFallback: Effect.Effect<
187 DesktopSettingsChange,
188 DesktopSettingsWriteError
189 >;
190 readonly applyWslWindowsFallbackInMemory: Effect.Effect<DesktopSettingsChange>;
191 }
192>()("@t3tools/desktop/settings/DesktopAppSettings") {}
193
194export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
195 return {
196 ...DEFAULT_DESKTOP_SETTINGS,
197 updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
198 };
199}
200
201function normalizeTailscaleServePort(value: unknown): number {
202 return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535
203 ? value
204 : DEFAULT_TAILSCALE_SERVE_PORT;
205}
206
207function normalizeWslDistro(value: unknown): string | null {
208 return typeof value === "string" && isValidDistroName(value) ? value : null;
209}
210
211export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null {
212 return Option.getOrNull(decodeDesktopWindowBounds(value));
213}
214
215function normalizeDesktopSettingsDocument(
216 parsed: DesktopSettingsDocument,
217 appVersion: string,
218): DesktopSettings {
219 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
220 const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds);
221 const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel);
222 const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
223 const updateChannelConfiguredByUser =
224 parsed.updateChannelConfiguredByUser === true ||
225 (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly"));
226
227 // Newer form wins when both are present; otherwise fall back to the legacy
228 // `wslMode === "wsl"` signal so users coming off the swap-mode build keep
229 // their WSL backend enabled.
230 const wslBackendEnabled =
231 parsed.wslBackendEnabled === true ||
232 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");
233
234 return {
235 localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false,
236 linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore),
237 mainWindowBounds,
238 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,
239 serverExposureMode:
240 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
241 tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
242 tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
243 updateChannel: updateChannelConfiguredByUser
244 ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel)
245 : defaultSettings.updateChannel,
246 updateChannelConfiguredByUser,
247 wslBackendEnabled,
248 wslDistro: normalizeWslDistro(parsed.wslDistro),
249 wslOnly: parsed.wslOnly === true,
250 };
251}
252
253function toDesktopSettingsDocument(
254 settings: DesktopSettings,
255 defaults: DesktopSettings,
256): DesktopSettingsDocument {
257 const document: Mutable<DesktopSettingsDocument> = {};
258
259 if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) {
260 document.localEnvironmentEnabled = settings.localEnvironmentEnabled;
261 }
262
263 if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) {
264 document.linuxPasswordStore = settings.linuxPasswordStore;
265 }
266 if (settings.mainWindowBounds !== null) {
267 document.mainWindowBounds = settings.mainWindowBounds;
268 }
269 if (settings.mainWindowMaximized) {
270 document.mainWindowMaximized = true;
271 }
272 if (settings.serverExposureMode !== defaults.serverExposureMode) {
273 document.serverExposureMode = settings.serverExposureMode;
274 }
275 if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) {
276 document.tailscaleServeEnabled = settings.tailscaleServeEnabled;
277 }
278 if (settings.tailscaleServePort !== defaults.tailscaleServePort) {
279 document.tailscaleServePort = settings.tailscaleServePort;
280 }
281 if (settings.updateChannel !== defaults.updateChannel) {
282 document.updateChannel = settings.updateChannel;
283 }
284 if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) {
285 document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser;
286 }
287 if (settings.wslBackendEnabled !== defaults.wslBackendEnabled) {
288 document.wslBackendEnabled = settings.wslBackendEnabled;
289 }
290 if (settings.wslDistro !== defaults.wslDistro) {
291 document.wslDistro = settings.wslDistro;
292 }
293 if (settings.wslOnly !== defaults.wslOnly) {
294 document.wslOnly = settings.wslOnly;
295 }
296
297 return document;
298}
299
300function setServerExposureMode(
301 settings: DesktopSettings,
302 requestedMode: DesktopServerExposureMode,
303): DesktopSettings {
304 return settings.serverExposureMode === requestedMode
305 ? settings
306 : {
307 ...settings,
308 serverExposureMode: requestedMode,
309 };
310}
311
312function setMainWindowBounds(
313 settings: DesktopSettings,
314 bounds: DesktopWindowBounds,
315 isMaximized: boolean,
316): DesktopSettings {
317 return settings.mainWindowBounds !== null &&
318 desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) &&
319 settings.mainWindowMaximized === isMaximized
320 ? settings
321 : {
322 ...settings,
323 mainWindowBounds: bounds,
324 mainWindowMaximized: isMaximized,
325 };
326}
327
328function setTailscaleServe(
329 settings: DesktopSettings,
330 input: { readonly enabled: boolean; readonly port: Option.Option<number> },
331): DesktopSettings {
332 const port = Option.match(input.port, {
333 onNone: () => settings.tailscaleServePort,
334 onSome: normalizeTailscaleServePort,
335 });
336 return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port
337 ? settings
338 : {
339 ...settings,
340 tailscaleServeEnabled: input.enabled,
341 tailscaleServePort: port,
342 };
343}
344
345function setUpdateChannel(
346 settings: DesktopSettings,
347 requestedChannel: DesktopUpdateChannel,
348): DesktopSettings {
349 return settings.updateChannel === requestedChannel
350 ? settings
351 : {
352 ...settings,
353 updateChannel: requestedChannel,
354 updateChannelConfiguredByUser: true,
355 };
356}
357
358function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings {
359 return settings.wslBackendEnabled === enabled
360 ? settings
361 : {
362 ...settings,
363 wslBackendEnabled: enabled,
364 };
365}
366
367function setWslDistro(settings: DesktopSettings, distro: string | null): DesktopSettings {
368 const normalized = normalizeWslDistro(distro);
369 return settings.wslDistro === normalized
370 ? settings
371 : {
372 ...settings,
373 wslDistro: normalized,
374 };
375}
376
377function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSettings {
378 return settings.wslOnly === enabled
379 ? settings
380 : {
381 ...settings,
382 wslOnly: enabled,
383 };
384}
385
386function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings {
387 return settings.localEnvironmentEnabled === enabled
388 ? settings
389 : { ...settings, localEnvironmentEnabled: enabled };
390}
391
392function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings {
393 return setWslOnly(setWslBackendEnabled(settings, false), false);
394}
395
396function readSettings(
397 fileSystem: FileSystem.FileSystem,
398 settingsPath: string,
399 appVersion: string,
400): Effect.Effect<DesktopSettings> {
401 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
402
403 return fileSystem.readFileString(settingsPath).pipe(
404 Effect.option,
405 Effect.flatMap(
406 Option.match({
407 onNone: () => Effect.succeed(defaultSettings),
408 onSome: (raw) =>
409 decodeDesktopSettingsJson(raw).pipe(
410 Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)),
411 Effect.orElseSucceed(() => defaultSettings),
412 ),
413 }),
414 ),
415 );
416}
417
418const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: {
419 readonly fileSystem: FileSystem.FileSystem;
420 readonly path: Path.Path;
421 readonly settingsPath: string;
422 readonly settings: DesktopSettings;
423 readonly defaultSettings: DesktopSettings;
424 readonly suffix: string;
425}): Effect.fn.Return<void, DesktopSettingsWriteError> {
426 const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe(
427 Effect.provideService(FileSystem.FileSystem, input.fileSystem),
428 Effect.provideService(Path.Path, input.path),
429 Effect.mapError(
430 (cause) =>
431 new DesktopSettingsWriteError({
432 operation: "resolve-symlink",
433 path: input.settingsPath,
434 cause,
435 }),
436 ),
437 );
438 const directory = input.path.dirname(targetPath);
439 const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`;
440 const encoded = yield* encodeDesktopSettingsJson(
441 toDesktopSettingsDocument(input.settings, input.defaultSettings),
442 ).pipe(
443 Effect.mapError(
444 (cause) =>
445 new DesktopSettingsWriteError({
446 operation: "encode-document",
447 path: input.settingsPath,
448 cause,
449 }),
450 ),
451 );
452 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
453 Effect.mapError(
454 (cause) =>
455 new DesktopSettingsWriteError({
456 operation: "create-directory",
457 path: directory,
458 cause,
459 }),
460 ),
461 );
462 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
463 Effect.mapError(
464 (cause) =>
465 new DesktopSettingsWriteError({
466 operation: "write-temporary-file",
467 path: tempPath,
468 cause,
469 }),
470 ),
471 );
472 yield* input.fileSystem.rename(tempPath, targetPath).pipe(
473 Effect.mapError(
474 (cause) =>
475 new DesktopSettingsWriteError({
476 operation: "replace-settings-file",
477 path: input.settingsPath,
478 cause,
479 }),
480 ),
481 );
482});
483
484/** @public Service construction is part of the canonical Effect module API. */
485export const make = Effect.gen(function* () {
486 const environment = yield* DesktopEnvironment.DesktopEnvironment;
487 const fileSystem = yield* FileSystem.FileSystem;
488 const path = yield* Path.Path;
489 const crypto = yield* Crypto.Crypto;
490 const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings);
491
492 const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) =>
493 SynchronizedRef.modify(settingsRef, (settings) => {
494 const nextSettings = update(settings);
495 return [settingsChange(nextSettings, nextSettings !== settings), nextSettings] as const;
496 });
497
498 const persist = (
499 update: (settings: DesktopSettings) => DesktopSettings,
500 ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> =>
501 SynchronizedRef.modifyEffect(settingsRef, (settings) => {
502 const nextSettings = update(settings);
503 if (nextSettings === settings) {
504 return Effect.succeed([settingsChange(settings, false), settings] as const);
505 }
506
507 return crypto.randomUUIDv4().pipe(
508 Effect.map((uuid) => uuid.replace(/-/g, "")),
509 Effect.mapError(
510 (cause) =>
511 new DesktopSettingsWriteError({
512 operation: "create-temporary-file-name",
513 path: environment.desktopSettingsPath,
514 cause,
515 }),
516 ),
517 Effect.flatMap((suffix) =>
518 writeSettings({
519 fileSystem,
520 path,
521 settingsPath: environment.desktopSettingsPath,
522 settings: nextSettings,
523 defaultSettings: environment.defaultDesktopSettings,
524 suffix,
525 }),
526 ),
527 Effect.as([settingsChange(nextSettings, true), nextSettings] as const),
528 );
529 });
530
531 return DesktopAppSettings.of({
532 get: SynchronizedRef.get(settingsRef),
533 load: Effect.gen(function* () {
534 const settings = yield* readSettings(
535 fileSystem,
536 environment.desktopSettingsPath,
537 environment.appVersion,
538 );
539 return yield* SynchronizedRef.setAndGet(settingsRef, settings);
540 }).pipe(Effect.withSpan("desktop.settings.load")),
541 setMainWindowBounds: (bounds, isMaximized) =>
542 persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe(
543 Effect.withSpan("desktop.settings.setMainWindowBounds", {
544 attributes: {
545 x: bounds.x,
546 y: bounds.y,
547 width: bounds.width,
548 height: bounds.height,
549 isMaximized,
550 },
551 }),
552 ),
553 setServerExposureMode: (mode) =>
554 persist((settings) => setServerExposureMode(settings, mode)).pipe(
555 Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }),
556 ),
557 setTailscaleServe: (input) =>
558 persist((settings) => setTailscaleServe(settings, input)).pipe(
559 Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }),
560 ),
561 setUpdateChannel: (channel) =>
562 persist((settings) => setUpdateChannel(settings, channel)).pipe(
563 Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }),
564 ),
565 setWslBackendEnabled: (enabled) =>
566 persist((settings) => setWslBackendEnabled(settings, enabled)).pipe(
567 Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }),
568 ),
569 setWslDistro: (distro) =>
570 persist((settings) => setWslDistro(settings, distro)).pipe(
571 Effect.withSpan("desktop.settings.setWslDistro", {
572 attributes: { distro: distro ?? null },
573 }),
574 ),
575 setWslOnly: (enabled) =>
576 persist((settings) => setWslOnly(settings, enabled)).pipe(
577 Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }),
578 ),
579 setLocalEnvironmentEnabled: (enabled) =>
580 persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe(
581 Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }),
582 ),
583 applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe(
584 Effect.withSpan("desktop.settings.applyWslWindowsFallback"),
585 ),
586 applyWslWindowsFallbackInMemory: updateInMemory(applyWslWindowsFallback).pipe(
587 Effect.withSpan("desktop.settings.applyWslWindowsFallbackInMemory"),
588 ),
589 });
590});
591
592export const layer = Layer.effect(DesktopAppSettings, make);
593
594export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) =>
595 Layer.effect(
596 DesktopAppSettings,
597 Effect.gen(function* () {
598 const settingsRef = yield* SynchronizedRef.make(initialSettings);
599 const update = (f: (settings: DesktopSettings) => DesktopSettings) =>
600 SynchronizedRef.modify(settingsRef, (settings) => {
601 const nextSettings = f(settings);
602 return [
603 {
604 settings: nextSettings,
605 changed: nextSettings !== settings,
606 },
607 nextSettings,
608 ] as const;
609 });
610
611 return DesktopAppSettings.of({
612 get: SynchronizedRef.get(settingsRef),
613 load: SynchronizedRef.get(settingsRef),
614 setMainWindowBounds: (bounds, isMaximized) =>
615 update((settings) => setMainWindowBounds(settings, bounds, isMaximized)),
616 setServerExposureMode: (mode) =>
617 update((settings) => setServerExposureMode(settings, mode)),
618 setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)),
619 setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)),
620 setWslBackendEnabled: (enabled) =>
621 update((settings) => setWslBackendEnabled(settings, enabled)),
622 setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)),
623 setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)),
624 setLocalEnvironmentEnabled: (enabled) =>
625 update((settings) => setLocalEnvironmentEnabled(settings, enabled)),
626 applyWslWindowsFallback: update(applyWslWindowsFallback),
627 applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback),
628 });
629 }),
630 );