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 SecureRandom from "effect/crypto/SecureRandom";
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 PlatformError from "effect/PlatformError";
17import * as Ref from "effect/Ref";
18import * as Schema from "effect/Schema";
19import * as SynchronizedRef from "effect/SynchronizedRef";
20
21import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
22import {
23 DEFAULT_LINUX_PASSWORD_STORE,
24 normalizeLinuxPasswordStorePreference,
25 type LinuxPasswordStorePreference,
26} from "../linuxSecretStorage.ts";
27import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease";
28import { isValidDistroName } from "../wsl/wslPathParsing.ts";
29
30export interface DesktopSettings {
31 readonly localEnvironmentEnabled: boolean;
32 readonly linuxPasswordStore: LinuxPasswordStorePreference;
33 readonly mainWindowBounds: DesktopWindowBounds | null;
34 readonly mainWindowMaximized: boolean;
35 readonly serverExposureMode: DesktopServerExposureMode;
36 readonly tailscaleServeEnabled: boolean;
37 readonly tailscaleServePort: number;
38 readonly updateChannel: DesktopUpdateChannel;
39 readonly updateChannelConfiguredByUser: boolean;
40 readonly wslBackendEnabled: boolean;
41 readonly wslDistro: string | null;
42 // Requires restart because the pool chooses its primary spec at layer initialization.
43 readonly wslOnly: boolean;
44}
45
46export interface DesktopSettingsChange {
47 readonly settings: DesktopSettings;
48 readonly changed: boolean;
49}
50
51const DEFAULT_TAILSCALE_SERVE_PORT = 443;
52// Fits half and third tiles of common scaled displays (down to 480x432 DIP) on
53// Wayland, where Electron's client-side frame adds 32x42 DIP to the enforced
54// minimum. The web layout already handles phone widths.
55export const MIN_MAIN_WINDOW_SIZE = {
56 width: 400,
57 height: 360,
58} as const;
59export const DesktopWindowBoundsSchema = Schema.Struct({
60 x: Schema.Int,
61 y: Schema.Int,
62 width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)),
63 height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)),
64});
65export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type;
66export const DEFAULT_MAIN_WINDOW_SIZE = {
67 width: 1100,
68 height: 780,
69} as const;
70
71export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
72 localEnvironmentEnabled: true,
73 linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE,
74 mainWindowBounds: null,
75 mainWindowMaximized: false,
76 serverExposureMode: "local-only",
77 tailscaleServeEnabled: false,
78 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
79 updateChannel: "latest",
80 updateChannelConfiguredByUser: false,
81 wslBackendEnabled: false,
82 wslDistro: null,
83 wslOnly: false,
84};
85
86const DesktopWindowBoundsDocument = Schema.Struct({
87 x: Schema.Number,
88 y: Schema.Number,
89 width: Schema.Number,
90 height: Schema.Number,
91});
92
93// A value this build cannot read drops only its own key, so the rest of the file
94// still loads and is written back instead of every setting resetting to defaults.
95const optionalReadableKey = <S extends Schema.Top>(schema: S) =>
96 Schema.optionalKey(schema.pipe(Schema.catchDecoding(() => Effect.succeedNone)));
97
98const DesktopSettingsDocument = Schema.Struct({
99 localEnvironmentEnabled: optionalReadableKey(Schema.Boolean),
100 linuxPasswordStore: optionalReadableKey(Schema.Unknown),
101 mainWindowBounds: optionalReadableKey(Schema.NullOr(DesktopWindowBoundsDocument)),
102 mainWindowMaximized: optionalReadableKey(Schema.Boolean),
103 serverExposureMode: optionalReadableKey(DesktopServerExposureModeSchema),
104 tailscaleServeEnabled: optionalReadableKey(Schema.Boolean),
105 tailscaleServePort: optionalReadableKey(Schema.Number),
106 updateChannel: optionalReadableKey(DesktopUpdateChannelSchema),
107 updateChannelConfiguredByUser: optionalReadableKey(Schema.Boolean),
108 wslBackendEnabled: optionalReadableKey(Schema.Boolean),
109 wslMode: optionalReadableKey(Schema.Literals(["local", "wsl"])),
110 wslDistro: optionalReadableKey(Schema.NullOr(Schema.String)),
111 wslOnly: optionalReadableKey(Schema.Boolean),
112});
113
114type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type;
115type Mutable<T> = { -readonly [K in keyof T]: T[K] };
116
117const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument);
118const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson);
119const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson);
120const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema);
121const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema);
122
123const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({
124 settings,
125 changed,
126});
127
128const DesktopSettingsWriteOperation = Schema.Literals([
129 "create-temporary-file-name",
130 "resolve-symlink",
131 "encode-document",
132 "create-directory",
133 "write-temporary-file",
134 "replace-settings-file",
135]);
136type DesktopSettingsWriteOperation = typeof DesktopSettingsWriteOperation.Type;
137
138export class DesktopSettingsWriteError extends Schema.TaggedError<DesktopSettingsWriteError>()(
139 "DesktopSettingsWriteError",
140 {
141 operation: DesktopSettingsWriteOperation,
142 path: Schema.String,
143 cause: Schema.Defect(),
144 },
145) {
146 override get message(): string {
147 return `Desktop settings write failed during ${this.operation} at ${this.path}.`;
148 }
149}
150
151export class DesktopAppSettings extends Context.Service<
152 DesktopAppSettings,
153 {
154 readonly load: Effect.Effect<DesktopSettings>;
155 readonly get: Effect.Effect<DesktopSettings>;
156 readonly setLocalEnvironmentEnabled: (
157 enabled: boolean,
158 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
159 readonly setMainWindowBounds: (
160 bounds: DesktopWindowBounds,
161 isMaximized: boolean,
162 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
163 readonly setServerExposureMode: (
164 mode: DesktopServerExposureMode,
165 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
166 readonly setTailscaleServe: (input: {
167 readonly enabled: boolean;
168 readonly port: Option.Option<number>;
169 }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
170 readonly setUpdateChannel: (
171 channel: DesktopUpdateChannel,
172 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
173 readonly setWslBackendEnabled: (
174 enabled: boolean,
175 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
176 readonly setWslDistro: (
177 distro: string | null,
178 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
179 readonly setWslOnly: (
180 enabled: boolean,
181 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
182 readonly applyWslWindowsFallback: Effect.Effect<
183 DesktopSettingsChange,
184 DesktopSettingsWriteError
185 >;
186 readonly applyWslWindowsFallbackInMemory: Effect.Effect<DesktopSettingsChange>;
187 }
188>()("@t3tools/desktop/settings/DesktopAppSettings") {}
189
190export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
191 return {
192 ...DEFAULT_DESKTOP_SETTINGS,
193 updateChannel: desktopUpdateChannelOf(appVersion),
194 };
195}
196
197function normalizeTailscaleServePort(value: unknown): number {
198 return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535
199 ? value
200 : DEFAULT_TAILSCALE_SERVE_PORT;
201}
202
203function normalizeWslDistro(value: unknown): string | null {
204 return typeof value === "string" && isValidDistroName(value) ? value : null;
205}
206
207export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null {
208 return Option.getOrNull(decodeDesktopWindowBounds(value));
209}
210
211function normalizeDesktopSettingsDocument(
212 parsed: DesktopSettingsDocument,
213 appVersion: string,
214): DesktopSettings {
215 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
216 const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds);
217 const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel);
218 const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
219 const updateChannelConfiguredByUser =
220 parsed.updateChannelConfiguredByUser === true ||
221 (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly"));
222
223 const wslBackendEnabled =
224 parsed.wslBackendEnabled === true ||
225 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");
226
227 return {
228 localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false,
229 linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore),
230 mainWindowBounds,
231 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,
232 serverExposureMode:
233 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
234 tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
235 tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
236 updateChannel: updateChannelConfiguredByUser
237 ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel)
238 : defaultSettings.updateChannel,
239 updateChannelConfiguredByUser,
240 wslBackendEnabled,
241 wslDistro: normalizeWslDistro(parsed.wslDistro),
242 wslOnly: parsed.wslOnly === true,
243 };
244}
245
246function toDesktopSettingsDocument(
247 settings: DesktopSettings,
248 defaults: DesktopSettings,
249): DesktopSettingsDocument {
250 const document: Mutable<DesktopSettingsDocument> = {};
251
252 if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) {
253 document.localEnvironmentEnabled = settings.localEnvironmentEnabled;
254 }
255
256 if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) {
257 document.linuxPasswordStore = settings.linuxPasswordStore;
258 }
259 if (settings.mainWindowBounds !== null) {
260 document.mainWindowBounds = settings.mainWindowBounds;
261 }
262 if (settings.mainWindowMaximized) {
263 document.mainWindowMaximized = true;
264 }
265 if (settings.serverExposureMode !== defaults.serverExposureMode) {
266 document.serverExposureMode = settings.serverExposureMode;
267 }
268 if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) {
269 document.tailscaleServeEnabled = settings.tailscaleServeEnabled;
270 }
271 if (settings.tailscaleServePort !== defaults.tailscaleServePort) {
272 document.tailscaleServePort = settings.tailscaleServePort;
273 }
274 if (settings.updateChannel !== defaults.updateChannel) {
275 document.updateChannel = settings.updateChannel;
276 }
277 if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) {
278 document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser;
279 }
280 if (settings.wslBackendEnabled !== defaults.wslBackendEnabled) {
281 document.wslBackendEnabled = settings.wslBackendEnabled;
282 }
283 if (settings.wslDistro !== defaults.wslDistro) {
284 document.wslDistro = settings.wslDistro;
285 }
286 if (settings.wslOnly !== defaults.wslOnly) {
287 document.wslOnly = settings.wslOnly;
288 }
289
290 return document;
291}
292
293function setServerExposureMode(
294 settings: DesktopSettings,
295 requestedMode: DesktopServerExposureMode,
296): DesktopSettings {
297 return settings.serverExposureMode === requestedMode
298 ? settings
299 : {
300 ...settings,
301 serverExposureMode: requestedMode,
302 };
303}
304
305function setMainWindowBounds(
306 settings: DesktopSettings,
307 bounds: DesktopWindowBounds,
308 isMaximized: boolean,
309): DesktopSettings {
310 return settings.mainWindowBounds !== null &&
311 desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) &&
312 settings.mainWindowMaximized === isMaximized
313 ? settings
314 : {
315 ...settings,
316 mainWindowBounds: bounds,
317 mainWindowMaximized: isMaximized,
318 };
319}
320
321function setTailscaleServe(
322 settings: DesktopSettings,
323 input: { readonly enabled: boolean; readonly port: Option.Option<number> },
324): DesktopSettings {
325 const port = Option.match(input.port, {
326 onNone: () => settings.tailscaleServePort,
327 onSome: normalizeTailscaleServePort,
328 });
329 return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port
330 ? settings
331 : {
332 ...settings,
333 tailscaleServeEnabled: input.enabled,
334 tailscaleServePort: port,
335 };
336}
337
338function setUpdateChannel(
339 settings: DesktopSettings,
340 requestedChannel: DesktopUpdateChannel,
341): DesktopSettings {
342 return settings.updateChannel === requestedChannel
343 ? settings
344 : {
345 ...settings,
346 updateChannel: requestedChannel,
347 updateChannelConfiguredByUser: true,
348 };
349}
350
351function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings {
352 return settings.wslBackendEnabled === enabled
353 ? settings
354 : {
355 ...settings,
356 wslBackendEnabled: enabled,
357 };
358}
359
360function setWslDistro(settings: DesktopSettings, distro: string | null): DesktopSettings {
361 const normalized = normalizeWslDistro(distro);
362 return settings.wslDistro === normalized
363 ? settings
364 : {
365 ...settings,
366 wslDistro: normalized,
367 };
368}
369
370function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSettings {
371 return settings.wslOnly === enabled
372 ? settings
373 : {
374 ...settings,
375 wslOnly: enabled,
376 };
377}
378
379function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings {
380 return settings.localEnvironmentEnabled === enabled
381 ? settings
382 : { ...settings, localEnvironmentEnabled: enabled };
383}
384
385function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings {
386 return setWslOnly(setWslBackendEnabled(settings, false), false);
387}
388
389// Fails only when the file may exist but could not be read; a missing file means defaults.
390function readSettings(
391 fileSystem: FileSystem.FileSystem,
392 settingsPath: string,
393 appVersion: string,
394): Effect.Effect<DesktopSettings, PlatformError.PlatformError> {
395 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
396
397 return fileSystem.readFileString(settingsPath).pipe(
398 Effect.asSome,
399 Effect.catchIf(
400 (cause) => cause.reason._tag === "NotFound",
401 () => Effect.succeed(Option.none<string>()),
402 ),
403 Effect.flatMap(
404 Option.match({
405 onNone: () => Effect.succeed(defaultSettings),
406 onSome: (raw) =>
407 decodeDesktopSettingsJson(raw).pipe(
408 Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)),
409 Effect.orElseSucceed(() => defaultSettings),
410 ),
411 }),
412 ),
413 );
414}
415
416const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: {
417 readonly fileSystem: FileSystem.FileSystem;
418 readonly path: Path.Path;
419 readonly settingsPath: string;
420 readonly settings: DesktopSettings;
421 readonly defaultSettings: DesktopSettings;
422 readonly suffix: string;
423}): Effect.fn.Return<void, DesktopSettingsWriteError> {
424 const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe(
425 Effect.provideService(FileSystem.FileSystem, input.fileSystem),
426 Effect.provideService(Path.Path, input.path),
427 Effect.mapError(
428 (cause) =>
429 new DesktopSettingsWriteError({
430 operation: "resolve-symlink",
431 path: input.settingsPath,
432 cause,
433 }),
434 ),
435 );
436 const directory = input.path.dirname(targetPath);
437 const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`;
438 const encoded = yield* encodeDesktopSettingsJson(
439 toDesktopSettingsDocument(input.settings, input.defaultSettings),
440 ).pipe(
441 Effect.mapError(
442 (cause) =>
443 new DesktopSettingsWriteError({
444 operation: "encode-document",
445 path: input.settingsPath,
446 cause,
447 }),
448 ),
449 );
450 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
451 Effect.mapError(
452 (cause) =>
453 new DesktopSettingsWriteError({
454 operation: "create-directory",
455 path: directory,
456 cause,
457 }),
458 ),
459 );
460 yield* Effect.gen(function* () {
461 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
462 Effect.mapError(
463 (cause) =>
464 new DesktopSettingsWriteError({
465 operation: "write-temporary-file",
466 path: tempPath,
467 cause,
468 }),
469 ),
470 );
471 yield* input.fileSystem.rename(tempPath, targetPath).pipe(
472 Effect.mapError(
473 (cause) =>
474 new DesktopSettingsWriteError({
475 operation: "replace-settings-file",
476 path: input.settingsPath,
477 cause,
478 }),
479 ),
480 );
481 }).pipe(Effect.ensuring(input.fileSystem.remove(tempPath, { force: true }).pipe(Effect.ignore)));
482});
483
484/** @public */
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 random = yield* SecureRandom.SecureRandom;
490 const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings);
491 // Writing over a settings file we could not read would erase the preferences it holds.
492 const persistenceEnabled = yield* Ref.make(true);
493
494 const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) =>
495 SynchronizedRef.modify(settingsRef, (settings) => {
496 const nextSettings = update(settings);
497 return [settingsChange(nextSettings, nextSettings !== settings), nextSettings] as const;
498 });
499
500 const persist = (
501 update: (settings: DesktopSettings) => DesktopSettings,
502 ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> =>
503 SynchronizedRef.modifyEffect(settingsRef, (settings) => {
504 const nextSettings = update(settings);
505 if (nextSettings === settings) {
506 return Effect.succeed([settingsChange(settings, false), settings] as const);
507 }
508
509 return Ref.get(persistenceEnabled).pipe(
510 Effect.flatMap((enabled) =>
511 enabled
512 ? random.uuidV4().pipe(
513 Effect.map((uuid) => uuid.replace(/-/g, "")),
514 Effect.flatMap((suffix) =>
515 writeSettings({
516 fileSystem,
517 path,
518 settingsPath: environment.desktopSettingsPath,
519 settings: nextSettings,
520 defaultSettings: environment.defaultDesktopSettings,
521 suffix,
522 }),
523 ),
524 )
525 : Effect.void,
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 ).pipe(
539 Effect.catch((cause) =>
540 Effect.logWarning(
541 "Could not read desktop settings; using defaults without saving changes.",
542 cause,
543 ).pipe(
544 Effect.annotateLogs({ settingsPath: environment.desktopSettingsPath }),
545 Effect.andThen(Ref.set(persistenceEnabled, false)),
546 Effect.as(resolveDefaultDesktopSettings(environment.appVersion)),
547 ),
548 ),
549 );
550 return yield* SynchronizedRef.setAndGet(settingsRef, settings);
551 }).pipe(Effect.withSpan("desktop.settings.load")),
552 setMainWindowBounds: Effect.fn("desktop.settings.setMainWindowBounds")(
553 function* (bounds, isMaximized) {
554 yield* Effect.annotateCurrentSpan({
555 x: bounds.x,
556 y: bounds.y,
557 width: bounds.width,
558 height: bounds.height,
559 isMaximized,
560 });
561
562 return yield* persist((settings) => setMainWindowBounds(settings, bounds, isMaximized));
563 },
564 ),
565 setServerExposureMode: Effect.fn("desktop.settings.setServerExposureMode")(function* (mode) {
566 yield* Effect.annotateCurrentSpan({ mode });
567
568 return yield* persist((settings) => setServerExposureMode(settings, mode));
569 }),
570 setTailscaleServe: Effect.fn("desktop.settings.setTailscaleServe")(function* (input) {
571 yield* Effect.annotateCurrentSpan(input);
572
573 return yield* persist((settings) => setTailscaleServe(settings, input));
574 }),
575 setUpdateChannel: Effect.fn("desktop.settings.setUpdateChannel")(function* (channel) {
576 yield* Effect.annotateCurrentSpan({ channel });
577
578 return yield* persist((settings) => setUpdateChannel(settings, channel));
579 }),
580 setWslBackendEnabled: Effect.fn("desktop.settings.setWslBackendEnabled")(function* (enabled) {
581 yield* Effect.annotateCurrentSpan({ enabled });
582
583 return yield* persist((settings) => setWslBackendEnabled(settings, enabled));
584 }),
585 setWslDistro: Effect.fn("desktop.settings.setWslDistro")(function* (distro) {
586 yield* Effect.annotateCurrentSpan({ distro: distro ?? null });
587
588 return yield* persist((settings) => setWslDistro(settings, distro));
589 }),
590 setWslOnly: Effect.fn("desktop.settings.setWslOnly")(function* (enabled) {
591 yield* Effect.annotateCurrentSpan({ enabled });
592
593 return yield* persist((settings) => setWslOnly(settings, enabled));
594 }),
595 setLocalEnvironmentEnabled: Effect.fn("desktop.settings.setLocalEnvironmentEnabled")(
596 function* (enabled) {
597 yield* Effect.annotateCurrentSpan({ enabled });
598
599 return yield* persist((settings) => setLocalEnvironmentEnabled(settings, enabled));
600 },
601 ),
602 applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe(
603 Effect.withSpan("desktop.settings.applyWslWindowsFallback"),
604 ),
605 applyWslWindowsFallbackInMemory: updateInMemory(applyWslWindowsFallback).pipe(
606 Effect.withSpan("desktop.settings.applyWslWindowsFallbackInMemory"),
607 ),
608 });
609});
610
611export const layer = Layer.effect(DesktopAppSettings, make);
612
613export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) =>
614 Layer.effect(
615 DesktopAppSettings,
616 Effect.gen(function* () {
617 const settingsRef = yield* SynchronizedRef.make(initialSettings);
618 const update = (f: (settings: DesktopSettings) => DesktopSettings) =>
619 SynchronizedRef.modify(settingsRef, (settings) => {
620 const nextSettings = f(settings);
621 return [
622 {
623 settings: nextSettings,
624 changed: nextSettings !== settings,
625 },
626 nextSettings,
627 ] as const;
628 });
629
630 return DesktopAppSettings.of({
631 get: SynchronizedRef.get(settingsRef),
632 load: SynchronizedRef.get(settingsRef),
633 setMainWindowBounds: Effect.fn("DesktopAppSettings.setMainWindowBounds")(
634 function (bounds, isMaximized) {
635 return update((settings) => setMainWindowBounds(settings, bounds, isMaximized));
636 },
637 ),
638 setServerExposureMode: Effect.fn("DesktopAppSettings.setServerExposureMode")(
639 function (mode) {
640 return update((settings) => setServerExposureMode(settings, mode));
641 },
642 ),
643 setTailscaleServe: Effect.fn("DesktopAppSettings.setTailscaleServe")(function (input) {
644 return update((settings) => setTailscaleServe(settings, input));
645 }),
646 setUpdateChannel: Effect.fn("DesktopAppSettings.setUpdateChannel")(function (channel) {
647 return update((settings) => setUpdateChannel(settings, channel));
648 }),
649 setWslBackendEnabled: Effect.fn("DesktopAppSettings.setWslBackendEnabled")(
650 function (enabled) {
651 return update((settings) => setWslBackendEnabled(settings, enabled));
652 },
653 ),
654 setWslDistro: Effect.fn("DesktopAppSettings.setWslDistro")(function (distro) {
655 return update((settings) => setWslDistro(settings, distro));
656 }),
657 setWslOnly: Effect.fn("DesktopAppSettings.setWslOnly")(function (enabled) {
658 return update((settings) => setWslOnly(settings, enabled));
659 }),
660 setLocalEnvironmentEnabled: Effect.fn("DesktopAppSettings.setLocalEnvironmentEnabled")(
661 function (enabled) {
662 return update((settings) => setLocalEnvironmentEnabled(settings, enabled));
663 },
664 ),
665 applyWslWindowsFallback: update(applyWslWindowsFallback),
666 applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback),
667 });
668 }),
669 );