1import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
2import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, it } from "@effect/vitest";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Sink from "effect/Sink";
11import * as Stream from "effect/Stream";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15import * as DesktopConfig from "../app/DesktopConfig.ts";
16import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
17import * as DesktopServerExposure from "./DesktopServerExposure.ts";
18import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
19
20const encoder = new TextEncoder();
21
22const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {};
23const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
24 en0: [
25 {
26 address: "192.168.1.20",
27 family: "IPv4",
28 internal: false,
29 },
30 ],
31};
32
33const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
34 tailscale0: [
35 {
36 address: "100.90.1.2",
37 family: "IPv4",
38 internal: false,
39 },
40 ],
41};
42
43function layerMockSpawner(statusJson = "{}") {
44 return Layer.succeed(
45 ChildProcessSpawner.ChildProcessSpawner,
46 ChildProcessSpawner.make(() =>
47 Effect.succeed(
48 ChildProcessSpawner.makeHandle({
49 pid: ChildProcessSpawner.ProcessId(1),
50 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
51 isRunning: Effect.succeed(false),
52 kill: () => Effect.void,
53 unref: Effect.succeed(Effect.void),
54 stdin: Sink.drain,
55 stdout: Stream.make(encoder.encode(statusJson)),
56 stderr: Stream.empty,
57 all: Stream.empty,
58 getInputFd: () => Sink.drain,
59 getOutputFd: () => Stream.empty,
60 }),
61 ),
62 ),
63 );
64}
65
66function layerDieOnSpawn() {
67 return Layer.succeed(
68 ChildProcessSpawner.ChildProcessSpawner,
69 ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")),
70 );
71}
72
73function layerEnvironmentFor(baseDir: string, env: Record<string, string | undefined> = {}) {
74 return DesktopEnvironment.layer({
75 dirname: "/repo/apps/desktop/src",
76 homeDirectory: baseDir,
77 platform: "darwin",
78 processArch: "x64",
79 appVersion: "1.2.3",
80 appPath: "/repo",
81 isPackaged: true,
82 resourcesPath: "/missing/resources",
83 runningUnderArm64Translation: false,
84 }).pipe(
85 Layer.provide(
86 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })),
87 ),
88 );
89}
90
91function layer(input: {
92 readonly baseDir: string;
93 readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces;
94 readonly env?: Record<string, string | undefined>;
95 readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
96 readonly desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>;
97}) {
98 const env = { T3CODE_HOME: input.baseDir, ...input.env };
99 const layerEnvironment = layerEnvironmentFor(input.baseDir, env);
100 const layerNetwork = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, {
101 read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces),
102 });
103
104 return DesktopServerExposure.layer.pipe(
105 Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer),
106 Layer.provideMerge(NodeFileSystem.layer),
107 Layer.provideMerge(NodeHttpClient.layerUndici),
108 Layer.provideMerge(input.spawnerLayer ?? layerMockSpawner()),
109 Layer.provideMerge(layerNetwork),
110 Layer.provideMerge(DesktopConfig.layerTest(env)),
111 Layer.provideMerge(layerEnvironment),
112 );
113}
114
115const withHarness = <A, E, R>(
116 networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces,
117 effect: Effect.Effect<
118 A,
119 E,
120 | R
121 | DesktopEnvironment.DesktopEnvironment
122 | FileSystem.FileSystem
123 | DesktopServerExposure.DesktopServerExposure
124 | DesktopAppSettings.DesktopAppSettings
125 >,
126 env: Record<string, string | undefined> = {},
127 spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
128 desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>,
129) =>
130 Effect.gen(function* () {
131 const fileSystem = yield* FileSystem.FileSystem;
132 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
133 prefix: "t3-desktop-server-exposure-test-",
134 });
135 return yield* effect.pipe(
136 Effect.provide(
137 layer({
138 baseDir,
139 networkInterfaces,
140 env,
141 ...(spawnerLayer ? { spawnerLayer } : {}),
142 ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}),
143 }),
144 ),
145 );
146 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
147
148describe("DesktopServerExposure", () => {
149 it.effect("falls back to local-only without losing the requested network preference", () =>
150 withHarness(
151 emptyNetworkInterfaces,
152 Effect.gen(function* () {
153 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
154 const settings = yield* DesktopAppSettings.DesktopAppSettings;
155
156 yield* settings.setServerExposureMode("network-accessible");
157
158 const state = yield* serverExposure.configureFromSettings({ port: 4173 });
159 assert.equal(state.mode, "local-only");
160 assert.equal(state.endpointUrl, null);
161 assert.equal((yield* settings.get).serverExposureMode, "network-accessible");
162
163 const backendConfig = yield* serverExposure.backendConfig;
164 assert.equal(backendConfig.bindHost, "127.0.0.1");
165 assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
166 }),
167 ),
168 );
169
170 it.effect("returns a typed error when network access is explicitly unavailable", () =>
171 withHarness(
172 emptyNetworkInterfaces,
173 Effect.gen(function* () {
174 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
175 yield* serverExposure.configureFromSettings({ port: 4173 });
176
177 const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip);
178 assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError");
179 assert.equal(error.port, 4173);
180 }),
181 ),
182 );
183
184 it.effect("persists network-accessible mode and updates backend binding state", () =>
185 withHarness(
186 lanNetworkInterfaces,
187 Effect.gen(function* () {
188 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
189 const settings = yield* DesktopAppSettings.DesktopAppSettings;
190
191 yield* settings.load;
192 yield* serverExposure.configureFromSettings({ port: 4173 });
193
194 const change = yield* serverExposure.setMode("network-accessible");
195 assert.equal(change.requiresRelaunch, true);
196 assert.deepEqual(change.state, {
197 mode: "network-accessible",
198 endpointUrl: "http://192.168.1.20:4173",
199 advertisedHost: "192.168.1.20",
200 tailscaleServeEnabled: false,
201 tailscaleServePort: 443,
202 });
203
204 const backendConfig = yield* serverExposure.backendConfig;
205 assert.equal(backendConfig.bindHost, "0.0.0.0");
206 assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
207
208 const persisted = yield* settings.get;
209 assert.equal(persisted.serverExposureMode, "network-accessible");
210 }),
211 ),
212 );
213
214 it.effect("persists tailscale serve preferences atomically and reports no-op updates", () =>
215 withHarness(
216 emptyNetworkInterfaces,
217 Effect.gen(function* () {
218 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
219 const settings = yield* DesktopAppSettings.DesktopAppSettings;
220
221 yield* settings.load;
222 yield* serverExposure.configureFromSettings({ port: 4173 });
223
224 const changed = yield* serverExposure.setTailscaleServeEnabled({
225 enabled: true,
226 port: 8443,
227 });
228 assert.equal(changed.requiresRelaunch, true);
229 assert.equal(changed.state.tailscaleServeEnabled, true);
230 assert.equal(changed.state.tailscaleServePort, 8443);
231
232 const unchanged = yield* serverExposure.setTailscaleServeEnabled({
233 enabled: true,
234 port: 8443,
235 });
236 assert.equal(unchanged.requiresRelaunch, false);
237
238 const persisted = yield* settings.get;
239 assert.equal(persisted.tailscaleServeEnabled, true);
240 assert.equal(persisted.tailscaleServePort, 8443);
241 }),
242 ),
243 );
244
245 it.effect("preserves persistence request context and the settings failure chain", () => {
246 const diskFailure = new Error("disk exploded");
247 const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({
248 operation: "replace-settings-file",
249 path: "/tmp/desktop-settings.json",
250 cause: diskFailure,
251 });
252 const layerSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
253 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
254 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
255 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),
256 setServerExposureMode: () => Effect.fail(settingsFailure),
257 setTailscaleServe: () => Effect.fail(settingsFailure),
258 setUpdateChannel: () => Effect.die("unexpected update channel change"),
259 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
260 setWslDistro: () => Effect.die("unexpected WSL distro change"),
261 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
262 setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
263 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
264 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
265 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
266
267 return withHarness(
268 lanNetworkInterfaces,
269 Effect.gen(function* () {
270 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
271 yield* serverExposure.configureFromSettings({ port: 4173 });
272
273 const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip);
274 assert.instanceOf(
275 modeError,
276 DesktopServerExposure.DesktopServerExposureModePersistenceError,
277 );
278 assert.equal(modeError.mode, "network-accessible");
279 assert.strictEqual(modeError.cause, settingsFailure);
280 assert.strictEqual(modeError.cause.cause, diskFailure);
281 assert.equal(
282 modeError.message,
283 "Failed to persist desktop server exposure mode network-accessible.",
284 );
285 assert.notInclude(modeError.message, diskFailure.message);
286
287 const tailscaleError = yield* serverExposure
288 .setTailscaleServeEnabled({ enabled: true, port: 8443 })
289 .pipe(Effect.flip);
290 assert.instanceOf(
291 tailscaleError,
292 DesktopServerExposure.DesktopTailscaleServePersistenceError,
293 );
294 assert.equal(tailscaleError.enabled, true);
295 assert.equal(tailscaleError.port, 8443);
296 assert.strictEqual(tailscaleError.cause, settingsFailure);
297 assert.strictEqual(tailscaleError.cause.cause, diskFailure);
298 assert.equal(
299 tailscaleError.message,
300 "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).",
301 );
302 assert.notInclude(tailscaleError.message, diskFailure.message);
303 }),
304 {},
305 undefined,
306 layerSettings,
307 );
308 });
309
310 it.effect("keeps a Tailscale Serve change made while a mode change is saving", () =>
311 Effect.gen(function* () {
312 const modeWriteStarted = yield* Deferred.make<void>();
313 const releaseModeWrite = yield* Deferred.make<void>();
314 const settingsLayer = Layer.effect(
315 DesktopAppSettings.DesktopAppSettings,
316 Effect.gen(function* () {
317 const settings = yield* DesktopAppSettings.DesktopAppSettings;
318 return DesktopAppSettings.DesktopAppSettings.of({
319 ...settings,
320 // Hold the mode write the way a slow disk would.
321 setServerExposureMode: (mode) =>
322 Deferred.succeed(modeWriteStarted, undefined).pipe(
323 Effect.andThen(Deferred.await(releaseModeWrite)),
324 Effect.andThen(settings.setServerExposureMode(mode)),
325 ),
326 });
327 }),
328 ).pipe(Layer.provide(DesktopAppSettings.layerTest()));
329
330 return yield* withHarness(
331 lanNetworkInterfaces,
332 Effect.gen(function* () {
333 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
334 yield* serverExposure.configureFromSettings({ port: 4173 });
335
336 const modeChange = yield* serverExposure
337 .setMode("network-accessible")
338 .pipe(Effect.forkChild({ startImmediately: true }));
339 yield* Deferred.await(modeWriteStarted);
340 const tailscaleChange = yield* serverExposure
341 .setTailscaleServeEnabled({ enabled: true, port: 8443 })
342 .pipe(Effect.forkChild({ startImmediately: true }));
343 yield* Deferred.succeed(releaseModeWrite, undefined);
344 yield* Fiber.join(modeChange);
345 yield* Fiber.join(tailscaleChange);
346
347 const state = yield* serverExposure.getState;
348 assert.equal(state.mode, "network-accessible");
349 assert.equal(state.tailscaleServeEnabled, true);
350 assert.equal(state.tailscaleServePort, 8443);
351 }),
352 {},
353 undefined,
354 settingsLayer,
355 );
356 }),
357 );
358
359 it.effect("keeps LAN and Tailscale endpoints distinct when Tailscale is enumerated first", () =>
360 withHarness(
361 { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces },
362 Effect.gen(function* () {
363 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
364 yield* serverExposure.configureFromSettings({ port: 4173 });
365 yield* serverExposure.setMode("network-accessible");
366
367 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
368 assert.deepEqual(
369 endpoints.map((endpoint) => endpoint.httpBaseUrl),
370 ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"],
371 );
372 }),
373 ),
374 );
375
376 it.effect("keeps Tailscale-only hosts network-accessible", () =>
377 withHarness(
378 tailnetNetworkInterfaces,
379 Effect.gen(function* () {
380 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
381 const settings = yield* DesktopAppSettings.DesktopAppSettings;
382 yield* settings.setServerExposureMode("network-accessible");
383
384 const state = yield* serverExposure.configureFromSettings({ port: 4173 });
385 assert.equal(state.mode, "network-accessible");
386 assert.equal(state.advertisedHost, null);
387 assert.equal(state.endpointUrl, null);
388 assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0");
389
390 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
391 assert.deepEqual(
392 endpoints.map((endpoint) => [endpoint.reachability, endpoint.httpBaseUrl]),
393 [
394 ["loopback", "http://127.0.0.1:4173/"],
395 ["private-network", "http://100.90.1.2:4173/"],
396 ],
397 );
398 }),
399 ),
400 );
401
402 it.effect("does not spawn the tailscale CLI while server exposure is local-only", () =>
403 withHarness(
404 lanNetworkInterfaces,
405 Effect.gen(function* () {
406 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
407 yield* serverExposure.configureFromSettings({ port: 4173 });
408 // mode stays at default "local-only", tailscaleServeEnabled stays false.
409
410 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
411 // Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer
412 // would have crashed the test if the gate was missing.
413 assert.deepEqual(
414 endpoints.map((endpoint) => endpoint.httpBaseUrl),
415 ["http://127.0.0.1:4173/"],
416 );
417 }),
418 {},
419 layerDieOnSpawn(),
420 ),
421 );
422
423 it.effect("preserves explicit Tailscale exposure overrides", () =>
424 withHarness(
425 lanNetworkInterfaces,
426 Effect.gen(function* () {
427 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
428 yield* serverExposure.configureFromSettings({ port: 4173 });
429 const change = yield* serverExposure.setMode("network-accessible");
430
431 assert.equal(change.state.advertisedHost, "100.90.1.2");
432 assert.equal(change.state.endpointUrl, "http://100.90.1.2:4173");
433
434 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
435 assert.deepEqual(
436 endpoints.map((endpoint) => endpoint.httpBaseUrl),
437 ["http://127.0.0.1:4173/", "http://100.90.1.2:4173/", "https://public.example.test/"],
438 );
439 }),
440 {
441 T3CODE_DESKTOP_LAN_HOST: "100.90.1.2",
442 T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test",
443 },
444 ),
445 );
446
447 it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () =>
448 withHarness(
449 lanNetworkInterfaces,
450 Effect.gen(function* () {
451 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
452 yield* serverExposure.configureFromSettings({ port: 3773 });
453 yield* serverExposure.setMode("network-accessible");
454
455 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
456 assert.deepEqual(endpoints, [
457 {
458 id: "desktop-loopback:3773",
459 label: "This machine",
460 provider: {
461 id: "desktop-core",
462 label: "Desktop",
463 kind: "core",
464 isAddon: false,
465 },
466 httpBaseUrl: "http://127.0.0.1:3773/",
467 wsBaseUrl: "ws://127.0.0.1:3773/",
468 reachability: "loopback",
469 compatibility: {
470 hostedHttpsApp: "mixed-content-blocked",
471 desktopApp: "compatible",
472 },
473 source: "desktop-core",
474 status: "available",
475 description: "Loopback endpoint for this desktop app.",
476 },
477 {
478 id: "desktop-lan:http://192.168.1.20:3773",
479 label: "Local network",
480 provider: {
481 id: "desktop-core",
482 label: "Desktop",
483 kind: "core",
484 isAddon: false,
485 },
486 httpBaseUrl: "http://192.168.1.20:3773/",
487 wsBaseUrl: "ws://192.168.1.20:3773/",
488 reachability: "lan",
489 compatibility: {
490 hostedHttpsApp: "mixed-content-blocked",
491 desktopApp: "compatible",
492 },
493 source: "desktop-core",
494 status: "available",
495 isDefault: true,
496 description: "Reachable from devices on the same network.",
497 },
498 {
499 id: "manual:https://desktop.example.ts.net",
500 label: "Custom HTTPS",
501 provider: {
502 id: "manual",
503 label: "Manual",
504 kind: "manual",
505 isAddon: false,
506 },
507 httpBaseUrl: "https://desktop.example.ts.net/",
508 wsBaseUrl: "wss://desktop.example.ts.net/",
509 reachability: "public",
510 compatibility: {
511 hostedHttpsApp: "compatible",
512 desktopApp: "compatible",
513 },
514 source: "user",
515 status: "unknown",
516 description: "User-configured HTTPS endpoint for this desktop backend.",
517 },
518 {
519 id: "manual:http://desktop.example.test:3773",
520 label: "Custom endpoint",
521 provider: {
522 id: "manual",
523 label: "Manual",
524 kind: "manual",
525 isAddon: false,
526 },
527 httpBaseUrl: "http://desktop.example.test:3773/",
528 wsBaseUrl: "ws://desktop.example.test:3773/",
529 reachability: "public",
530 compatibility: {
531 hostedHttpsApp: "mixed-content-blocked",
532 desktopApp: "compatible",
533 },
534 source: "user",
535 status: "unknown",
536 description: "User-configured endpoint for this desktop backend.",
537 },
538 ]);
539 }),
540 {
541 T3CODE_DESKTOP_HTTPS_ENDPOINTS:
542 "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url",
543 },
544 ),
545 );
546});