authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-07 21:25:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-08 04:25:33+00:00
log9d919d0bb8d6a2d561464201713117e2d0c588ad
tree1fbf6af887f856bd3fd0fa58a39d5153f03bc73f
parentaa219be713e534fa79a179e2cffdd773caa9cdc5
signature Signed by PGP key B5690EEEBB952194

Move desktop server exposure logic into backend (#2591)


15 files changed, 1200 insertions(+), 1200 deletions(-)

apps/desktop/src/app/DesktopApp.ts+1-1
......@@ -16,7 +16,7 @@ import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
1616import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1717import * as DesktopLifecycle from "./DesktopLifecycle.ts";
1818import * as DesktopObservability from "./DesktopObservability.ts";
19import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
19import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
2020import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
2121import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
2222import * as DesktopState from "./DesktopState.ts";
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+1-1
......@@ -8,7 +8,7 @@ import * as Schema from "effect/Schema";
88import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
99import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
1010import * as DesktopConfig from "../app/DesktopConfig.ts";
11import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
11import * as DesktopServerExposure from "./DesktopServerExposure.ts";
1212
1313const PersistedServerObservabilitySettingsDocument = Schema.Struct({
1414 observability: Schema.Struct({
apps/desktop/src/backend/DesktopBackendConfiguration.ts+1-1
......@@ -10,7 +10,7 @@ import * as Ref from "effect/Ref";
1010import * as DesktopBackendManager from "./DesktopBackendManager.ts";
1111import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1212import * as DesktopObservability from "../app/DesktopObservability.ts";
13import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
13import * as DesktopServerExposure from "./DesktopServerExposure.ts";
1414
1515export interface DesktopBackendConfigurationShape {
1616 readonly resolve: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>;
apps/desktop/src/backend/DesktopServerExposure.test.ts created+365
......@@ -0,0 +1,365 @@
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 Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import { ChildProcessSpawner } from "effect/unstable/process";
11
12import {
13 DesktopEnvironment,
14 layer as makeDesktopEnvironmentLayer,
15} from "../app/DesktopEnvironment.ts";
16import * as DesktopConfig from "../app/DesktopConfig.ts";
17import * as DesktopServerExposure from "./DesktopServerExposure.ts";
18import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
19import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
20
21const encoder = new TextEncoder();
22
23const emptyNetworkInterfaces: DesktopNetworkInterfaces = {};
24const lanNetworkInterfaces: DesktopNetworkInterfaces = {
25 en0: [
26 {
27 address: "192.168.1.20",
28 family: "IPv4",
29 internal: false,
30 },
31 ],
32};
33
34const tailnetNetworkInterfaces: DesktopNetworkInterfaces = {
35 tailscale0: [
36 {
37 address: "100.90.1.2",
38 family: "IPv4",
39 internal: false,
40 },
41 ],
42};
43
44function mockSpawnerLayer(statusJson = "{}") {
45 return Layer.succeed(
46 ChildProcessSpawner.ChildProcessSpawner,
47 ChildProcessSpawner.make(() =>
48 Effect.succeed(
49 ChildProcessSpawner.makeHandle({
50 pid: ChildProcessSpawner.ProcessId(1),
51 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
52 isRunning: Effect.succeed(false),
53 kill: () => Effect.void,
54 unref: Effect.succeed(Effect.void),
55 stdin: Sink.drain,
56 stdout: Stream.make(encoder.encode(statusJson)),
57 stderr: Stream.empty,
58 all: Stream.empty,
59 getInputFd: () => Sink.drain,
60 getOutputFd: () => Stream.empty,
61 }),
62 ),
63 ),
64 );
65}
66
67function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) {
68 return makeDesktopEnvironmentLayer({
69 dirname: "/repo/apps/desktop/src",
70 homeDirectory: baseDir,
71 platform: "darwin",
72 processArch: "x64",
73 appVersion: "1.2.3",
74 appPath: "/repo",
75 isPackaged: true,
76 resourcesPath: "/missing/resources",
77 runningUnderArm64Translation: false,
78 }).pipe(
79 Layer.provide(
80 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })),
81 ),
82 );
83}
84
85function makeLayer(input: {
86 readonly baseDir: string;
87 readonly networkInterfaces?: DesktopNetworkInterfaces;
88 readonly env?: Record<string, string | undefined>;
89}) {
90 const env = { T3CODE_HOME: input.baseDir, ...input.env };
91 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
92 const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, {
93 read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces),
94 });
95
96 return DesktopServerExposure.layer.pipe(
97 Layer.provideMerge(DesktopAppSettings.layer),
98 Layer.provideMerge(NodeFileSystem.layer),
99 Layer.provideMerge(NodeHttpClient.layerUndici),
100 Layer.provideMerge(mockSpawnerLayer()),
101 Layer.provideMerge(networkLayer),
102 Layer.provideMerge(DesktopConfig.layerTest(env)),
103 Layer.provideMerge(environmentLayer),
104 );
105}
106
107const withHarness = <A, E, R>(
108 networkInterfaces: DesktopNetworkInterfaces,
109 effect: Effect.Effect<
110 A,
111 E,
112 | R
113 | DesktopEnvironment
114 | FileSystem.FileSystem
115 | DesktopServerExposure.DesktopServerExposure
116 | DesktopAppSettings.DesktopAppSettings
117 >,
118 env: Record<string, string | undefined> = {},
119) =>
120 Effect.gen(function* () {
121 const fileSystem = yield* FileSystem.FileSystem;
122 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
123 prefix: "t3-desktop-server-exposure-test-",
124 });
125 return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env })));
126 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
127
128describe("DesktopServerExposure", () => {
129 it.effect("falls back to local-only without losing the requested network preference", () =>
130 withHarness(
131 emptyNetworkInterfaces,
132 Effect.gen(function* () {
133 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
134 const settings = yield* DesktopAppSettings.DesktopAppSettings;
135
136 yield* settings.setServerExposureMode("network-accessible");
137
138 const state = yield* serverExposure.configureFromSettings({ port: 4173 });
139 assert.equal(state.mode, "local-only");
140 assert.equal(state.endpointUrl, null);
141 assert.equal((yield* settings.get).serverExposureMode, "network-accessible");
142
143 const backendConfig = yield* serverExposure.backendConfig;
144 assert.equal(backendConfig.bindHost, "127.0.0.1");
145 assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
146 }),
147 ),
148 );
149
150 it.effect("returns a typed error when network access is explicitly unavailable", () =>
151 withHarness(
152 emptyNetworkInterfaces,
153 Effect.gen(function* () {
154 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
155 yield* serverExposure.configureFromSettings({ port: 4173 });
156
157 const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip);
158 assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError");
159 assert.equal(error.port, 4173);
160 }),
161 ),
162 );
163
164 it.effect("persists network-accessible mode and updates backend binding state", () =>
165 withHarness(
166 lanNetworkInterfaces,
167 Effect.gen(function* () {
168 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
169 const settings = yield* DesktopAppSettings.DesktopAppSettings;
170
171 yield* settings.load;
172 yield* serverExposure.configureFromSettings({ port: 4173 });
173
174 const change = yield* serverExposure.setMode("network-accessible");
175 assert.equal(change.requiresRelaunch, true);
176 assert.deepEqual(change.state, {
177 mode: "network-accessible",
178 endpointUrl: "http://192.168.1.20:4173",
179 advertisedHost: "192.168.1.20",
180 tailscaleServeEnabled: false,
181 tailscaleServePort: 443,
182 });
183
184 const backendConfig = yield* serverExposure.backendConfig;
185 assert.equal(backendConfig.bindHost, "0.0.0.0");
186 assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
187
188 const persisted = yield* settings.get;
189 assert.equal(persisted.serverExposureMode, "network-accessible");
190 }),
191 ),
192 );
193
194 it.effect("persists tailscale serve preferences atomically and reports no-op updates", () =>
195 withHarness(
196 emptyNetworkInterfaces,
197 Effect.gen(function* () {
198 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
199 const settings = yield* DesktopAppSettings.DesktopAppSettings;
200
201 yield* settings.load;
202 yield* serverExposure.configureFromSettings({ port: 4173 });
203
204 const changed = yield* serverExposure.setTailscaleServeEnabled({
205 enabled: true,
206 port: 8443,
207 });
208 assert.equal(changed.requiresRelaunch, true);
209 assert.equal(changed.state.tailscaleServeEnabled, true);
210 assert.equal(changed.state.tailscaleServePort, 8443);
211
212 const unchanged = yield* serverExposure.setTailscaleServeEnabled({
213 enabled: true,
214 port: 8443,
215 });
216 assert.equal(unchanged.requiresRelaunch, false);
217
218 const persisted = yield* settings.get;
219 assert.equal(persisted.tailscaleServeEnabled, true);
220 assert.equal(persisted.tailscaleServePort, 8443);
221 }),
222 ),
223 );
224
225 it.effect("resolves advertised endpoints from the scoped runtime state", () =>
226 withHarness(
227 { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces },
228 Effect.gen(function* () {
229 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
230 yield* serverExposure.configureFromSettings({ port: 4173 });
231 yield* serverExposure.setMode("network-accessible");
232
233 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
234 assert.deepEqual(
235 endpoints.map((endpoint) => endpoint.httpBaseUrl),
236 ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"],
237 );
238 }),
239 ),
240 );
241
242 it.effect("uses ConfigProvider desktop exposure overrides", () =>
243 withHarness(
244 lanNetworkInterfaces,
245 Effect.gen(function* () {
246 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
247 yield* serverExposure.configureFromSettings({ port: 4173 });
248 const change = yield* serverExposure.setMode("network-accessible");
249
250 assert.equal(change.state.advertisedHost, "10.0.0.7");
251 assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173");
252
253 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
254 assert.deepEqual(
255 endpoints.map((endpoint) => endpoint.httpBaseUrl),
256 ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"],
257 );
258 }),
259 {
260 T3CODE_DESKTOP_LAN_HOST: "10.0.0.7",
261 T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test",
262 },
263 ),
264 );
265
266 it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () =>
267 withHarness(
268 lanNetworkInterfaces,
269 Effect.gen(function* () {
270 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
271 yield* serverExposure.configureFromSettings({ port: 3773 });
272 yield* serverExposure.setMode("network-accessible");
273
274 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
275 assert.deepEqual(endpoints, [
276 {
277 id: "desktop-loopback:3773",
278 label: "This machine",
279 provider: {
280 id: "desktop-core",
281 label: "Desktop",
282 kind: "core",
283 isAddon: false,
284 },
285 httpBaseUrl: "http://127.0.0.1:3773/",
286 wsBaseUrl: "ws://127.0.0.1:3773/",
287 reachability: "loopback",
288 compatibility: {
289 hostedHttpsApp: "mixed-content-blocked",
290 desktopApp: "compatible",
291 },
292 source: "desktop-core",
293 status: "available",
294 description: "Loopback endpoint for this desktop app.",
295 },
296 {
297 id: "desktop-lan:http://192.168.1.20:3773",
298 label: "Local network",
299 provider: {
300 id: "desktop-core",
301 label: "Desktop",
302 kind: "core",
303 isAddon: false,
304 },
305 httpBaseUrl: "http://192.168.1.20:3773/",
306 wsBaseUrl: "ws://192.168.1.20:3773/",
307 reachability: "lan",
308 compatibility: {
309 hostedHttpsApp: "mixed-content-blocked",
310 desktopApp: "compatible",
311 },
312 source: "desktop-core",
313 status: "available",
314 isDefault: true,
315 description: "Reachable from devices on the same network.",
316 },
317 {
318 id: "manual:https://desktop.example.ts.net",
319 label: "Custom HTTPS",
320 provider: {
321 id: "manual",
322 label: "Manual",
323 kind: "manual",
324 isAddon: false,
325 },
326 httpBaseUrl: "https://desktop.example.ts.net/",
327 wsBaseUrl: "wss://desktop.example.ts.net/",
328 reachability: "public",
329 compatibility: {
330 hostedHttpsApp: "compatible",
331 desktopApp: "compatible",
332 },
333 source: "user",
334 status: "unknown",
335 description: "User-configured HTTPS endpoint for this desktop backend.",
336 },
337 {
338 id: "manual:http://desktop.example.test:3773",
339 label: "Custom endpoint",
340 provider: {
341 id: "manual",
342 label: "Manual",
343 kind: "manual",
344 isAddon: false,
345 },
346 httpBaseUrl: "http://desktop.example.test:3773/",
347 wsBaseUrl: "ws://desktop.example.test:3773/",
348 reachability: "public",
349 compatibility: {
350 hostedHttpsApp: "mixed-content-blocked",
351 desktopApp: "compatible",
352 },
353 source: "user",
354 status: "unknown",
355 description: "User-configured endpoint for this desktop backend.",
356 },
357 ]);
358 }),
359 {
360 T3CODE_DESKTOP_HTTPS_ENDPOINTS:
361 "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url",
362 },
363 ),
364 );
365});
apps/desktop/src/backend/DesktopServerExposure.ts created+548
......@@ -0,0 +1,548 @@
1import * as NodeOS from "node:os";
2
3import {
4 createAdvertisedEndpoint,
5 type CreateAdvertisedEndpointInput,
6} from "@t3tools/client-runtime";
7import type {
8 AdvertisedEndpoint,
9 AdvertisedEndpointProvider,
10 DesktopServerExposureMode,
11 DesktopServerExposureState,
12} from "@t3tools/contracts";
13import * as Context from "effect/Context";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19import { HttpClient } from "effect/unstable/http";
20import { ChildProcessSpawner } from "effect/unstable/process";
21
22import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";
23import * as DesktopConfig from "../app/DesktopConfig.ts";
24import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
25import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
26
27export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
28const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
29
30export interface DesktopNetworkInterfaceInfo {
31 readonly address: string;
32 readonly family: string | number;
33 readonly internal: boolean;
34 readonly netmask?: string;
35 readonly mac?: string;
36 readonly cidr?: string | null;
37 readonly scopeid?: number;
38}
39
40export type DesktopNetworkInterfaces = Readonly<
41 Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined>
42>;
43
44interface ResolvedDesktopServerExposure {
45 readonly mode: DesktopServerExposureMode;
46 readonly bindHost: string;
47 readonly localHttpUrl: string;
48 readonly localWsUrl: string;
49 readonly endpointUrl: string | null;
50 readonly advertisedHost: string | null;
51}
52
53interface DesktopAdvertisedEndpointInput {
54 readonly port: number;
55 readonly exposure: ResolvedDesktopServerExposure;
56 readonly customHttpsEndpointUrls?: readonly string[];
57}
58
59const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
60 id: "desktop-core",
61 label: "Desktop",
62 kind: "core",
63 isAddon: false,
64};
65
66const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
67 id: "manual",
68 label: "Manual",
69 kind: "manual",
70 isAddon: false,
71};
72
73const normalizeOptionalHost = (value: string | undefined): string | undefined => {
74 const normalized = value?.trim();
75 return normalized && normalized.length > 0 ? normalized : undefined;
76};
77
78const isUsableLanIpv4Address = (address: string): boolean =>
79 !address.startsWith("127.") && !address.startsWith("169.254.");
80
81const isHttpsEndpointUrl = (value: string): boolean => {
82 try {
83 return new URL(value).protocol === "https:";
84 } catch {
85 return false;
86 }
87};
88
89const resolveLanAdvertisedHost = (
90 networkInterfaces: DesktopNetworkInterfaces,
91 explicitHost: string | undefined,
92): string | null => {
93 const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
94 if (normalizedExplicitHost) {
95 return normalizedExplicitHost;
96 }
97
98 for (const interfaceAddresses of Object.values(networkInterfaces)) {
99 if (!interfaceAddresses) continue;
100
101 for (const address of interfaceAddresses) {
102 if (address.internal) continue;
103 if (address.family !== "IPv4") continue;
104 if (!isUsableLanIpv4Address(address.address)) continue;
105 return address.address;
106 }
107 }
108
109 return null;
110};
111
112const resolveDesktopServerExposure = (input: {
113 readonly mode: DesktopServerExposureMode;
114 readonly port: number;
115 readonly networkInterfaces: DesktopNetworkInterfaces;
116 readonly advertisedHostOverride?: string;
117}): ResolvedDesktopServerExposure => {
118 const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
119 const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
120
121 if (input.mode === "local-only") {
122 return {
123 mode: input.mode,
124 bindHost: DESKTOP_LOOPBACK_HOST,
125 localHttpUrl,
126 localWsUrl,
127 endpointUrl: null,
128 advertisedHost: null,
129 };
130 }
131
132 const advertisedHost = resolveLanAdvertisedHost(
133 input.networkInterfaces,
134 input.advertisedHostOverride,
135 );
136
137 return {
138 mode: input.mode,
139 bindHost: DESKTOP_LAN_BIND_HOST,
140 localHttpUrl,
141 localWsUrl,
142 endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
143 advertisedHost,
144 };
145};
146
147const createDesktopEndpoint = (
148 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
149): AdvertisedEndpoint =>
150 createAdvertisedEndpoint({
151 ...input,
152 provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
153 source: "desktop-core",
154 });
155
156const createManualEndpoint = (
157 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
158): AdvertisedEndpoint =>
159 createAdvertisedEndpoint({
160 ...input,
161 provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
162 source: "user",
163 });
164
165const resolveDesktopCoreAdvertisedEndpoints = (
166 input: DesktopAdvertisedEndpointInput,
167): readonly AdvertisedEndpoint[] => {
168 const endpoints: AdvertisedEndpoint[] = [
169 createDesktopEndpoint({
170 id: `desktop-loopback:${input.port}`,
171 label: "This machine",
172 httpBaseUrl: input.exposure.localHttpUrl,
173 reachability: "loopback",
174 status: "available",
175 description: "Loopback endpoint for this desktop app.",
176 }),
177 ];
178
179 if (input.exposure.endpointUrl) {
180 endpoints.push(
181 createDesktopEndpoint({
182 id: `desktop-lan:${input.exposure.endpointUrl}`,
183 label: "Local network",
184 httpBaseUrl: input.exposure.endpointUrl,
185 reachability: "lan",
186 status: "available",
187 isDefault: true,
188 description: "Reachable from devices on the same network.",
189 }),
190 );
191 }
192
193 for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
194 try {
195 const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl);
196 endpoints.push(
197 createManualEndpoint({
198 id: `manual:${customEndpointUrl}`,
199 label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint",
200 httpBaseUrl: customEndpointUrl,
201 reachability: "public",
202 ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
203 status: "unknown",
204 description: isHttpsEndpoint
205 ? "User-configured HTTPS endpoint for this desktop backend."
206 : "User-configured endpoint for this desktop backend.",
207 }),
208 );
209 } catch {
210 // Ignore malformed user-configured endpoints without dropping valid endpoints.
211 }
212 }
213
214 return endpoints;
215};
216
217type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve";
218
219export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError(
220 "DesktopServerExposureNoNetworkAddressError",
221)<{
222 readonly port: number;
223}> {
224 override get message() {
225 return `No reachable network address is available for desktop network access on port ${this.port}.`;
226 }
227}
228
229export class DesktopServerExposurePersistenceError extends Data.TaggedError(
230 "DesktopServerExposurePersistenceError",
231)<{
232 readonly operation: DesktopServerExposurePersistenceOperation;
233 readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError;
234}> {
235 override get message() {
236 return `Failed to persist desktop ${this.operation} settings.`;
237 }
238}
239
240export type DesktopServerExposureSetModeError =
241 | DesktopServerExposureNoNetworkAddressError
242 | DesktopServerExposurePersistenceError;
243
244export type DesktopServerExposureError = DesktopServerExposureSetModeError;
245
246export interface DesktopServerExposureBackendConfig {
247 readonly port: number;
248 readonly bindHost: string;
249 readonly httpBaseUrl: URL;
250 readonly tailscaleServeEnabled: boolean;
251 readonly tailscaleServePort: number;
252}
253
254export interface DesktopServerExposureChange {
255 readonly state: DesktopServerExposureState;
256 readonly requiresRelaunch: boolean;
257}
258
259export interface DesktopServerExposureShape {
260 readonly getState: Effect.Effect<DesktopServerExposureState>;
261 readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>;
262 readonly configureFromSettings: (input: {
263 readonly port: number;
264 }) => Effect.Effect<DesktopServerExposureState>;
265 readonly setMode: (
266 mode: DesktopServerExposureMode,
267 ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>;
268 readonly setTailscaleServeEnabled: (input: {
269 readonly enabled: boolean;
270 readonly port?: number;
271 }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>;
272 readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
273}
274
275export class DesktopServerExposure extends Context.Service<
276 DesktopServerExposure,
277 DesktopServerExposureShape
278>()("t3/desktop/ServerExposure") {}
279
280export interface DesktopNetworkInterfacesServiceShape {
281 readonly read: Effect.Effect<DesktopNetworkInterfaces>;
282}
283
284export class DesktopNetworkInterfacesService extends Context.Service<
285 DesktopNetworkInterfacesService,
286 DesktopNetworkInterfacesServiceShape
287>()("t3/desktop/ServerExposure/NetworkInterfaces") {}
288
289interface RuntimeState {
290 readonly requestedMode: DesktopServerExposureMode;
291 readonly mode: DesktopServerExposureMode;
292 readonly port: number;
293 readonly bindHost: string;
294 readonly localHttpUrl: string;
295 readonly localWsUrl: string;
296 readonly httpBaseUrl: URL;
297 readonly endpointUrl: Option.Option<string>;
298 readonly advertisedHost: Option.Option<string>;
299 readonly tailscaleServeEnabled: boolean;
300 readonly tailscaleServePort: number;
301}
302
303interface ResolvedRuntimeState {
304 readonly state: RuntimeState;
305 readonly unavailable: boolean;
306}
307
308const initialRuntimeState = (): RuntimeState =>
309 runtimeStateFromResolvedExposure({
310 requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
311 settings: DEFAULT_DESKTOP_SETTINGS,
312 exposure: resolveDesktopServerExposure({
313 mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
314 port: 0,
315 networkInterfaces: {},
316 }),
317 port: 0,
318 });
319
320const toContractState = (state: RuntimeState): DesktopServerExposureState => ({
321 mode: state.mode,
322 endpointUrl: Option.getOrNull(state.endpointUrl),
323 advertisedHost: Option.getOrNull(state.advertisedHost),
324 tailscaleServeEnabled: state.tailscaleServeEnabled,
325 tailscaleServePort: state.tailscaleServePort,
326});
327
328const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({
329 port: state.port,
330 bindHost: state.bindHost,
331 httpBaseUrl: state.httpBaseUrl,
332 tailscaleServeEnabled: state.tailscaleServeEnabled,
333 tailscaleServePort: state.tailscaleServePort,
334});
335
336const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({
337 mode: state.mode,
338 bindHost: state.bindHost,
339 localHttpUrl: state.localHttpUrl,
340 localWsUrl: state.localWsUrl,
341 endpointUrl: Option.getOrNull(state.endpointUrl),
342 advertisedHost: Option.getOrNull(state.advertisedHost),
343});
344
345function runtimeStateFromResolvedExposure(input: {
346 readonly requestedMode: DesktopServerExposureMode;
347 readonly settings: DesktopSettings;
348 readonly exposure: ResolvedDesktopServerExposure;
349 readonly port: number;
350}): RuntimeState {
351 return {
352 requestedMode: input.requestedMode,
353 mode: input.exposure.mode,
354 port: input.port,
355 bindHost: input.exposure.bindHost,
356 localHttpUrl: input.exposure.localHttpUrl,
357 localWsUrl: input.exposure.localWsUrl,
358 httpBaseUrl: new URL(input.exposure.localHttpUrl),
359 endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl),
360 advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost),
361 tailscaleServeEnabled: input.settings.tailscaleServeEnabled,
362 tailscaleServePort: input.settings.tailscaleServePort,
363 };
364}
365
366function resolveRuntimeState(input: {
367 readonly requestedMode: DesktopServerExposureMode;
368 readonly settings: DesktopSettings;
369 readonly port: number;
370 readonly networkInterfaces: DesktopNetworkInterfaces;
371 readonly advertisedHostOverride: Option.Option<string>;
372}): ResolvedRuntimeState {
373 const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride);
374 const requestedExposure = resolveDesktopServerExposure({
375 mode: input.requestedMode,
376 port: input.port,
377 networkInterfaces: input.networkInterfaces,
378 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
379 });
380 const unavailable =
381 input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null;
382 const exposure = unavailable
383 ? resolveDesktopServerExposure({
384 mode: "local-only",
385 port: input.port,
386 networkInterfaces: input.networkInterfaces,
387 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
388 })
389 : requestedExposure;
390
391 return {
392 state: runtimeStateFromResolvedExposure({
393 requestedMode: input.requestedMode,
394 settings: input.settings,
395 exposure,
396 port: input.port,
397 }),
398 unavailable,
399 };
400}
401
402const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean =>
403 previous.port !== next.port ||
404 previous.bindHost !== next.bindHost ||
405 previous.localHttpUrl !== next.localHttpUrl;
406
407const make = Effect.gen(function* () {
408 const config = yield* DesktopConfig.DesktopConfig;
409 const networkInterfaces = yield* DesktopNetworkInterfacesService;
410 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
411 const httpClient = yield* HttpClient.HttpClient;
412 const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;
413 const stateRef = yield* Ref.make(initialRuntimeState());
414
415 const readNetworkInterfaces = networkInterfaces.read;
416
417 const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
418 const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig));
419
420 const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")(
421 function* ({ port }: { readonly port: number }) {
422 yield* Effect.annotateCurrentSpan({ port });
423 const settings = yield* desktopSettings.get;
424 const currentNetworkInterfaces = yield* readNetworkInterfaces;
425 const resolved = resolveRuntimeState({
426 requestedMode: settings.serverExposureMode,
427 settings,
428 port,
429 networkInterfaces: currentNetworkInterfaces,
430 advertisedHostOverride: config.desktopLanHostOverride,
431 });
432 yield* Ref.set(stateRef, resolved.state);
433 return toContractState(resolved.state);
434 },
435 );
436
437 const setMode = Effect.fn("desktop.serverExposure.setMode")(function* (
438 mode: DesktopServerExposureMode,
439 ) {
440 yield* Effect.annotateCurrentSpan({ mode });
441 const previous = yield* Ref.get(stateRef);
442 const currentSettings = yield* desktopSettings.get;
443 const nextSettings = {
444 ...currentSettings,
445 serverExposureMode: mode,
446 };
447 const currentNetworkInterfaces = yield* readNetworkInterfaces;
448 const resolved = resolveRuntimeState({
449 requestedMode: mode,
450 settings: nextSettings,
451 port: previous.port,
452 networkInterfaces: currentNetworkInterfaces,
453 advertisedHostOverride: config.desktopLanHostOverride,
454 });
455
456 if (resolved.unavailable) {
457 return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port });
458 }
459
460 const change = yield* desktopSettings.setServerExposureMode(mode).pipe(
461 Effect.mapError(
462 (cause) =>
463 new DesktopServerExposurePersistenceError({
464 operation: "server-exposure-mode",
465 cause,
466 }),
467 ),
468 );
469
470 yield* Ref.set(stateRef, resolved.state);
471 return {
472 state: toContractState(resolved.state),
473 requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state),
474 };
475 });
476
477 const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")(
478 function* (input: { readonly enabled: boolean; readonly port?: number }) {
479 yield* Effect.annotateCurrentSpan({
480 enabled: input.enabled,
481 ...(input.port === undefined ? {} : { port: input.port }),
482 });
483 const result = yield* desktopSettings
484 .setTailscaleServe({
485 enabled: input.enabled,
486 port: Option.fromNullishOr(input.port),
487 })
488 .pipe(
489 Effect.mapError(
490 (cause) =>
491 new DesktopServerExposurePersistenceError({
492 operation: "tailscale-serve",
493 cause,
494 }),
495 ),
496 );
497
498 const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({
499 ...current,
500 tailscaleServeEnabled: result.settings.tailscaleServeEnabled,
501 tailscaleServePort: result.settings.tailscaleServePort,
502 }));
503
504 return {
505 state: toContractState(nextState),
506 requiresRelaunch: result.changed,
507 };
508 },
509 );
510
511 const getAdvertisedEndpoints = Effect.gen(function* () {
512 const state = yield* Ref.get(stateRef);
513 const currentNetworkInterfaces = yield* readNetworkInterfaces;
514 const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
515 port: state.port,
516 exposure: toResolvedExposure(state),
517 customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
518 });
519 const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
520 port: state.port,
521 serveEnabled: state.tailscaleServeEnabled,
522 servePort: state.tailscaleServePort,
523 networkInterfaces: currentNetworkInterfaces,
524 }).pipe(
525 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
526 Effect.provideService(HttpClient.HttpClient, httpClient),
527 );
528 return [...coreEndpoints, ...tailscaleEndpoints];
529 }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints"));
530
531 return DesktopServerExposure.of({
532 getState,
533 backendConfig,
534 configureFromSettings,
535 setMode,
536 setTailscaleServeEnabled,
537 getAdvertisedEndpoints,
538 });
539});
540
541export const layer = Layer.effect(DesktopServerExposure, make);
542
543export const networkInterfacesLayer = Layer.succeed(
544 DesktopNetworkInterfacesService,
545 DesktopNetworkInterfacesService.of({
546 read: Effect.sync(() => NodeOS.networkInterfaces()),
547 }),
548);
apps/desktop/src/backend/tailscaleEndpointProvider.test.ts created+142
......@@ -0,0 +1,142 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpClient } from "effect/unstable/http";
5import { ChildProcessSpawner } from "effect/unstable/process";
6
7import {
8 isTailscaleIpv4Address,
9 parseTailscaleMagicDnsName,
10 resolveTailscaleAdvertisedEndpoints,
11} from "./tailscaleEndpointProvider.ts";
12
13const unusedTailscaleExternalServicesLayer = Layer.mergeAll(
14 Layer.succeed(
15 HttpClient.HttpClient,
16 HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")),
17 ),
18 Layer.succeed(
19 ChildProcessSpawner.ChildProcessSpawner,
20 ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")),
21 ),
22);
23
24describe("tailscale endpoint provider", () => {
25 it("detects Tailnet IPv4 addresses", () => {
26 assert.equal(isTailscaleIpv4Address("100.64.0.1"), true);
27 assert.equal(isTailscaleIpv4Address("100.127.255.254"), true);
28 assert.equal(isTailscaleIpv4Address("100.128.0.1"), false);
29 assert.equal(isTailscaleIpv4Address("192.168.1.44"), false);
30 });
31
32 it.effect("parses MagicDNS names from tailscale status", () =>
33 Effect.gen(function* () {
34 const dnsName = yield* parseTailscaleMagicDnsName(
35 `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
36 );
37 assert.equal(dnsName, "desktop.tail.ts.net");
38 assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
39 const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
40 assert.isTrue(malformed._tag === "Failure");
41 }),
42 );
43
44 it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () =>
45 Effect.gen(function* () {
46 const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
47 port: 3773,
48 networkInterfaces: {
49 tailscale0: [
50 {
51 address: "100.100.100.100",
52 family: "IPv4",
53 internal: false,
54 netmask: "255.192.0.0",
55 cidr: "100.100.100.100/10",
56 mac: "00:00:00:00:00:00",
57 },
58 ],
59 },
60 statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
61 });
62 assert.deepEqual(endpoints, [
63 {
64 id: "tailscale-ip:http://100.100.100.100:3773",
65 label: "Tailscale IP",
66 provider: {
67 id: "tailscale",
68 label: "Tailscale",
69 kind: "private-network",
70 isAddon: true,
71 },
72 httpBaseUrl: "http://100.100.100.100:3773/",
73 wsBaseUrl: "ws://100.100.100.100:3773/",
74 reachability: "private-network",
75 compatibility: {
76 hostedHttpsApp: "mixed-content-blocked",
77 desktopApp: "compatible",
78 },
79 source: "desktop-addon",
80 status: "available",
81 description: "Reachable from devices on the same Tailnet.",
82 },
83 {
84 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
85 label: "Tailscale HTTPS",
86 provider: {
87 id: "tailscale",
88 label: "Tailscale",
89 kind: "private-network",
90 isAddon: true,
91 },
92 httpBaseUrl: "https://desktop.tail.ts.net/",
93 wsBaseUrl: "wss://desktop.tail.ts.net/",
94 reachability: "private-network",
95 compatibility: {
96 hostedHttpsApp: "requires-configuration",
97 desktopApp: "compatible",
98 },
99 source: "desktop-addon",
100 status: "unavailable",
101 description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
102 },
103 ]);
104 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
105 );
106
107 it.effect(
108 "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable",
109 () =>
110 Effect.gen(function* () {
111 const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
112 port: 3773,
113 networkInterfaces: {},
114 statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
115 serveEnabled: true,
116 probe: () => Effect.succeed(true),
117 });
118 assert.deepEqual(endpoints, [
119 {
120 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
121 label: "Tailscale HTTPS",
122 provider: {
123 id: "tailscale",
124 label: "Tailscale",
125 kind: "private-network",
126 isAddon: true,
127 },
128 httpBaseUrl: "https://desktop.tail.ts.net/",
129 wsBaseUrl: "wss://desktop.tail.ts.net/",
130 reachability: "private-network",
131 compatibility: {
132 hostedHttpsApp: "compatible",
133 desktopApp: "compatible",
134 },
135 source: "desktop-addon",
136 status: "available",
137 description: "HTTPS endpoint served by Tailscale Serve.",
138 },
139 ]);
140 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
141 );
142});
apps/desktop/src/backend/tailscaleEndpointProvider.ts created+138
......@@ -0,0 +1,138 @@
1import { createAdvertisedEndpoint } from "@t3tools/client-runtime";
2import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
3import {
4 buildTailscaleHttpsBaseUrl,
5 isTailscaleIpv4Address,
6 parseTailscaleMagicDnsName,
7 probeTailscaleHttpsEndpoint,
8 readTailscaleStatus,
9} from "@t3tools/tailscale";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import { HttpClient } from "effect/unstable/http";
13import { ChildProcessSpawner } from "effect/unstable/process";
14
15import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
16
17export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
18
19const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
20 id: "tailscale",
21 label: "Tailscale",
22 kind: "private-network",
23 isAddon: true,
24};
25
26function resolveTailscaleIpAdvertisedEndpoints(input: {
27 readonly port: number;
28 readonly networkInterfaces: DesktopNetworkInterfaces;
29}): readonly AdvertisedEndpoint[] {
30 const seen = new Set<string>();
31 const endpoints: AdvertisedEndpoint[] = [];
32
33 for (const interfaceAddresses of Object.values(input.networkInterfaces)) {
34 if (!interfaceAddresses) continue;
35
36 for (const address of interfaceAddresses) {
37 if (address.internal) continue;
38 if (address.family !== "IPv4") continue;
39 if (!isTailscaleIpv4Address(address.address)) continue;
40 if (seen.has(address.address)) continue;
41 seen.add(address.address);
42
43 endpoints.push(
44 createAdvertisedEndpoint({
45 provider: TAILSCALE_ENDPOINT_PROVIDER,
46 source: "desktop-addon",
47 id: `tailscale-ip:http://${address.address}:${input.port}`,
48 label: "Tailscale IP",
49 httpBaseUrl: `http://${address.address}:${input.port}`,
50 reachability: "private-network",
51 status: "available",
52 description: "Reachable from devices on the same Tailnet.",
53 }),
54 );
55 }
56 }
57
58 return endpoints;
59}
60
61const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
62 "resolveTailscaleMagicDnsAdvertisedEndpoint",
63)(function* (input: {
64 readonly dnsName: string | null;
65 readonly serveEnabled: boolean;
66 readonly servePort?: number;
67 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
68}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {
69 if (!input.dnsName) {
70 return Option.none();
71 }
72
73 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
74 magicDnsName: input.dnsName,
75 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
76 });
77 const probe =
78 input.probe?.(httpBaseUrl) ??
79 probeTailscaleHttpsEndpoint({
80 baseUrl: httpBaseUrl,
81 });
82 const isReachable = input.serveEnabled ? yield* probe : false;
83
84 return Option.some(
85 createAdvertisedEndpoint({
86 provider: TAILSCALE_ENDPOINT_PROVIDER,
87 source: "desktop-addon",
88 id: `tailscale-magicdns:${httpBaseUrl}`,
89 label: "Tailscale HTTPS",
90 httpBaseUrl,
91 reachability: "private-network",
92 hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration",
93 status: isReachable ? "available" : "unavailable",
94 description: isReachable
95 ? "HTTPS endpoint served by Tailscale Serve."
96 : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
97 }),
98 );
99});
100
101export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")(
102 function* (input: {
103 readonly port: number;
104 readonly serveEnabled?: boolean;
105 readonly servePort?: number;
106 readonly networkInterfaces: DesktopNetworkInterfaces;
107 readonly statusJson?: string | null;
108 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
109 }): Effect.fn.Return<
110 readonly AdvertisedEndpoint[],
111 never,
112 ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient
113 > {
114 const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
115 const dnsName =
116 input.statusJson === undefined
117 ? yield* readTailscaleStatus.pipe(
118 Effect.map((status) => status.magicDnsName),
119 Effect.catch(() => Effect.succeed(null)),
120 )
121 : input.statusJson
122 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
123 Effect.catch(() => Effect.succeed(null)),
124 )
125 : null;
126 const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
127 dnsName,
128 serveEnabled: input.serveEnabled === true,
129 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
130 ...(input.probe === undefined ? {} : { probe: input.probe }),
131 });
132
133 return Option.match(magicDnsEndpoint, {
134 onNone: () => ipEndpoints,
135 onSome: (endpoint) => [...ipEndpoints, endpoint],
136 });
137 },
138);
apps/desktop/src/ipc/methods/serverExposure.ts+1-1
......@@ -7,7 +7,7 @@ import * as Effect from "effect/Effect";
77import * as Schema from "effect/Schema";
88
99import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
10import * as DesktopServerExposure from "../../serverExposure/DesktopServerExposure.ts";
10import * as DesktopServerExposure from "../../backend/DesktopServerExposure.ts";
1111import * as IpcChannels from "../channels.ts";
1212import { makeIpcMethod } from "../DesktopIpc.ts";
1313
apps/desktop/src/main.ts+1-1
......@@ -32,7 +32,7 @@ import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts";
3232import * as DesktopEnvironment from "./app/DesktopEnvironment.ts";
3333import * as DesktopLifecycle from "./app/DesktopLifecycle.ts";
3434import * as DesktopObservability from "./app/DesktopObservability.ts";
35import * as DesktopServerExposure from "./serverExposure/DesktopServerExposure.ts";
35import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts";
3636import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts";
3737import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts";
3838import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
apps/desktop/src/serverExposure/DesktopServerExposure.test.ts deleted-365
......@@ -1,365 +0,0 @@
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 Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import { ChildProcessSpawner } from "effect/unstable/process";
11
12import {
13 DesktopEnvironment,
14 layer as makeDesktopEnvironmentLayer,
15} from "../app/DesktopEnvironment.ts";
16import * as DesktopConfig from "../app/DesktopConfig.ts";
17import * as DesktopServerExposure from "./DesktopServerExposure.ts";
18import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
19import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
20
21const encoder = new TextEncoder();
22
23const emptyNetworkInterfaces: DesktopNetworkInterfaces = {};
24const lanNetworkInterfaces: DesktopNetworkInterfaces = {
25 en0: [
26 {
27 address: "192.168.1.20",
28 family: "IPv4",
29 internal: false,
30 },
31 ],
32};
33
34const tailnetNetworkInterfaces: DesktopNetworkInterfaces = {
35 tailscale0: [
36 {
37 address: "100.90.1.2",
38 family: "IPv4",
39 internal: false,
40 },
41 ],
42};
43
44function mockSpawnerLayer(statusJson = "{}") {
45 return Layer.succeed(
46 ChildProcessSpawner.ChildProcessSpawner,
47 ChildProcessSpawner.make(() =>
48 Effect.succeed(
49 ChildProcessSpawner.makeHandle({
50 pid: ChildProcessSpawner.ProcessId(1),
51 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
52 isRunning: Effect.succeed(false),
53 kill: () => Effect.void,
54 unref: Effect.succeed(Effect.void),
55 stdin: Sink.drain,
56 stdout: Stream.make(encoder.encode(statusJson)),
57 stderr: Stream.empty,
58 all: Stream.empty,
59 getInputFd: () => Sink.drain,
60 getOutputFd: () => Stream.empty,
61 }),
62 ),
63 ),
64 );
65}
66
67function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) {
68 return makeDesktopEnvironmentLayer({
69 dirname: "/repo/apps/desktop/src",
70 homeDirectory: baseDir,
71 platform: "darwin",
72 processArch: "x64",
73 appVersion: "1.2.3",
74 appPath: "/repo",
75 isPackaged: true,
76 resourcesPath: "/missing/resources",
77 runningUnderArm64Translation: false,
78 }).pipe(
79 Layer.provide(
80 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })),
81 ),
82 );
83}
84
85function makeLayer(input: {
86 readonly baseDir: string;
87 readonly networkInterfaces?: DesktopNetworkInterfaces;
88 readonly env?: Record<string, string | undefined>;
89}) {
90 const env = { T3CODE_HOME: input.baseDir, ...input.env };
91 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
92 const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, {
93 read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces),
94 });
95
96 return DesktopServerExposure.layer.pipe(
97 Layer.provideMerge(DesktopAppSettings.layer),
98 Layer.provideMerge(NodeFileSystem.layer),
99 Layer.provideMerge(NodeHttpClient.layerUndici),
100 Layer.provideMerge(mockSpawnerLayer()),
101 Layer.provideMerge(networkLayer),
102 Layer.provideMerge(DesktopConfig.layerTest(env)),
103 Layer.provideMerge(environmentLayer),
104 );
105}
106
107const withHarness = <A, E, R>(
108 networkInterfaces: DesktopNetworkInterfaces,
109 effect: Effect.Effect<
110 A,
111 E,
112 | R
113 | DesktopEnvironment
114 | FileSystem.FileSystem
115 | DesktopServerExposure.DesktopServerExposure
116 | DesktopAppSettings.DesktopAppSettings
117 >,
118 env: Record<string, string | undefined> = {},
119) =>
120 Effect.gen(function* () {
121 const fileSystem = yield* FileSystem.FileSystem;
122 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
123 prefix: "t3-desktop-server-exposure-test-",
124 });
125 return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env })));
126 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
127
128describe("DesktopServerExposure", () => {
129 it.effect("falls back to local-only without losing the requested network preference", () =>
130 withHarness(
131 emptyNetworkInterfaces,
132 Effect.gen(function* () {
133 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
134 const settings = yield* DesktopAppSettings.DesktopAppSettings;
135
136 yield* settings.setServerExposureMode("network-accessible");
137
138 const state = yield* serverExposure.configureFromSettings({ port: 4173 });
139 assert.equal(state.mode, "local-only");
140 assert.equal(state.endpointUrl, null);
141 assert.equal((yield* settings.get).serverExposureMode, "network-accessible");
142
143 const backendConfig = yield* serverExposure.backendConfig;
144 assert.equal(backendConfig.bindHost, "127.0.0.1");
145 assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
146 }),
147 ),
148 );
149
150 it.effect("returns a typed error when network access is explicitly unavailable", () =>
151 withHarness(
152 emptyNetworkInterfaces,
153 Effect.gen(function* () {
154 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
155 yield* serverExposure.configureFromSettings({ port: 4173 });
156
157 const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip);
158 assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError");
159 assert.equal(error.port, 4173);
160 }),
161 ),
162 );
163
164 it.effect("persists network-accessible mode and updates backend binding state", () =>
165 withHarness(
166 lanNetworkInterfaces,
167 Effect.gen(function* () {
168 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
169 const settings = yield* DesktopAppSettings.DesktopAppSettings;
170
171 yield* settings.load;
172 yield* serverExposure.configureFromSettings({ port: 4173 });
173
174 const change = yield* serverExposure.setMode("network-accessible");
175 assert.equal(change.requiresRelaunch, true);
176 assert.deepEqual(change.state, {
177 mode: "network-accessible",
178 endpointUrl: "http://192.168.1.20:4173",
179 advertisedHost: "192.168.1.20",
180 tailscaleServeEnabled: false,
181 tailscaleServePort: 443,
182 });
183
184 const backendConfig = yield* serverExposure.backendConfig;
185 assert.equal(backendConfig.bindHost, "0.0.0.0");
186 assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
187
188 const persisted = yield* settings.get;
189 assert.equal(persisted.serverExposureMode, "network-accessible");
190 }),
191 ),
192 );
193
194 it.effect("persists tailscale serve preferences atomically and reports no-op updates", () =>
195 withHarness(
196 emptyNetworkInterfaces,
197 Effect.gen(function* () {
198 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
199 const settings = yield* DesktopAppSettings.DesktopAppSettings;
200
201 yield* settings.load;
202 yield* serverExposure.configureFromSettings({ port: 4173 });
203
204 const changed = yield* serverExposure.setTailscaleServeEnabled({
205 enabled: true,
206 port: 8443,
207 });
208 assert.equal(changed.requiresRelaunch, true);
209 assert.equal(changed.state.tailscaleServeEnabled, true);
210 assert.equal(changed.state.tailscaleServePort, 8443);
211
212 const unchanged = yield* serverExposure.setTailscaleServeEnabled({
213 enabled: true,
214 port: 8443,
215 });
216 assert.equal(unchanged.requiresRelaunch, false);
217
218 const persisted = yield* settings.get;
219 assert.equal(persisted.tailscaleServeEnabled, true);
220 assert.equal(persisted.tailscaleServePort, 8443);
221 }),
222 ),
223 );
224
225 it.effect("resolves advertised endpoints from the scoped runtime state", () =>
226 withHarness(
227 { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces },
228 Effect.gen(function* () {
229 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
230 yield* serverExposure.configureFromSettings({ port: 4173 });
231 yield* serverExposure.setMode("network-accessible");
232
233 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
234 assert.deepEqual(
235 endpoints.map((endpoint) => endpoint.httpBaseUrl),
236 ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"],
237 );
238 }),
239 ),
240 );
241
242 it.effect("uses ConfigProvider desktop exposure overrides", () =>
243 withHarness(
244 lanNetworkInterfaces,
245 Effect.gen(function* () {
246 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
247 yield* serverExposure.configureFromSettings({ port: 4173 });
248 const change = yield* serverExposure.setMode("network-accessible");
249
250 assert.equal(change.state.advertisedHost, "10.0.0.7");
251 assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173");
252
253 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
254 assert.deepEqual(
255 endpoints.map((endpoint) => endpoint.httpBaseUrl),
256 ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"],
257 );
258 }),
259 {
260 T3CODE_DESKTOP_LAN_HOST: "10.0.0.7",
261 T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test",
262 },
263 ),
264 );
265
266 it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () =>
267 withHarness(
268 lanNetworkInterfaces,
269 Effect.gen(function* () {
270 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
271 yield* serverExposure.configureFromSettings({ port: 3773 });
272 yield* serverExposure.setMode("network-accessible");
273
274 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
275 assert.deepEqual(endpoints, [
276 {
277 id: "desktop-loopback:3773",
278 label: "This machine",
279 provider: {
280 id: "desktop-core",
281 label: "Desktop",
282 kind: "core",
283 isAddon: false,
284 },
285 httpBaseUrl: "http://127.0.0.1:3773/",
286 wsBaseUrl: "ws://127.0.0.1:3773/",
287 reachability: "loopback",
288 compatibility: {
289 hostedHttpsApp: "mixed-content-blocked",
290 desktopApp: "compatible",
291 },
292 source: "desktop-core",
293 status: "available",
294 description: "Loopback endpoint for this desktop app.",
295 },
296 {
297 id: "desktop-lan:http://192.168.1.20:3773",
298 label: "Local network",
299 provider: {
300 id: "desktop-core",
301 label: "Desktop",
302 kind: "core",
303 isAddon: false,
304 },
305 httpBaseUrl: "http://192.168.1.20:3773/",
306 wsBaseUrl: "ws://192.168.1.20:3773/",
307 reachability: "lan",
308 compatibility: {
309 hostedHttpsApp: "mixed-content-blocked",
310 desktopApp: "compatible",
311 },
312 source: "desktop-core",
313 status: "available",
314 isDefault: true,
315 description: "Reachable from devices on the same network.",
316 },
317 {
318 id: "manual:https://desktop.example.ts.net",
319 label: "Custom HTTPS",
320 provider: {
321 id: "manual",
322 label: "Manual",
323 kind: "manual",
324 isAddon: false,
325 },
326 httpBaseUrl: "https://desktop.example.ts.net/",
327 wsBaseUrl: "wss://desktop.example.ts.net/",
328 reachability: "public",
329 compatibility: {
330 hostedHttpsApp: "compatible",
331 desktopApp: "compatible",
332 },
333 source: "user",
334 status: "unknown",
335 description: "User-configured HTTPS endpoint for this desktop backend.",
336 },
337 {
338 id: "manual:http://desktop.example.test:3773",
339 label: "Custom endpoint",
340 provider: {
341 id: "manual",
342 label: "Manual",
343 kind: "manual",
344 isAddon: false,
345 },
346 httpBaseUrl: "http://desktop.example.test:3773/",
347 wsBaseUrl: "ws://desktop.example.test:3773/",
348 reachability: "public",
349 compatibility: {
350 hostedHttpsApp: "mixed-content-blocked",
351 desktopApp: "compatible",
352 },
353 source: "user",
354 status: "unknown",
355 description: "User-configured endpoint for this desktop backend.",
356 },
357 ]);
358 }),
359 {
360 T3CODE_DESKTOP_HTTPS_ENDPOINTS:
361 "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url",
362 },
363 ),
364 );
365});
apps/desktop/src/serverExposure/DesktopServerExposure.ts deleted-548
......@@ -1,548 +0,0 @@
1import * as NodeOS from "node:os";
2
3import {
4 createAdvertisedEndpoint,
5 type CreateAdvertisedEndpointInput,
6} from "@t3tools/client-runtime";
7import type {
8 AdvertisedEndpoint,
9 AdvertisedEndpointProvider,
10 DesktopServerExposureMode,
11 DesktopServerExposureState,
12} from "@t3tools/contracts";
13import * as Context from "effect/Context";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19import { HttpClient } from "effect/unstable/http";
20import { ChildProcessSpawner } from "effect/unstable/process";
21
22import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";
23import * as DesktopConfig from "../app/DesktopConfig.ts";
24import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
25import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
26
27export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
28const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
29
30export interface DesktopNetworkInterfaceInfo {
31 readonly address: string;
32 readonly family: string | number;
33 readonly internal: boolean;
34 readonly netmask?: string;
35 readonly mac?: string;
36 readonly cidr?: string | null;
37 readonly scopeid?: number;
38}
39
40export type DesktopNetworkInterfaces = Readonly<
41 Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined>
42>;
43
44interface ResolvedDesktopServerExposure {
45 readonly mode: DesktopServerExposureMode;
46 readonly bindHost: string;
47 readonly localHttpUrl: string;
48 readonly localWsUrl: string;
49 readonly endpointUrl: string | null;
50 readonly advertisedHost: string | null;
51}
52
53interface DesktopAdvertisedEndpointInput {
54 readonly port: number;
55 readonly exposure: ResolvedDesktopServerExposure;
56 readonly customHttpsEndpointUrls?: readonly string[];
57}
58
59const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
60 id: "desktop-core",
61 label: "Desktop",
62 kind: "core",
63 isAddon: false,
64};
65
66const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
67 id: "manual",
68 label: "Manual",
69 kind: "manual",
70 isAddon: false,
71};
72
73const normalizeOptionalHost = (value: string | undefined): string | undefined => {
74 const normalized = value?.trim();
75 return normalized && normalized.length > 0 ? normalized : undefined;
76};
77
78const isUsableLanIpv4Address = (address: string): boolean =>
79 !address.startsWith("127.") && !address.startsWith("169.254.");
80
81const isHttpsEndpointUrl = (value: string): boolean => {
82 try {
83 return new URL(value).protocol === "https:";
84 } catch {
85 return false;
86 }
87};
88
89const resolveLanAdvertisedHost = (
90 networkInterfaces: DesktopNetworkInterfaces,
91 explicitHost: string | undefined,
92): string | null => {
93 const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
94 if (normalizedExplicitHost) {
95 return normalizedExplicitHost;
96 }
97
98 for (const interfaceAddresses of Object.values(networkInterfaces)) {
99 if (!interfaceAddresses) continue;
100
101 for (const address of interfaceAddresses) {
102 if (address.internal) continue;
103 if (address.family !== "IPv4") continue;
104 if (!isUsableLanIpv4Address(address.address)) continue;
105 return address.address;
106 }
107 }
108
109 return null;
110};
111
112const resolveDesktopServerExposure = (input: {
113 readonly mode: DesktopServerExposureMode;
114 readonly port: number;
115 readonly networkInterfaces: DesktopNetworkInterfaces;
116 readonly advertisedHostOverride?: string;
117}): ResolvedDesktopServerExposure => {
118 const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
119 const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
120
121 if (input.mode === "local-only") {
122 return {
123 mode: input.mode,
124 bindHost: DESKTOP_LOOPBACK_HOST,
125 localHttpUrl,
126 localWsUrl,
127 endpointUrl: null,
128 advertisedHost: null,
129 };
130 }
131
132 const advertisedHost = resolveLanAdvertisedHost(
133 input.networkInterfaces,
134 input.advertisedHostOverride,
135 );
136
137 return {
138 mode: input.mode,
139 bindHost: DESKTOP_LAN_BIND_HOST,
140 localHttpUrl,
141 localWsUrl,
142 endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
143 advertisedHost,
144 };
145};
146
147const createDesktopEndpoint = (
148 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
149): AdvertisedEndpoint =>
150 createAdvertisedEndpoint({
151 ...input,
152 provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
153 source: "desktop-core",
154 });
155
156const createManualEndpoint = (
157 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
158): AdvertisedEndpoint =>
159 createAdvertisedEndpoint({
160 ...input,
161 provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
162 source: "user",
163 });
164
165const resolveDesktopCoreAdvertisedEndpoints = (
166 input: DesktopAdvertisedEndpointInput,
167): readonly AdvertisedEndpoint[] => {
168 const endpoints: AdvertisedEndpoint[] = [
169 createDesktopEndpoint({
170 id: `desktop-loopback:${input.port}`,
171 label: "This machine",
172 httpBaseUrl: input.exposure.localHttpUrl,
173 reachability: "loopback",
174 status: "available",
175 description: "Loopback endpoint for this desktop app.",
176 }),
177 ];
178
179 if (input.exposure.endpointUrl) {
180 endpoints.push(
181 createDesktopEndpoint({
182 id: `desktop-lan:${input.exposure.endpointUrl}`,
183 label: "Local network",
184 httpBaseUrl: input.exposure.endpointUrl,
185 reachability: "lan",
186 status: "available",
187 isDefault: true,
188 description: "Reachable from devices on the same network.",
189 }),
190 );
191 }
192
193 for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
194 try {
195 const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl);
196 endpoints.push(
197 createManualEndpoint({
198 id: `manual:${customEndpointUrl}`,
199 label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint",
200 httpBaseUrl: customEndpointUrl,
201 reachability: "public",
202 ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
203 status: "unknown",
204 description: isHttpsEndpoint
205 ? "User-configured HTTPS endpoint for this desktop backend."
206 : "User-configured endpoint for this desktop backend.",
207 }),
208 );
209 } catch {
210 // Ignore malformed user-configured endpoints without dropping valid endpoints.
211 }
212 }
213
214 return endpoints;
215};
216
217type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve";
218
219export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError(
220 "DesktopServerExposureNoNetworkAddressError",
221)<{
222 readonly port: number;
223}> {
224 override get message() {
225 return `No reachable network address is available for desktop network access on port ${this.port}.`;
226 }
227}
228
229export class DesktopServerExposurePersistenceError extends Data.TaggedError(
230 "DesktopServerExposurePersistenceError",
231)<{
232 readonly operation: DesktopServerExposurePersistenceOperation;
233 readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError;
234}> {
235 override get message() {
236 return `Failed to persist desktop ${this.operation} settings.`;
237 }
238}
239
240export type DesktopServerExposureSetModeError =
241 | DesktopServerExposureNoNetworkAddressError
242 | DesktopServerExposurePersistenceError;
243
244export type DesktopServerExposureError = DesktopServerExposureSetModeError;
245
246export interface DesktopServerExposureBackendConfig {
247 readonly port: number;
248 readonly bindHost: string;
249 readonly httpBaseUrl: URL;
250 readonly tailscaleServeEnabled: boolean;
251 readonly tailscaleServePort: number;
252}
253
254export interface DesktopServerExposureChange {
255 readonly state: DesktopServerExposureState;
256 readonly requiresRelaunch: boolean;
257}
258
259export interface DesktopServerExposureShape {
260 readonly getState: Effect.Effect<DesktopServerExposureState>;
261 readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>;
262 readonly configureFromSettings: (input: {
263 readonly port: number;
264 }) => Effect.Effect<DesktopServerExposureState>;
265 readonly setMode: (
266 mode: DesktopServerExposureMode,
267 ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>;
268 readonly setTailscaleServeEnabled: (input: {
269 readonly enabled: boolean;
270 readonly port?: number;
271 }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>;
272 readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
273}
274
275export class DesktopServerExposure extends Context.Service<
276 DesktopServerExposure,
277 DesktopServerExposureShape
278>()("t3/desktop/ServerExposure") {}
279
280export interface DesktopNetworkInterfacesServiceShape {
281 readonly read: Effect.Effect<DesktopNetworkInterfaces>;
282}
283
284export class DesktopNetworkInterfacesService extends Context.Service<
285 DesktopNetworkInterfacesService,
286 DesktopNetworkInterfacesServiceShape
287>()("t3/desktop/ServerExposure/NetworkInterfaces") {}
288
289interface RuntimeState {
290 readonly requestedMode: DesktopServerExposureMode;
291 readonly mode: DesktopServerExposureMode;
292 readonly port: number;
293 readonly bindHost: string;
294 readonly localHttpUrl: string;
295 readonly localWsUrl: string;
296 readonly httpBaseUrl: URL;
297 readonly endpointUrl: Option.Option<string>;
298 readonly advertisedHost: Option.Option<string>;
299 readonly tailscaleServeEnabled: boolean;
300 readonly tailscaleServePort: number;
301}
302
303interface ResolvedRuntimeState {
304 readonly state: RuntimeState;
305 readonly unavailable: boolean;
306}
307
308const initialRuntimeState = (): RuntimeState =>
309 runtimeStateFromResolvedExposure({
310 requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
311 settings: DEFAULT_DESKTOP_SETTINGS,
312 exposure: resolveDesktopServerExposure({
313 mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
314 port: 0,
315 networkInterfaces: {},
316 }),
317 port: 0,
318 });
319
320const toContractState = (state: RuntimeState): DesktopServerExposureState => ({
321 mode: state.mode,
322 endpointUrl: Option.getOrNull(state.endpointUrl),
323 advertisedHost: Option.getOrNull(state.advertisedHost),
324 tailscaleServeEnabled: state.tailscaleServeEnabled,
325 tailscaleServePort: state.tailscaleServePort,
326});
327
328const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({
329 port: state.port,
330 bindHost: state.bindHost,
331 httpBaseUrl: state.httpBaseUrl,
332 tailscaleServeEnabled: state.tailscaleServeEnabled,
333 tailscaleServePort: state.tailscaleServePort,
334});
335
336const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({
337 mode: state.mode,
338 bindHost: state.bindHost,
339 localHttpUrl: state.localHttpUrl,
340 localWsUrl: state.localWsUrl,
341 endpointUrl: Option.getOrNull(state.endpointUrl),
342 advertisedHost: Option.getOrNull(state.advertisedHost),
343});
344
345function runtimeStateFromResolvedExposure(input: {
346 readonly requestedMode: DesktopServerExposureMode;
347 readonly settings: DesktopSettings;
348 readonly exposure: ResolvedDesktopServerExposure;
349 readonly port: number;
350}): RuntimeState {
351 return {
352 requestedMode: input.requestedMode,
353 mode: input.exposure.mode,
354 port: input.port,
355 bindHost: input.exposure.bindHost,
356 localHttpUrl: input.exposure.localHttpUrl,
357 localWsUrl: input.exposure.localWsUrl,
358 httpBaseUrl: new URL(input.exposure.localHttpUrl),
359 endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl),
360 advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost),
361 tailscaleServeEnabled: input.settings.tailscaleServeEnabled,
362 tailscaleServePort: input.settings.tailscaleServePort,
363 };
364}
365
366function resolveRuntimeState(input: {
367 readonly requestedMode: DesktopServerExposureMode;
368 readonly settings: DesktopSettings;
369 readonly port: number;
370 readonly networkInterfaces: DesktopNetworkInterfaces;
371 readonly advertisedHostOverride: Option.Option<string>;
372}): ResolvedRuntimeState {
373 const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride);
374 const requestedExposure = resolveDesktopServerExposure({
375 mode: input.requestedMode,
376 port: input.port,
377 networkInterfaces: input.networkInterfaces,
378 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
379 });
380 const unavailable =
381 input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null;
382 const exposure = unavailable
383 ? resolveDesktopServerExposure({
384 mode: "local-only",
385 port: input.port,
386 networkInterfaces: input.networkInterfaces,
387 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
388 })
389 : requestedExposure;
390
391 return {
392 state: runtimeStateFromResolvedExposure({
393 requestedMode: input.requestedMode,
394 settings: input.settings,
395 exposure,
396 port: input.port,
397 }),
398 unavailable,
399 };
400}
401
402const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean =>
403 previous.port !== next.port ||
404 previous.bindHost !== next.bindHost ||
405 previous.localHttpUrl !== next.localHttpUrl;
406
407const make = Effect.gen(function* () {
408 const config = yield* DesktopConfig.DesktopConfig;
409 const networkInterfaces = yield* DesktopNetworkInterfacesService;
410 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
411 const httpClient = yield* HttpClient.HttpClient;
412 const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;
413 const stateRef = yield* Ref.make(initialRuntimeState());
414
415 const readNetworkInterfaces = networkInterfaces.read;
416
417 const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
418 const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig));
419
420 const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")(
421 function* ({ port }: { readonly port: number }) {
422 yield* Effect.annotateCurrentSpan({ port });
423 const settings = yield* desktopSettings.get;
424 const currentNetworkInterfaces = yield* readNetworkInterfaces;
425 const resolved = resolveRuntimeState({
426 requestedMode: settings.serverExposureMode,
427 settings,
428 port,
429 networkInterfaces: currentNetworkInterfaces,
430 advertisedHostOverride: config.desktopLanHostOverride,
431 });
432 yield* Ref.set(stateRef, resolved.state);
433 return toContractState(resolved.state);
434 },
435 );
436
437 const setMode = Effect.fn("desktop.serverExposure.setMode")(function* (
438 mode: DesktopServerExposureMode,
439 ) {
440 yield* Effect.annotateCurrentSpan({ mode });
441 const previous = yield* Ref.get(stateRef);
442 const currentSettings = yield* desktopSettings.get;
443 const nextSettings = {
444 ...currentSettings,
445 serverExposureMode: mode,
446 };
447 const currentNetworkInterfaces = yield* readNetworkInterfaces;
448 const resolved = resolveRuntimeState({
449 requestedMode: mode,
450 settings: nextSettings,
451 port: previous.port,
452 networkInterfaces: currentNetworkInterfaces,
453 advertisedHostOverride: config.desktopLanHostOverride,
454 });
455
456 if (resolved.unavailable) {
457 return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port });
458 }
459
460 const change = yield* desktopSettings.setServerExposureMode(mode).pipe(
461 Effect.mapError(
462 (cause) =>
463 new DesktopServerExposurePersistenceError({
464 operation: "server-exposure-mode",
465 cause,
466 }),
467 ),
468 );
469
470 yield* Ref.set(stateRef, resolved.state);
471 return {
472 state: toContractState(resolved.state),
473 requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state),
474 };
475 });
476
477 const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")(
478 function* (input: { readonly enabled: boolean; readonly port?: number }) {
479 yield* Effect.annotateCurrentSpan({
480 enabled: input.enabled,
481 ...(input.port === undefined ? {} : { port: input.port }),
482 });
483 const result = yield* desktopSettings
484 .setTailscaleServe({
485 enabled: input.enabled,
486 port: Option.fromNullishOr(input.port),
487 })
488 .pipe(
489 Effect.mapError(
490 (cause) =>
491 new DesktopServerExposurePersistenceError({
492 operation: "tailscale-serve",
493 cause,
494 }),
495 ),
496 );
497
498 const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({
499 ...current,
500 tailscaleServeEnabled: result.settings.tailscaleServeEnabled,
501 tailscaleServePort: result.settings.tailscaleServePort,
502 }));
503
504 return {
505 state: toContractState(nextState),
506 requiresRelaunch: result.changed,
507 };
508 },
509 );
510
511 const getAdvertisedEndpoints = Effect.gen(function* () {
512 const state = yield* Ref.get(stateRef);
513 const currentNetworkInterfaces = yield* readNetworkInterfaces;
514 const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
515 port: state.port,
516 exposure: toResolvedExposure(state),
517 customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
518 });
519 const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
520 port: state.port,
521 serveEnabled: state.tailscaleServeEnabled,
522 servePort: state.tailscaleServePort,
523 networkInterfaces: currentNetworkInterfaces,
524 }).pipe(
525 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
526 Effect.provideService(HttpClient.HttpClient, httpClient),
527 );
528 return [...coreEndpoints, ...tailscaleEndpoints];
529 }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints"));
530
531 return DesktopServerExposure.of({
532 getState,
533 backendConfig,
534 configureFromSettings,
535 setMode,
536 setTailscaleServeEnabled,
537 getAdvertisedEndpoints,
538 });
539});
540
541export const layer = Layer.effect(DesktopServerExposure, make);
542
543export const networkInterfacesLayer = Layer.succeed(
544 DesktopNetworkInterfacesService,
545 DesktopNetworkInterfacesService.of({
546 read: Effect.sync(() => NodeOS.networkInterfaces()),
547 }),
548);
apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts deleted-142
......@@ -1,142 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpClient } from "effect/unstable/http";
5import { ChildProcessSpawner } from "effect/unstable/process";
6
7import {
8 isTailscaleIpv4Address,
9 parseTailscaleMagicDnsName,
10 resolveTailscaleAdvertisedEndpoints,
11} from "./tailscaleEndpointProvider.ts";
12
13const unusedTailscaleExternalServicesLayer = Layer.mergeAll(
14 Layer.succeed(
15 HttpClient.HttpClient,
16 HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")),
17 ),
18 Layer.succeed(
19 ChildProcessSpawner.ChildProcessSpawner,
20 ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")),
21 ),
22);
23
24describe("tailscale endpoint provider", () => {
25 it("detects Tailnet IPv4 addresses", () => {
26 assert.equal(isTailscaleIpv4Address("100.64.0.1"), true);
27 assert.equal(isTailscaleIpv4Address("100.127.255.254"), true);
28 assert.equal(isTailscaleIpv4Address("100.128.0.1"), false);
29 assert.equal(isTailscaleIpv4Address("192.168.1.44"), false);
30 });
31
32 it.effect("parses MagicDNS names from tailscale status", () =>
33 Effect.gen(function* () {
34 const dnsName = yield* parseTailscaleMagicDnsName(
35 `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
36 );
37 assert.equal(dnsName, "desktop.tail.ts.net");
38 assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
39 const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
40 assert.isTrue(malformed._tag === "Failure");
41 }),
42 );
43
44 it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () =>
45 Effect.gen(function* () {
46 const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
47 port: 3773,
48 networkInterfaces: {
49 tailscale0: [
50 {
51 address: "100.100.100.100",
52 family: "IPv4",
53 internal: false,
54 netmask: "255.192.0.0",
55 cidr: "100.100.100.100/10",
56 mac: "00:00:00:00:00:00",
57 },
58 ],
59 },
60 statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
61 });
62 assert.deepEqual(endpoints, [
63 {
64 id: "tailscale-ip:http://100.100.100.100:3773",
65 label: "Tailscale IP",
66 provider: {
67 id: "tailscale",
68 label: "Tailscale",
69 kind: "private-network",
70 isAddon: true,
71 },
72 httpBaseUrl: "http://100.100.100.100:3773/",
73 wsBaseUrl: "ws://100.100.100.100:3773/",
74 reachability: "private-network",
75 compatibility: {
76 hostedHttpsApp: "mixed-content-blocked",
77 desktopApp: "compatible",
78 },
79 source: "desktop-addon",
80 status: "available",
81 description: "Reachable from devices on the same Tailnet.",
82 },
83 {
84 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
85 label: "Tailscale HTTPS",
86 provider: {
87 id: "tailscale",
88 label: "Tailscale",
89 kind: "private-network",
90 isAddon: true,
91 },
92 httpBaseUrl: "https://desktop.tail.ts.net/",
93 wsBaseUrl: "wss://desktop.tail.ts.net/",
94 reachability: "private-network",
95 compatibility: {
96 hostedHttpsApp: "requires-configuration",
97 desktopApp: "compatible",
98 },
99 source: "desktop-addon",
100 status: "unavailable",
101 description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
102 },
103 ]);
104 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
105 );
106
107 it.effect(
108 "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable",
109 () =>
110 Effect.gen(function* () {
111 const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
112 port: 3773,
113 networkInterfaces: {},
114 statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
115 serveEnabled: true,
116 probe: () => Effect.succeed(true),
117 });
118 assert.deepEqual(endpoints, [
119 {
120 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
121 label: "Tailscale HTTPS",
122 provider: {
123 id: "tailscale",
124 label: "Tailscale",
125 kind: "private-network",
126 isAddon: true,
127 },
128 httpBaseUrl: "https://desktop.tail.ts.net/",
129 wsBaseUrl: "wss://desktop.tail.ts.net/",
130 reachability: "private-network",
131 compatibility: {
132 hostedHttpsApp: "compatible",
133 desktopApp: "compatible",
134 },
135 source: "desktop-addon",
136 status: "available",
137 description: "HTTPS endpoint served by Tailscale Serve.",
138 },
139 ]);
140 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
141 );
142});
apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts deleted-138
......@@ -1,138 +0,0 @@
1import { createAdvertisedEndpoint } from "@t3tools/client-runtime";
2import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
3import {
4 buildTailscaleHttpsBaseUrl,
5 isTailscaleIpv4Address,
6 parseTailscaleMagicDnsName,
7 probeTailscaleHttpsEndpoint,
8 readTailscaleStatus,
9} from "@t3tools/tailscale";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import { HttpClient } from "effect/unstable/http";
13import { ChildProcessSpawner } from "effect/unstable/process";
14
15import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
16
17export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
18
19const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
20 id: "tailscale",
21 label: "Tailscale",
22 kind: "private-network",
23 isAddon: true,
24};
25
26function resolveTailscaleIpAdvertisedEndpoints(input: {
27 readonly port: number;
28 readonly networkInterfaces: DesktopNetworkInterfaces;
29}): readonly AdvertisedEndpoint[] {
30 const seen = new Set<string>();
31 const endpoints: AdvertisedEndpoint[] = [];
32
33 for (const interfaceAddresses of Object.values(input.networkInterfaces)) {
34 if (!interfaceAddresses) continue;
35
36 for (const address of interfaceAddresses) {
37 if (address.internal) continue;
38 if (address.family !== "IPv4") continue;
39 if (!isTailscaleIpv4Address(address.address)) continue;
40 if (seen.has(address.address)) continue;
41 seen.add(address.address);
42
43 endpoints.push(
44 createAdvertisedEndpoint({
45 provider: TAILSCALE_ENDPOINT_PROVIDER,
46 source: "desktop-addon",
47 id: `tailscale-ip:http://${address.address}:${input.port}`,
48 label: "Tailscale IP",
49 httpBaseUrl: `http://${address.address}:${input.port}`,
50 reachability: "private-network",
51 status: "available",
52 description: "Reachable from devices on the same Tailnet.",
53 }),
54 );
55 }
56 }
57
58 return endpoints;
59}
60
61const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
62 "resolveTailscaleMagicDnsAdvertisedEndpoint",
63)(function* (input: {
64 readonly dnsName: string | null;
65 readonly serveEnabled: boolean;
66 readonly servePort?: number;
67 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
68}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {
69 if (!input.dnsName) {
70 return Option.none();
71 }
72
73 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
74 magicDnsName: input.dnsName,
75 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
76 });
77 const probe =
78 input.probe?.(httpBaseUrl) ??
79 probeTailscaleHttpsEndpoint({
80 baseUrl: httpBaseUrl,
81 });
82 const isReachable = input.serveEnabled ? yield* probe : false;
83
84 return Option.some(
85 createAdvertisedEndpoint({
86 provider: TAILSCALE_ENDPOINT_PROVIDER,
87 source: "desktop-addon",
88 id: `tailscale-magicdns:${httpBaseUrl}`,
89 label: "Tailscale HTTPS",
90 httpBaseUrl,
91 reachability: "private-network",
92 hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration",
93 status: isReachable ? "available" : "unavailable",
94 description: isReachable
95 ? "HTTPS endpoint served by Tailscale Serve."
96 : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
97 }),
98 );
99});
100
101export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")(
102 function* (input: {
103 readonly port: number;
104 readonly serveEnabled?: boolean;
105 readonly servePort?: number;
106 readonly networkInterfaces: DesktopNetworkInterfaces;
107 readonly statusJson?: string | null;
108 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
109 }): Effect.fn.Return<
110 readonly AdvertisedEndpoint[],
111 never,
112 ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient
113 > {
114 const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
115 const dnsName =
116 input.statusJson === undefined
117 ? yield* readTailscaleStatus.pipe(
118 Effect.map((status) => status.magicDnsName),
119 Effect.catch(() => Effect.succeed(null)),
120 )
121 : input.statusJson
122 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
123 Effect.catch(() => Effect.succeed(null)),
124 )
125 : null;
126 const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
127 dnsName,
128 serveEnabled: input.serveEnabled === true,
129 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
130 ...(input.probe === undefined ? {} : { probe: input.probe }),
131 });
132
133 return Option.match(magicDnsEndpoint, {
134 onNone: () => ipEndpoints,
135 onSome: (endpoint) => [...ipEndpoints, endpoint],
136 });
137 },
138);
apps/desktop/src/window/DesktopWindow.test.ts+1-1
......@@ -16,7 +16,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts";
1616import * as ElectronShell from "../electron/ElectronShell.ts";
1717import * as ElectronTheme from "../electron/ElectronTheme.ts";
1818import * as ElectronWindow from "../electron/ElectronWindow.ts";
19import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
19import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
2020import * as DesktopWindow from "./DesktopWindow.ts";
2121
2222const environmentInput = {
apps/desktop/src/window/DesktopWindow.ts+1-1
......@@ -16,7 +16,7 @@ import * as ElectronShell from "../electron/ElectronShell.ts";
1616import * as ElectronTheme from "../electron/ElectronTheme.ts";
1717import * as ElectronWindow from "../electron/ElectronWindow.ts";
1818import * as IpcChannels from "../ipc/channels.ts";
19import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
19import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
2020
2121const TITLEBAR_HEIGHT = 40;
2222const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux