import { createAdvertisedEndpoint, type CreateAdvertisedEndpointInput, } from "@t3tools/shared/advertisedEndpoint"; import { DesktopServerExposureModeSchema, type AdvertisedEndpoint, type AdvertisedEndpointProvider, type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; interface ResolvedDesktopServerExposure { readonly mode: DesktopServerExposureMode; readonly bindHost: string; readonly localHttpUrl: string; readonly localWsUrl: string; readonly endpointUrl: string | null; readonly advertisedHost: string | null; } interface DesktopAdvertisedEndpointInput { readonly port: number; readonly exposure: ResolvedDesktopServerExposure; readonly customHttpsEndpointUrls?: readonly string[]; } const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { id: "desktop-core", label: "Desktop", kind: "core", isAddon: false, }; const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { id: "manual", label: "Manual", kind: "manual", isAddon: false, }; const normalizeOptionalHost = (value: string | undefined): string | undefined => { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : undefined; }; const isUsableLanIpv4Address = (address: string): boolean => !address.startsWith("127.") && !address.startsWith("169.254.") && !isTailscaleIpv4Address(address); const isHttpsEndpointUrl = (value: string): boolean => { try { return new URL(value).protocol === "https:"; } catch { return false; } }; const resolveLanAdvertisedHost = ( networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, explicitHost: string | undefined, ): string | null => { const normalizedExplicitHost = normalizeOptionalHost(explicitHost); if (normalizedExplicitHost) { return normalizedExplicitHost; } for (const interfaceAddresses of Object.values(networkInterfaces)) { if (!interfaceAddresses) continue; for (const address of interfaceAddresses) { if (address.internal) continue; if (address.family !== "IPv4") continue; if (!isUsableLanIpv4Address(address.address)) continue; return address.address; } } return null; }; const resolveDesktopServerExposure = (input: { readonly mode: DesktopServerExposureMode; readonly port: number; readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; readonly advertisedHostOverride?: string; }): ResolvedDesktopServerExposure => { const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; if (input.mode === "local-only") { return { mode: input.mode, bindHost: DESKTOP_LOOPBACK_HOST, localHttpUrl, localWsUrl, endpointUrl: null, advertisedHost: null, }; } const advertisedHost = resolveLanAdvertisedHost( input.networkInterfaces, input.advertisedHostOverride, ); return { mode: input.mode, bindHost: DESKTOP_LAN_BIND_HOST, localHttpUrl, localWsUrl, endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, advertisedHost, }; }; const createDesktopEndpoint = ( input: Omit, ): AdvertisedEndpoint => createAdvertisedEndpoint({ ...input, provider: DESKTOP_CORE_ENDPOINT_PROVIDER, source: "desktop-core", }); const createManualEndpoint = ( input: Omit, ): AdvertisedEndpoint => createAdvertisedEndpoint({ ...input, provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, source: "user", }); const resolveDesktopCoreAdvertisedEndpoints = ( input: DesktopAdvertisedEndpointInput, ): readonly AdvertisedEndpoint[] => { const endpoints: AdvertisedEndpoint[] = [ createDesktopEndpoint({ id: `desktop-loopback:${input.port}`, label: "This machine", httpBaseUrl: input.exposure.localHttpUrl, reachability: "loopback", status: "available", description: "Loopback endpoint for this desktop app.", }), ]; if (input.exposure.endpointUrl) { endpoints.push( createDesktopEndpoint({ id: `desktop-lan:${input.exposure.endpointUrl}`, label: "Local network", httpBaseUrl: input.exposure.endpointUrl, reachability: "lan", status: "available", isDefault: true, description: "Reachable from devices on the same network.", }), ); } for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { try { const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); endpoints.push( createManualEndpoint({ id: `manual:${customEndpointUrl}`, label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", httpBaseUrl: customEndpointUrl, reachability: "public", ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), status: "unknown", description: isHttpsEndpoint ? "User-configured HTTPS endpoint for this desktop backend." : "User-configured endpoint for this desktop backend.", }), ); } catch { // Ignore malformed user-configured endpoints without dropping valid endpoints. } } return endpoints; }; export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedError()( "DesktopServerExposureNoNetworkAddressError", { port: Schema.Number, }, ) { override get message(): string { return `No reachable network address is available for desktop network access on port ${this.port}.`; } } export class DesktopServerExposureModePersistenceError extends Schema.TaggedError()( "DesktopServerExposureModePersistenceError", { mode: DesktopServerExposureModeSchema, cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), }, ) { override get message(): string { return `Failed to persist desktop server exposure mode ${this.mode}.`; } } export class DesktopTailscaleServePersistenceError extends Schema.TaggedError()( "DesktopTailscaleServePersistenceError", { enabled: Schema.Boolean, port: Schema.NullOr(Schema.Number), cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), }, ) { override get message(): string { return `Failed to persist desktop Tailscale Serve settings (enabled: ${this.enabled}, port: ${this.port ?? "unchanged"}).`; } } export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, DesktopServerExposureModePersistenceError, ]); export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, DesktopServerExposureModePersistenceError, DesktopTailscaleServePersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; export interface DesktopServerExposureBackendConfig { readonly port: number; readonly bindHost: string; readonly httpBaseUrl: URL; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; } export interface DesktopServerExposureChange { readonly state: DesktopServerExposureState; readonly requiresRelaunch: boolean; } export class DesktopServerExposure extends Context.Service< DesktopServerExposure, { readonly getState: Effect.Effect; readonly backendConfig: Effect.Effect; readonly configureFromSettings: (input: { readonly port: number; }) => Effect.Effect; readonly setMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; readonly setTailscaleServeEnabled: (input: { readonly enabled: boolean; readonly port?: number; }) => Effect.Effect; readonly getAdvertisedEndpoints: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopServerExposure") {} interface RuntimeState { readonly requestedMode: DesktopServerExposureMode; readonly mode: DesktopServerExposureMode; readonly port: number; readonly bindHost: string; readonly localHttpUrl: string; readonly localWsUrl: string; readonly httpBaseUrl: URL; readonly endpointUrl: Option.Option; readonly advertisedHost: Option.Option; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; } interface ResolvedRuntimeState { readonly state: RuntimeState; readonly unavailable: boolean; } const initialRuntimeState = (): RuntimeState => runtimeStateFromResolvedExposure({ requestedMode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, exposure: resolveDesktopServerExposure({ mode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, port: 0, networkInterfaces: {}, }), port: 0, }); const toContractState = (state: RuntimeState): DesktopServerExposureState => ({ mode: state.mode, endpointUrl: Option.getOrNull(state.endpointUrl), advertisedHost: Option.getOrNull(state.advertisedHost), tailscaleServeEnabled: state.tailscaleServeEnabled, tailscaleServePort: state.tailscaleServePort, }); const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({ port: state.port, bindHost: state.bindHost, httpBaseUrl: state.httpBaseUrl, tailscaleServeEnabled: state.tailscaleServeEnabled, tailscaleServePort: state.tailscaleServePort, }); const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({ mode: state.mode, bindHost: state.bindHost, localHttpUrl: state.localHttpUrl, localWsUrl: state.localWsUrl, endpointUrl: Option.getOrNull(state.endpointUrl), advertisedHost: Option.getOrNull(state.advertisedHost), }); function runtimeStateFromResolvedExposure(input: { readonly requestedMode: DesktopServerExposureMode; readonly settings: DesktopAppSettings.DesktopSettings; readonly exposure: ResolvedDesktopServerExposure; readonly port: number; }): RuntimeState { return { requestedMode: input.requestedMode, mode: input.exposure.mode, port: input.port, bindHost: input.exposure.bindHost, localHttpUrl: input.exposure.localHttpUrl, localWsUrl: input.exposure.localWsUrl, httpBaseUrl: new URL(input.exposure.localHttpUrl), endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl), advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), tailscaleServeEnabled: input.settings.tailscaleServeEnabled, tailscaleServePort: input.settings.tailscaleServePort, }; } function resolveRuntimeState(input: { readonly requestedMode: DesktopServerExposureMode; readonly settings: DesktopAppSettings.DesktopSettings; readonly port: number; readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; readonly advertisedHostOverride: Option.Option; }): ResolvedRuntimeState { const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); const requestedExposure = resolveDesktopServerExposure({ mode: input.requestedMode, port: input.port, networkInterfaces: input.networkInterfaces, ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); const unavailable = input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null && !Object.values(input.networkInterfaces).some((addresses) => addresses?.some( (address) => !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address), ), ); const exposure = unavailable ? resolveDesktopServerExposure({ mode: "local-only", port: input.port, networkInterfaces: input.networkInterfaces, ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }) : requestedExposure; return { state: runtimeStateFromResolvedExposure({ requestedMode: input.requestedMode, settings: input.settings, exposure, port: input.port, }), unavailable, }; } const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean => previous.port !== next.port || previous.bindHost !== next.bindHost || previous.localHttpUrl !== next.localHttpUrl; /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App // Store Tailscale CLI lives inside Tailscale's sandbox container, so each // spawn re-triggers the "Other apps" TCC prompt. const cachedReadMagicDnsName = yield* Effect.cachedWithTTL( readTailscaleStatus.pipe( Effect.map((status) => status.magicDnsName), Effect.orElseSucceed(() => null), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), ), TAILSCALE_STATUS_CACHE_TTL, ); const readNetworkInterfaces = networkInterfaces.read; const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( function* ({ port }: { readonly port: number }) { yield* Effect.annotateCurrentSpan({ port }); const settings = yield* desktopSettings.get; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ requestedMode: settings.serverExposureMode, settings, port, networkInterfaces: currentNetworkInterfaces, advertisedHostOverride: config.desktopLanHostOverride, }); yield* Ref.set(stateRef, resolved.state); return toContractState(resolved.state); }, ); const setMode = Effect.fn("desktop.serverExposure.setMode")(function* ( mode: DesktopServerExposureMode, ) { yield* Effect.annotateCurrentSpan({ mode }); const previous = yield* Ref.get(stateRef); const currentSettings = yield* desktopSettings.get; const nextSettings = { ...currentSettings, serverExposureMode: mode, }; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ requestedMode: mode, settings: nextSettings, port: previous.port, networkInterfaces: currentNetworkInterfaces, advertisedHostOverride: config.desktopLanHostOverride, }); if (resolved.unavailable) { return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port }); } const change = yield* desktopSettings.setServerExposureMode(mode).pipe( Effect.mapError( (cause) => new DesktopServerExposureModePersistenceError({ mode, cause, }), ), ); yield* Ref.set(stateRef, resolved.state); return { state: toContractState(resolved.state), requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state), }; }); const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")( function* (input: { readonly enabled: boolean; readonly port?: number }) { yield* Effect.annotateCurrentSpan({ enabled: input.enabled, ...(input.port === undefined ? {} : { port: input.port }), }); const result = yield* desktopSettings .setTailscaleServe({ enabled: input.enabled, port: Option.fromNullishOr(input.port), }) .pipe( Effect.mapError( (cause) => new DesktopTailscaleServePersistenceError({ enabled: input.enabled, port: input.port ?? null, cause, }), ), ); const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({ ...current, tailscaleServeEnabled: result.settings.tailscaleServeEnabled, tailscaleServePort: result.settings.tailscaleServePort, })); return { state: toContractState(nextState), requiresRelaunch: result.changed, }; }, ); const getAdvertisedEndpoints = Effect.gen(function* () { const state = yield* Ref.get(stateRef); const currentNetworkInterfaces = yield* readNetworkInterfaces; const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, exposure: toResolvedExposure(state), customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); // Don't spawn the Tailscale CLI when the user hasn't opted into any // network exposure. The spawn itself triggers a macOS "Other apps" // TCC prompt on Mac App Store Tailscale builds. if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { return coreEndpoints; } const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: state.port, serveEnabled: state.tailscaleServeEnabled, servePort: state.tailscaleServePort, networkInterfaces: currentNetworkInterfaces, readMagicDnsName: cachedReadMagicDnsName, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(HttpClient.HttpClient, httpClient), ); return [...coreEndpoints, ...tailscaleEndpoints]; }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints")); return DesktopServerExposure.of({ getState, backendConfig, configureFromSettings, setMode, setTailscaleServeEnabled, getAdvertisedEndpoints, }); }); export const layer = Layer.effect(DesktopServerExposure, make);