1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8
9import * as DesktopConfig from "../app/DesktopConfig.ts";
10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import * as DesktopAppSettings from "./DesktopAppSettings.ts";
12
13const DesktopSettingsPatch = Schema.Struct({
14 linuxPasswordStore: Schema.optionalKey(
15 Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]),
16 ),
17 mainWindowBounds: Schema.optionalKey(
18 Schema.NullOr(
19 Schema.Struct({
20 x: Schema.Number,
21 y: Schema.Number,
22 width: Schema.Number,
23 height: Schema.Number,
24 }),
25 ),
26 ),
27 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
28 serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])),
29 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
30 tailscaleServePort: Schema.optionalKey(Schema.Number),
31 updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])),
32 updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean),
33 wslBackendEnabled: Schema.optionalKey(Schema.Boolean),
34 wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])),
35 wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)),
36 wslOnly: Schema.optionalKey(Schema.Boolean),
37});
38
39const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
40const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
41
42function layerEnvironment(baseDir: string, appVersion = "0.0.17") {
43 return DesktopEnvironment.layer({
44 dirname: "/repo/apps/desktop/src",
45 homeDirectory: baseDir,
46 platform: "darwin",
47 processArch: "x64",
48 appVersion,
49 appPath: "/repo",
50 isPackaged: true,
51 resourcesPath: "/missing/resources",
52 runningUnderArm64Translation: false,
53 }).pipe(
54 Layer.provide(
55 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
56 ),
57 );
58}
59
60const withSettings = <A, E, R>(
61 effect: Effect.Effect<
62 A,
63 E,
64 R | DesktopAppSettings.DesktopAppSettings | DesktopEnvironment.DesktopEnvironment
65 >,
66 options?: { readonly appVersion?: string },
67) =>
68 Effect.gen(function* () {
69 const fileSystem = yield* FileSystem.FileSystem;
70 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
71 prefix: "t3-desktop-settings-test-",
72 });
73 return yield* effect.pipe(
74 Effect.provide(
75 DesktopAppSettings.layer.pipe(
76 Layer.provideMerge(layerEnvironment(baseDir, options?.appVersion)),
77 Layer.provideMerge(NodeServices.layer),
78 ),
79 ),
80 );
81 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
82
83function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) {
84 return Effect.gen(function* () {
85 const environment = yield* DesktopEnvironment.DesktopEnvironment;
86 const fileSystem = yield* FileSystem.FileSystem;
87 const encoded = yield* encodeDesktopSettingsPatch(patch);
88 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
89 yield* fileSystem.writeFileString(environment.desktopSettingsPath, `${encoded}\n`);
90 });
91}
92
93describe("DesktopSettings", () => {
94 it.effect(
95 "persists disabling and re-enabling local execution without clearing backend settings",
96 () =>
97 withSettings(
98 Effect.gen(function* () {
99 const settings = yield* DesktopAppSettings.DesktopAppSettings;
100 yield* settings.setWslBackendEnabled(true);
101 yield* settings.setWslDistro("Ubuntu");
102 yield* settings.setServerExposureMode("network-accessible");
103 const before = yield* settings.get;
104 assert.isTrue((yield* settings.setLocalEnvironmentEnabled(false)).changed);
105 assert.deepEqual(yield* settings.load, { ...before, localEnvironmentEnabled: false });
106 assert.isFalse((yield* settings.setLocalEnvironmentEnabled(false)).changed);
107 yield* settings.setLocalEnvironmentEnabled(true);
108 assert.deepEqual(yield* settings.load, before);
109 }),
110 ),
111 );
112 it.effect("loads defaults when no settings file exists", () =>
113 withSettings(
114 Effect.gen(function* () {
115 const settings = yield* DesktopAppSettings.DesktopAppSettings;
116 assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
117 assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
118 }),
119 ),
120 );
121
122 it("defaults packaged nightly builds to the nightly update channel", () => {
123 assert.deepEqual(
124 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),
125 {
126 linuxPasswordStore: "auto",
127 localEnvironmentEnabled: true,
128 mainWindowBounds: null,
129 mainWindowMaximized: false,
130 serverExposureMode: "local-only",
131 tailscaleServeEnabled: false,
132 tailscaleServePort: 443,
133 updateChannel: "nightly",
134 updateChannelConfiguredByUser: false,
135 wslBackendEnabled: false,
136 wslOnly: false,
137 wslDistro: null,
138 } satisfies DesktopAppSettings.DesktopSettings,
139 );
140 });
141
142 it.effect("loads persisted settings and applies semantic updates", () =>
143 withSettings(
144 Effect.gen(function* () {
145 const settings = yield* DesktopAppSettings.DesktopAppSettings;
146 yield* writeSettingsPatch({
147 linuxPasswordStore: "gnome-libsecret",
148 serverExposureMode: "network-accessible",
149 tailscaleServeEnabled: true,
150 tailscaleServePort: 8443,
151 updateChannel: "latest",
152 updateChannelConfiguredByUser: true,
153 });
154
155 assert.deepEqual(yield* settings.load, {
156 linuxPasswordStore: "gnome-libsecret",
157 localEnvironmentEnabled: true,
158 mainWindowBounds: null,
159 mainWindowMaximized: false,
160 serverExposureMode: "network-accessible",
161 tailscaleServeEnabled: true,
162 tailscaleServePort: 8443,
163 updateChannel: "latest",
164 updateChannelConfiguredByUser: true,
165 wslBackendEnabled: false,
166 wslOnly: false,
167 wslDistro: null,
168 } satisfies DesktopAppSettings.DesktopSettings);
169
170 const exposure = yield* settings.setServerExposureMode("local-only");
171 assert.isTrue(exposure.changed);
172 assert.equal(exposure.settings.serverExposureMode, "local-only");
173
174 const tailscale = yield* settings.setTailscaleServe({
175 enabled: true,
176 port: Option.some(9443),
177 });
178 assert.isTrue(tailscale.changed);
179 assert.equal(tailscale.settings.tailscaleServePort, 9443);
180
181 const updateChannel = yield* settings.setUpdateChannel("nightly");
182 assert.isTrue(updateChannel.changed);
183 assert.equal(updateChannel.settings.updateChannel, "nightly");
184 assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true);
185 }),
186 ),
187 );
188
189 it.effect("reports the failed desktop settings write operation and path", () =>
190 withSettings(
191 Effect.gen(function* () {
192 const environment = yield* DesktopEnvironment.DesktopEnvironment;
193 const fileSystem = yield* FileSystem.FileSystem;
194 const settings = yield* DesktopAppSettings.DesktopAppSettings;
195 yield* fileSystem.makeDirectory(environment.desktopSettingsPath, { recursive: true });
196
197 const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip);
198 assert.instanceOf(error, DesktopAppSettings.DesktopSettingsWriteError);
199 assert.equal(error.operation, "replace-settings-file");
200 assert.equal(error.path, environment.desktopSettingsPath);
201 assert.exists(error.cause);
202 assert.equal(
203 error.message,
204 `Desktop settings write failed during replace-settings-file at ${environment.desktopSettingsPath}.`,
205 );
206 }),
207 ),
208 );
209
210 it.effect("does not persist no-op semantic updates", () =>
211 withSettings(
212 Effect.gen(function* () {
213 const settings = yield* DesktopAppSettings.DesktopAppSettings;
214
215 const exposure = yield* settings.setServerExposureMode("local-only");
216 assert.isFalse(exposure.changed);
217
218 const tailscale = yield* settings.setTailscaleServe({
219 enabled: false,
220 port: Option.none(),
221 });
222 assert.isFalse(tailscale.changed);
223
224 const updateChannel = yield* settings.setUpdateChannel("latest");
225 assert.isFalse(updateChannel.changed);
226 assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false);
227 }),
228 ),
229 );
230
231 it.effect("falls back to defaults when the settings file is malformed", () =>
232 withSettings(
233 Effect.gen(function* () {
234 const environment = yield* DesktopEnvironment.DesktopEnvironment;
235 const fileSystem = yield* FileSystem.FileSystem;
236 const settings = yield* DesktopAppSettings.DesktopAppSettings;
237 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
238 yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json");
239
240 assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
241 }),
242 ),
243 );
244
245 it.effect("loads lenient persisted desktop settings JSON", () =>
246 withSettings(
247 Effect.gen(function* () {
248 const environment = yield* DesktopEnvironment.DesktopEnvironment;
249 const fileSystem = yield* FileSystem.FileSystem;
250 const settings = yield* DesktopAppSettings.DesktopAppSettings;
251 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
252 yield* fileSystem.writeFileString(
253 environment.desktopSettingsPath,
254 `{
255 // JSONC-style comments and trailing commas match server settings parsing.
256 "serverExposureMode": "network-accessible",
257 "tailscaleServeEnabled": true,
258 "tailscaleServePort": 8443,
259 "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 },
260 }\n`,
261 );
262
263 assert.deepEqual(yield* settings.load, {
264 linuxPasswordStore: "auto",
265 localEnvironmentEnabled: true,
266 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },
267 mainWindowMaximized: false,
268 serverExposureMode: "network-accessible",
269 tailscaleServeEnabled: true,
270 tailscaleServePort: 8443,
271 updateChannel: "latest",
272 updateChannelConfiguredByUser: false,
273 wslBackendEnabled: false,
274 wslOnly: false,
275 wslDistro: null,
276 } satisfies DesktopAppSettings.DesktopSettings);
277 }),
278 ),
279 );
280
281 it.effect("rejects window bounds that do not satisfy the domain schema", () =>
282 withSettings(
283 Effect.gen(function* () {
284 const settings = yield* DesktopAppSettings.DesktopAppSettings;
285 yield* writeSettingsPatch({
286 mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 },
287 mainWindowMaximized: true,
288 serverExposureMode: "network-accessible",
289 });
290
291 const loaded = yield* settings.load;
292 assert.isNull(loaded.mainWindowBounds);
293 assert.isFalse(loaded.mainWindowMaximized);
294 assert.equal(loaded.serverExposureMode, "network-accessible");
295 }),
296 ),
297 );
298
299 it.effect(
300 "normalizes unsupported linux password-store values without dropping other settings",
301 () =>
302 withSettings(
303 Effect.gen(function* () {
304 const environment = yield* DesktopEnvironment.DesktopEnvironment;
305 const fileSystem = yield* FileSystem.FileSystem;
306 const settings = yield* DesktopAppSettings.DesktopAppSettings;
307 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
308 yield* fileSystem.writeFileString(
309 environment.desktopSettingsPath,
310 `{
311 "linuxPasswordStore": "unsupported-store",
312 "serverExposureMode": "network-accessible",
313 "tailscaleServeEnabled": true,
314 "tailscaleServePort": 8443,
315 "updateChannel": "nightly",
316 "updateChannelConfiguredByUser": true
317 }\n`,
318 );
319
320 assert.deepEqual(yield* settings.load, {
321 linuxPasswordStore: "auto",
322 localEnvironmentEnabled: true,
323 mainWindowBounds: null,
324 mainWindowMaximized: false,
325 serverExposureMode: "network-accessible",
326 tailscaleServeEnabled: true,
327 tailscaleServePort: 8443,
328 updateChannel: "nightly",
329 updateChannelConfiguredByUser: true,
330 wslBackendEnabled: false,
331 wslOnly: false,
332 wslDistro: null,
333 } satisfies DesktopAppSettings.DesktopSettings);
334 }),
335 ),
336 );
337
338 it.effect("persists sparse desktop settings documents", () =>
339 withSettings(
340 Effect.gen(function* () {
341 const environment = yield* DesktopEnvironment.DesktopEnvironment;
342 const fileSystem = yield* FileSystem.FileSystem;
343 const settings = yield* DesktopAppSettings.DesktopAppSettings;
344
345 yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true);
346 yield* settings.setServerExposureMode("network-accessible");
347
348 const persisted = yield* decodeDesktopSettingsPatch(
349 yield* fileSystem.readFileString(environment.desktopSettingsPath),
350 );
351 assert.deepEqual(persisted, {
352 mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 },
353 mainWindowMaximized: true,
354 serverExposureMode: "network-accessible",
355 } satisfies typeof DesktopSettingsPatch.Type);
356 }),
357 ),
358 );
359
360 it.effect("saves through a symlinked settings file without replacing the link", () =>
361 withSettings(
362 Effect.gen(function* () {
363 const environment = yield* DesktopEnvironment.DesktopEnvironment;
364 const fileSystem = yield* FileSystem.FileSystem;
365 const settings = yield* DesktopAppSettings.DesktopAppSettings;
366 const dotfiles = yield* fileSystem.makeTempDirectoryScoped({
367 prefix: "t3-desktop-settings-dotfiles-",
368 });
369 const linkedSettingsPath = `${dotfiles}/desktop-settings.json`;
370 yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n");
371 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
372 yield* fileSystem.symlink(linkedSettingsPath, environment.desktopSettingsPath);
373
374 yield* settings.setServerExposureMode("network-accessible");
375
376 assert.equal(
377 yield* fileSystem.readLink(environment.desktopSettingsPath),
378 linkedSettingsPath,
379 );
380 const persisted = yield* decodeDesktopSettingsPatch(
381 yield* fileSystem.readFileString(linkedSettingsPath),
382 );
383 assert.equal(persisted.serverExposureMode, "network-accessible");
384 }),
385 ),
386 );
387
388 it.effect("migrates legacy implicit update channels to the runtime default", () =>
389 withSettings(
390 Effect.gen(function* () {
391 const settings = yield* DesktopAppSettings.DesktopAppSettings;
392 yield* writeSettingsPatch({
393 serverExposureMode: "local-only",
394 updateChannel: "latest",
395 });
396
397 assert.deepEqual(yield* settings.load, {
398 linuxPasswordStore: "auto",
399 localEnvironmentEnabled: true,
400 mainWindowBounds: null,
401 mainWindowMaximized: false,
402 serverExposureMode: "local-only",
403 tailscaleServeEnabled: false,
404 tailscaleServePort: 443,
405 updateChannel: "nightly",
406 updateChannelConfiguredByUser: false,
407 wslBackendEnabled: false,
408 wslOnly: false,
409 wslDistro: null,
410 } satisfies DesktopAppSettings.DesktopSettings);
411 }),
412 { appVersion: "0.0.17-nightly.20260415.1" },
413 ),
414 );
415
416 it.effect("preserves explicit stable update channel on nightly builds", () =>
417 withSettings(
418 Effect.gen(function* () {
419 const settings = yield* DesktopAppSettings.DesktopAppSettings;
420 yield* writeSettingsPatch({
421 serverExposureMode: "local-only",
422 updateChannel: "latest",
423 updateChannelConfiguredByUser: true,
424 });
425
426 assert.deepEqual(yield* settings.load, {
427 linuxPasswordStore: "auto",
428 localEnvironmentEnabled: true,
429 mainWindowBounds: null,
430 mainWindowMaximized: false,
431 serverExposureMode: "local-only",
432 tailscaleServeEnabled: false,
433 tailscaleServePort: 443,
434 updateChannel: "latest",
435 updateChannelConfiguredByUser: true,
436 wslBackendEnabled: false,
437 wslOnly: false,
438 wslDistro: null,
439 } satisfies DesktopAppSettings.DesktopSettings);
440 }),
441 { appVersion: "0.0.17-nightly.20260415.1" },
442 ),
443 );
444
445 it.effect("normalizes invalid persisted Tailscale Serve ports", () =>
446 withSettings(
447 Effect.gen(function* () {
448 const settings = yield* DesktopAppSettings.DesktopAppSettings;
449 yield* writeSettingsPatch({
450 tailscaleServeEnabled: true,
451 tailscaleServePort: 0,
452 });
453
454 assert.deepEqual(yield* settings.load, {
455 linuxPasswordStore: "auto",
456 localEnvironmentEnabled: true,
457 mainWindowBounds: null,
458 mainWindowMaximized: false,
459 serverExposureMode: "local-only",
460 tailscaleServeEnabled: true,
461 tailscaleServePort: 443,
462 updateChannel: "latest",
463 updateChannelConfiguredByUser: false,
464 wslBackendEnabled: false,
465 wslOnly: false,
466 wslDistro: null,
467 } satisfies DesktopAppSettings.DesktopSettings);
468 }),
469 ),
470 );
471
472 it.effect("persists wsl backend toggle and normalizes invalid distro names", () =>
473 withSettings(
474 Effect.gen(function* () {
475 const settings = yield* DesktopAppSettings.DesktopAppSettings;
476 const enable = yield* settings.setWslBackendEnabled(true);
477 assert.isTrue(enable.changed);
478 assert.equal(enable.settings.wslBackendEnabled, true);
479
480 const distro = yield* settings.setWslDistro("Ubuntu-22.04");
481 assert.isTrue(distro.changed);
482 assert.equal(distro.settings.wslDistro, "Ubuntu-22.04");
483
484 const reloaded = yield* settings.load;
485 assert.equal(reloaded.wslBackendEnabled, true);
486 assert.equal(reloaded.wslDistro, "Ubuntu-22.04");
487
488 const reject = yield* settings.setWslDistro("bad name!");
489 assert.equal(reject.settings.wslDistro, null);
490
491 const noop = yield* settings.setWslDistro(null);
492 assert.isFalse(noop.changed);
493 }),
494 ),
495 );
496
497 it.effect("applies WSL Windows fallback with persisted and volatile updates", () =>
498 withSettings(
499 Effect.gen(function* () {
500 const settings = yield* DesktopAppSettings.DesktopAppSettings;
501 yield* settings.setWslBackendEnabled(true);
502 yield* settings.setWslOnly(true);
503
504 const persistedFallback = yield* settings.applyWslWindowsFallback;
505 assert.isTrue(persistedFallback.changed);
506 assert.equal(persistedFallback.settings.wslBackendEnabled, false);
507 assert.equal(persistedFallback.settings.wslOnly, false);
508
509 const persistedReload = yield* settings.load;
510 assert.equal(persistedReload.wslBackendEnabled, false);
511 assert.equal(persistedReload.wslOnly, false);
512
513 yield* settings.setWslBackendEnabled(true);
514 yield* settings.setWslOnly(true);
515
516 const volatileFallback = yield* settings.applyWslWindowsFallbackInMemory;
517 assert.isTrue(volatileFallback.changed);
518 assert.equal(volatileFallback.settings.wslBackendEnabled, false);
519 assert.equal(volatileFallback.settings.wslOnly, false);
520
521 const current = yield* settings.get;
522 assert.equal(current.wslBackendEnabled, false);
523 assert.equal(current.wslOnly, false);
524
525 const diskReload = yield* settings.load;
526 assert.equal(diskReload.wslBackendEnabled, true);
527 assert.equal(diskReload.wslOnly, true);
528 }),
529 ),
530 );
531
532 it.effect("migrates legacy wslMode=wsl to wslBackendEnabled on load", () =>
533 withSettings(
534 Effect.gen(function* () {
535 const settings = yield* DesktopAppSettings.DesktopAppSettings;
536 yield* writeSettingsPatch({
537 wslMode: "wsl",
538 wslDistro: "Ubuntu-22.04",
539 });
540 const loaded = yield* settings.load;
541 assert.equal(loaded.wslBackendEnabled, true);
542 assert.equal(loaded.wslDistro, "Ubuntu-22.04");
543 }),
544 ),
545 );
546
547 it.effect("drops invalid persisted wsl distro values on load", () =>
548 withSettings(
549 Effect.gen(function* () {
550 const settings = yield* DesktopAppSettings.DesktopAppSettings;
551 yield* writeSettingsPatch({
552 wslBackendEnabled: true,
553 wslDistro: "bad/name",
554 });
555 const loaded = yield* settings.load;
556 assert.equal(loaded.wslBackendEnabled, true);
557 assert.equal(loaded.wslDistro, null);
558 }),
559 ),
560 );
561});