1import {
2 createAdvertisedEndpoint,
3 type CreateAdvertisedEndpointInput,
4} from "@t3tools/shared/advertisedEndpoint";
5import {
6 DesktopServerExposureModeSchema,
7 type AdvertisedEndpoint,
8 type AdvertisedEndpointProvider,
9 type DesktopServerExposureMode,
10 type DesktopServerExposureState,
11} from "@t3tools/contracts";
12import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale";
13import * as Context from "effect/Context";
14import * as Duration from "effect/Duration";
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 * as Schema from "effect/Schema";
20import * as Semaphore from "effect/Semaphore";
21import * as HttpClient from "effect/http/HttpClient";
22import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
23
24import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
25import * as DesktopConfig from "../app/DesktopConfig.ts";
26import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
27import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
28
29const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60);
30
31const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
32const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
33
34interface ResolvedDesktopServerExposure {
35 readonly mode: DesktopServerExposureMode;
36 readonly bindHost: string;
37 readonly localHttpUrl: string;
38 readonly localWsUrl: string;
39 readonly endpointUrl: string | null;
40 readonly advertisedHost: string | null;
41}
42
43interface DesktopAdvertisedEndpointInput {
44 readonly port: number;
45 readonly exposure: ResolvedDesktopServerExposure;
46 readonly customHttpsEndpointUrls?: readonly string[];
47}
48
49const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
50 id: "desktop-core",
51 label: "Desktop",
52 kind: "core",
53 isAddon: false,
54};
55
56const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
57 id: "manual",
58 label: "Manual",
59 kind: "manual",
60 isAddon: false,
61};
62
63const normalizeOptionalHost = (value: string | undefined): string | undefined => {
64 const normalized = value?.trim();
65 return normalized && normalized.length > 0 ? normalized : undefined;
66};
67
68const isUsableLanIpv4Address = (address: string): boolean =>
69 !address.startsWith("127.") &&
70 !address.startsWith("169.254.") &&
71 !isTailscaleIpv4Address(address);
72
73const isHttpsEndpointUrl = (value: string): boolean => {
74 try {
75 return new URL(value).protocol === "https:";
76 } catch {
77 return false;
78 }
79};
80
81const resolveLanAdvertisedHost = (
82 networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces,
83 explicitHost: string | undefined,
84): string | null => {
85 const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
86 if (normalizedExplicitHost) {
87 return normalizedExplicitHost;
88 }
89
90 for (const interfaceAddresses of Object.values(networkInterfaces)) {
91 if (!interfaceAddresses) continue;
92
93 for (const address of interfaceAddresses) {
94 if (address.internal) continue;
95 if (address.family !== "IPv4") continue;
96 if (!isUsableLanIpv4Address(address.address)) continue;
97 return address.address;
98 }
99 }
100
101 return null;
102};
103
104const resolveDesktopServerExposure = (input: {
105 readonly mode: DesktopServerExposureMode;
106 readonly port: number;
107 readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces;
108 readonly advertisedHostOverride?: string;
109}): ResolvedDesktopServerExposure => {
110 const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
111 const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
112
113 if (input.mode === "local-only") {
114 return {
115 mode: input.mode,
116 bindHost: DESKTOP_LOOPBACK_HOST,
117 localHttpUrl,
118 localWsUrl,
119 endpointUrl: null,
120 advertisedHost: null,
121 };
122 }
123
124 const advertisedHost = resolveLanAdvertisedHost(
125 input.networkInterfaces,
126 input.advertisedHostOverride,
127 );
128
129 return {
130 mode: input.mode,
131 bindHost: DESKTOP_LAN_BIND_HOST,
132 localHttpUrl,
133 localWsUrl,
134 endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
135 advertisedHost,
136 };
137};
138
139const createDesktopEndpoint = (
140 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
141): AdvertisedEndpoint =>
142 createAdvertisedEndpoint({
143 ...input,
144 provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
145 source: "desktop-core",
146 });
147
148const createManualEndpoint = (
149 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
150): AdvertisedEndpoint =>
151 createAdvertisedEndpoint({
152 ...input,
153 provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
154 source: "user",
155 });
156
157const resolveDesktopCoreAdvertisedEndpoints = (
158 input: DesktopAdvertisedEndpointInput,
159): readonly AdvertisedEndpoint[] => {
160 const endpoints: AdvertisedEndpoint[] = [
161 createDesktopEndpoint({
162 id: `desktop-loopback:${input.port}`,
163 label: "This machine",
164 httpBaseUrl: input.exposure.localHttpUrl,
165 reachability: "loopback",
166 status: "available",
167 description: "Loopback endpoint for this desktop app.",
168 }),
169 ];
170
171 if (input.exposure.endpointUrl) {
172 endpoints.push(
173 createDesktopEndpoint({
174 id: `desktop-lan:${input.exposure.endpointUrl}`,
175 label: "Local network",
176 httpBaseUrl: input.exposure.endpointUrl,
177 reachability: "lan",
178 status: "available",
179 isDefault: true,
180 description: "Reachable from devices on the same network.",
181 }),
182 );
183 }
184
185 for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
186 try {
187 const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl);
188 endpoints.push(
189 createManualEndpoint({
190 id: `manual:${customEndpointUrl}`,
191 label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint",
192 httpBaseUrl: customEndpointUrl,
193 reachability: "public",
194 ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
195 status: "unknown",
196 description: isHttpsEndpoint
197 ? "User-configured HTTPS endpoint for this desktop backend."
198 : "User-configured endpoint for this desktop backend.",
199 }),
200 );
201 } catch {
202 // Ignore malformed user-configured endpoints without dropping valid endpoints.
203 }
204 }
205
206 return endpoints;
207};
208
209export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedError<DesktopServerExposureNoNetworkAddressError>()(
210 "DesktopServerExposureNoNetworkAddressError",
211 {
212 port: Schema.Number,
213 },
214) {
215 override get message(): string {
216 return `No reachable network address is available for desktop network access on port ${this.port}.`;
217 }
218}
219
220export class DesktopServerExposureModePersistenceError extends Schema.TaggedError<DesktopServerExposureModePersistenceError>()(
221 "DesktopServerExposureModePersistenceError",
222 {
223 mode: DesktopServerExposureModeSchema,
224 cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError),
225 },
226) {
227 override get message(): string {
228 return `Failed to persist desktop server exposure mode ${this.mode}.`;
229 }
230}
231
232export class DesktopTailscaleServePersistenceError extends Schema.TaggedError<DesktopTailscaleServePersistenceError>()(
233 "DesktopTailscaleServePersistenceError",
234 {
235 enabled: Schema.Boolean,
236 port: Schema.NullOr(Schema.Number),
237 cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError),
238 },
239) {
240 override get message(): string {
241 return `Failed to persist desktop Tailscale Serve settings (enabled: ${this.enabled}, port: ${this.port ?? "unchanged"}).`;
242 }
243}
244
245export const DesktopServerExposureSetModeError = Schema.Union([
246 DesktopServerExposureNoNetworkAddressError,
247 DesktopServerExposureModePersistenceError,
248]);
249export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type;
250
251export const DesktopServerExposureError = Schema.Union([
252 DesktopServerExposureNoNetworkAddressError,
253 DesktopServerExposureModePersistenceError,
254 DesktopTailscaleServePersistenceError,
255]);
256export type DesktopServerExposureError = typeof DesktopServerExposureError.Type;
257
258export interface DesktopServerExposureBackendConfig {
259 readonly port: number;
260 readonly bindHost: string;
261 readonly httpBaseUrl: URL;
262 readonly tailscaleServeEnabled: boolean;
263 readonly tailscaleServePort: number;
264}
265
266export interface DesktopServerExposureChange {
267 readonly state: DesktopServerExposureState;
268 readonly requiresRelaunch: boolean;
269}
270
271export class DesktopServerExposure extends Context.Service<
272 DesktopServerExposure,
273 {
274 readonly getState: Effect.Effect<DesktopServerExposureState>;
275 readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>;
276 readonly configureFromSettings: (input: {
277 readonly port: number;
278 }) => Effect.Effect<DesktopServerExposureState>;
279 readonly setMode: (
280 mode: DesktopServerExposureMode,
281 ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>;
282 readonly setTailscaleServeEnabled: (input: {
283 readonly enabled: boolean;
284 readonly port?: number;
285 }) => Effect.Effect<DesktopServerExposureChange, DesktopTailscaleServePersistenceError>;
286 readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
287 }
288>()("@t3tools/desktop/backend/DesktopServerExposure") {}
289
290interface RuntimeState {
291 readonly requestedMode: DesktopServerExposureMode;
292 readonly mode: DesktopServerExposureMode;
293 readonly port: number;
294 readonly bindHost: string;
295 readonly localHttpUrl: string;
296 readonly localWsUrl: string;
297 readonly httpBaseUrl: URL;
298 readonly endpointUrl: Option.Option<string>;
299 readonly advertisedHost: Option.Option<string>;
300 readonly tailscaleServeEnabled: boolean;
301 readonly tailscaleServePort: number;
302}
303
304interface ResolvedRuntimeState {
305 readonly state: RuntimeState;
306 readonly unavailable: boolean;
307}
308
309const initialRuntimeState = (): RuntimeState =>
310 runtimeStateFromResolvedExposure({
311 requestedMode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
312 settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
313 exposure: resolveDesktopServerExposure({
314 mode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
315 port: 0,
316 networkInterfaces: {},
317 }),
318 port: 0,
319 });
320
321const toContractState = (state: RuntimeState): DesktopServerExposureState => ({
322 mode: state.mode,
323 endpointUrl: Option.getOrNull(state.endpointUrl),
324 advertisedHost: Option.getOrNull(state.advertisedHost),
325 tailscaleServeEnabled: state.tailscaleServeEnabled,
326 tailscaleServePort: state.tailscaleServePort,
327});
328
329const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({
330 port: state.port,
331 bindHost: state.bindHost,
332 httpBaseUrl: state.httpBaseUrl,
333 tailscaleServeEnabled: state.tailscaleServeEnabled,
334 tailscaleServePort: state.tailscaleServePort,
335});
336
337const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({
338 mode: state.mode,
339 bindHost: state.bindHost,
340 localHttpUrl: state.localHttpUrl,
341 localWsUrl: state.localWsUrl,
342 endpointUrl: Option.getOrNull(state.endpointUrl),
343 advertisedHost: Option.getOrNull(state.advertisedHost),
344});
345
346function runtimeStateFromResolvedExposure(input: {
347 readonly requestedMode: DesktopServerExposureMode;
348 readonly settings: DesktopAppSettings.DesktopSettings;
349 readonly exposure: ResolvedDesktopServerExposure;
350 readonly port: number;
351}): RuntimeState {
352 return {
353 requestedMode: input.requestedMode,
354 mode: input.exposure.mode,
355 port: input.port,
356 bindHost: input.exposure.bindHost,
357 localHttpUrl: input.exposure.localHttpUrl,
358 localWsUrl: input.exposure.localWsUrl,
359 httpBaseUrl: new URL(input.exposure.localHttpUrl),
360 endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl),
361 advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost),
362 tailscaleServeEnabled: input.settings.tailscaleServeEnabled,
363 tailscaleServePort: input.settings.tailscaleServePort,
364 };
365}
366
367function resolveRuntimeState(input: {
368 readonly requestedMode: DesktopServerExposureMode;
369 readonly settings: DesktopAppSettings.DesktopSettings;
370 readonly port: number;
371 readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces;
372 readonly advertisedHostOverride: Option.Option<string>;
373}): ResolvedRuntimeState {
374 const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride);
375 const requestedExposure = resolveDesktopServerExposure({
376 mode: input.requestedMode,
377 port: input.port,
378 networkInterfaces: input.networkInterfaces,
379 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
380 });
381 const unavailable =
382 input.requestedMode === "network-accessible" &&
383 requestedExposure.endpointUrl === null &&
384 !Object.values(input.networkInterfaces).some((addresses) =>
385 addresses?.some(
386 (address) =>
387 !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address),
388 ),
389 );
390 const exposure = unavailable
391 ? resolveDesktopServerExposure({
392 mode: "local-only",
393 port: input.port,
394 networkInterfaces: input.networkInterfaces,
395 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
396 })
397 : requestedExposure;
398
399 return {
400 state: runtimeStateFromResolvedExposure({
401 requestedMode: input.requestedMode,
402 settings: input.settings,
403 exposure,
404 port: input.port,
405 }),
406 unavailable,
407 };
408}
409
410const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean =>
411 previous.port !== next.port ||
412 previous.bindHost !== next.bindHost ||
413 previous.localHttpUrl !== next.localHttpUrl;
414
415/** @public Service construction is part of the canonical Effect module API. */
416export const make = Effect.gen(function* () {
417 const config = yield* DesktopConfig.DesktopConfig;
418 const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces;
419 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
420 const httpClient = yield* HttpClient.HttpClient;
421 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
422 const stateRef = yield* Ref.make(initialRuntimeState());
423 // Each change reads the runtime state, persists settings, then writes the
424 // state back. Run them one at a time so a change never writes over another
425 // with values it read before that change persisted.
426 const changePermit = yield* Semaphore.make(1);
427
428 // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App
429 // Store Tailscale CLI lives inside Tailscale's sandbox container, so each
430 // spawn re-triggers the "Other apps" TCC prompt.
431 const cachedReadMagicDnsName = yield* Effect.cachedWithTTL(
432 readTailscaleStatus.pipe(
433 Effect.map((status) => status.magicDnsName),
434 Effect.orElseSucceed(() => null),
435 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
436 ),
437 TAILSCALE_STATUS_CACHE_TTL,
438 );
439
440 const readNetworkInterfaces = networkInterfaces.read;
441
442 const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
443 const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig));
444
445 const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")(
446 function* ({ port }: { readonly port: number }) {
447 yield* Effect.annotateCurrentSpan({ port });
448 const settings = yield* desktopSettings.get;
449 const currentNetworkInterfaces = yield* readNetworkInterfaces;
450 const resolved = resolveRuntimeState({
451 requestedMode: settings.serverExposureMode,
452 settings,
453 port,
454 networkInterfaces: currentNetworkInterfaces,
455 advertisedHostOverride: config.desktopLanHostOverride,
456 });
457 yield* Ref.set(stateRef, resolved.state);
458 return toContractState(resolved.state);
459 },
460 );
461
462 const setMode = Effect.fn("desktop.serverExposure.setMode")(function* (
463 mode: DesktopServerExposureMode,
464 ) {
465 yield* Effect.annotateCurrentSpan({ mode });
466 const previous = yield* Ref.get(stateRef);
467 const currentSettings = yield* desktopSettings.get;
468 const nextSettings = {
469 ...currentSettings,
470 serverExposureMode: mode,
471 };
472 const currentNetworkInterfaces = yield* readNetworkInterfaces;
473 const resolved = resolveRuntimeState({
474 requestedMode: mode,
475 settings: nextSettings,
476 port: previous.port,
477 networkInterfaces: currentNetworkInterfaces,
478 advertisedHostOverride: config.desktopLanHostOverride,
479 });
480
481 if (resolved.unavailable) {
482 return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port });
483 }
484
485 const change = yield* desktopSettings.setServerExposureMode(mode).pipe(
486 Effect.mapError(
487 (cause) =>
488 new DesktopServerExposureModePersistenceError({
489 mode,
490 cause,
491 }),
492 ),
493 );
494
495 yield* Ref.set(stateRef, resolved.state);
496 return {
497 state: toContractState(resolved.state),
498 requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state),
499 };
500 }, changePermit.withPermit);
501
502 const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")(
503 function* (input: { readonly enabled: boolean; readonly port?: number }) {
504 yield* Effect.annotateCurrentSpan({
505 enabled: input.enabled,
506 ...(input.port === undefined ? {} : { port: input.port }),
507 });
508 const result = yield* desktopSettings
509 .setTailscaleServe({
510 enabled: input.enabled,
511 port: Option.fromNullishOr(input.port),
512 })
513 .pipe(
514 Effect.mapError(
515 (cause) =>
516 new DesktopTailscaleServePersistenceError({
517 enabled: input.enabled,
518 port: input.port ?? null,
519 cause,
520 }),
521 ),
522 );
523
524 const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({
525 ...current,
526 tailscaleServeEnabled: result.settings.tailscaleServeEnabled,
527 tailscaleServePort: result.settings.tailscaleServePort,
528 }));
529
530 return {
531 state: toContractState(nextState),
532 requiresRelaunch: result.changed,
533 };
534 },
535 changePermit.withPermit,
536 );
537
538 const getAdvertisedEndpoints = Effect.gen(function* () {
539 const state = yield* Ref.get(stateRef);
540 const currentNetworkInterfaces = yield* readNetworkInterfaces;
541 const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
542 port: state.port,
543 exposure: toResolvedExposure(state),
544 customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
545 });
546
547 // Don't spawn the Tailscale CLI when the user hasn't opted into any
548 // network exposure. The spawn itself triggers a macOS "Other apps"
549 // TCC prompt on Mac App Store Tailscale builds.
550 if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) {
551 return coreEndpoints;
552 }
553
554 const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
555 port: state.port,
556 serveEnabled: state.tailscaleServeEnabled,
557 servePort: state.tailscaleServePort,
558 networkInterfaces: currentNetworkInterfaces,
559 readMagicDnsName: cachedReadMagicDnsName,
560 }).pipe(
561 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
562 Effect.provideService(HttpClient.HttpClient, httpClient),
563 );
564 return [...coreEndpoints, ...tailscaleEndpoints];
565 }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints"));
566
567 return DesktopServerExposure.of({
568 getState,
569 backendConfig,
570 configureFromSettings,
571 setMode,
572 setTailscaleServeEnabled,
573 getAdvertisedEndpoints,
574 });
575});
576
577export const layer = Layer.effect(DesktopServerExposure, make);