diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e13ae08d5ff103ce23622ec3a01dd8ac45cb3f77..b9817552969fd57f3e10fe8e6db21d580a09f3f4 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -16,7 +16,7 @@ import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; -import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index de336ffb89c1da999ef279dc5301696718b8bf27..96e56a87c9dacac03d3667c1d0a79acc1883e63e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -8,7 +8,7 @@ import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; const PersistedServerObservabilitySettingsDocument = Schema.Struct({ observability: Schema.Struct({ diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 45103bee92e148b0ac03bb4ed60287a7d8aea53e..42e4ada438b6a5128ed582fcb8fcf4e55b464aef 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -10,7 +10,7 @@ import * as Ref from "effect/Ref"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; -import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; export interface DesktopBackendConfigurationShape { readonly resolve: Effect.Effect; diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f3e9eaeb45a535a9e882c0674b4c8ca877d2ba1 --- /dev/null +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -0,0 +1,365 @@ +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + DesktopEnvironment, + layer as makeDesktopEnvironmentLayer, +} from "../app/DesktopEnvironment.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; + +const encoder = new TextEncoder(); + +const emptyNetworkInterfaces: DesktopNetworkInterfaces = {}; +const lanNetworkInterfaces: DesktopNetworkInterfaces = { + en0: [ + { + address: "192.168.1.20", + family: "IPv4", + internal: false, + }, + ], +}; + +const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { + tailscale0: [ + { + address: "100.90.1.2", + family: "IPv4", + internal: false, + }, + ], +}; + +function mockSpawnerLayer(statusJson = "{}") { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(statusJson)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ); +} + +function makeEnvironmentLayer(baseDir: string, env: Record = {}) { + return makeDesktopEnvironmentLayer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })), + ), + ); +} + +function makeLayer(input: { + readonly baseDir: string; + readonly networkInterfaces?: DesktopNetworkInterfaces; + readonly env?: Record; +}) { + const env = { T3CODE_HOME: input.baseDir, ...input.env }; + const environmentLayer = makeEnvironmentLayer(input.baseDir, env); + const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { + read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), + }); + + return DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(NodeFileSystem.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(mockSpawnerLayer()), + Layer.provideMerge(networkLayer), + Layer.provideMerge(DesktopConfig.layerTest(env)), + Layer.provideMerge(environmentLayer), + ); +} + +const withHarness = ( + networkInterfaces: DesktopNetworkInterfaces, + effect: Effect.Effect< + A, + E, + | R + | DesktopEnvironment + | FileSystem.FileSystem + | DesktopServerExposure.DesktopServerExposure + | DesktopAppSettings.DesktopAppSettings + >, + env: Record = {}, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-server-exposure-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env }))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopServerExposure", () => { + it.effect("falls back to local-only without losing the requested network preference", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "local-only"); + assert.equal(state.endpointUrl, null); + assert.equal((yield* settings.get).serverExposureMode, "network-accessible"); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "127.0.0.1"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + }), + ), + ); + + it.effect("returns a typed error when network access is explicitly unavailable", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); + assert.equal(error.port, 4173); + }), + ), + ); + + it.effect("persists network-accessible mode and updates backend binding state", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.requiresRelaunch, true); + assert.deepEqual(change.state, { + mode: "network-accessible", + endpointUrl: "http://192.168.1.20:4173", + advertisedHost: "192.168.1.20", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "0.0.0.0"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + + const persisted = yield* settings.get; + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const changed = yield* serverExposure.setTailscaleServeEnabled({ + enabled: true, + port: 8443, + }); + assert.equal(changed.requiresRelaunch, true); + assert.equal(changed.state.tailscaleServeEnabled, true); + assert.equal(changed.state.tailscaleServePort, 8443); + + const unchanged = yield* serverExposure.setTailscaleServeEnabled({ + enabled: true, + port: 8443, + }); + assert.equal(unchanged.requiresRelaunch, false); + + const persisted = yield* settings.get; + assert.equal(persisted.tailscaleServeEnabled, true); + assert.equal(persisted.tailscaleServePort, 8443); + }), + ), + ); + + it.effect("resolves advertised endpoints from the scoped runtime state", () => + withHarness( + { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"], + ); + }), + ), + ); + + it.effect("uses ConfigProvider desktop exposure overrides", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + const change = yield* serverExposure.setMode("network-accessible"); + + assert.equal(change.state.advertisedHost, "10.0.0.7"); + assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ); + }), + { + T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", + }, + ), + ); + + it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 3773 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual(endpoints, [ + { + id: "desktop-loopback:3773", + label: "This machine", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + reachability: "loopback", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + description: "Loopback endpoint for this desktop app.", + }, + { + id: "desktop-lan:http://192.168.1.20:3773", + label: "Local network", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://192.168.1.20:3773/", + wsBaseUrl: "ws://192.168.1.20:3773/", + reachability: "lan", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }, + { + id: "manual:https://desktop.example.ts.net", + label: "Custom HTTPS", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "https://desktop.example.ts.net/", + wsBaseUrl: "wss://desktop.example.ts.net/", + reachability: "public", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }, + { + id: "manual:http://desktop.example.test:3773", + label: "Custom endpoint", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "http://desktop.example.test:3773/", + wsBaseUrl: "ws://desktop.example.test:3773/", + reachability: "public", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured endpoint for this desktop backend.", + }, + ]); + }), + { + T3CODE_DESKTOP_HTTPS_ENDPOINTS: + "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", + }, + ), + ); +}); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts new file mode 100644 index 0000000000000000000000000000000000000000..b83355368a6ff671a63ca16ed6e3f12eb7754d73 --- /dev/null +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -0,0 +1,548 @@ +import * as NodeOS from "node:os"; + +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/client-runtime"; +import type { + AdvertisedEndpoint, + AdvertisedEndpointProvider, + DesktopServerExposureMode, + DesktopServerExposureState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; + +export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; + +export interface DesktopNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; + readonly netmask?: string; + readonly mac?: string; + readonly cidr?: string | null; + readonly scopeid?: number; +} + +export type DesktopNetworkInterfaces = Readonly< + Record +>; + +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."); + +const isHttpsEndpointUrl = (value: string): boolean => { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +}; + +const resolveLanAdvertisedHost = ( + networkInterfaces: DesktopNetworkInterfaces, + 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; + 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; +}; + +type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; + +export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( + "DesktopServerExposureNoNetworkAddressError", +)<{ + readonly port: number; +}> { + override get message() { + return `No reachable network address is available for desktop network access on port ${this.port}.`; + } +} + +export class DesktopServerExposurePersistenceError extends Data.TaggedError( + "DesktopServerExposurePersistenceError", +)<{ + readonly operation: DesktopServerExposurePersistenceOperation; + readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; +}> { + override get message() { + return `Failed to persist desktop ${this.operation} settings.`; + } +} + +export type DesktopServerExposureSetModeError = + | DesktopServerExposureNoNetworkAddressError + | DesktopServerExposurePersistenceError; + +export type DesktopServerExposureError = DesktopServerExposureSetModeError; + +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 interface DesktopServerExposureShape { + 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; +} + +export class DesktopServerExposure extends Context.Service< + DesktopServerExposure, + DesktopServerExposureShape +>()("t3/desktop/ServerExposure") {} + +export interface DesktopNetworkInterfacesServiceShape { + readonly read: Effect.Effect; +} + +export class DesktopNetworkInterfacesService extends Context.Service< + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesServiceShape +>()("t3/desktop/ServerExposure/NetworkInterfaces") {} + +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: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + settings: DEFAULT_DESKTOP_SETTINGS, + exposure: resolveDesktopServerExposure({ + mode: 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: 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: DesktopSettings; + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + 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; + 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; + +const make = Effect.gen(function* () { + const config = yield* DesktopConfig.DesktopConfig; + const networkInterfaces = yield* DesktopNetworkInterfacesService; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; + const stateRef = yield* Ref.make(initialRuntimeState()); + + 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 DesktopServerExposurePersistenceError({ + operation: "server-exposure-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 DesktopServerExposurePersistenceError({ + operation: "tailscale-serve", + 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, + }); + const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: state.port, + serveEnabled: state.tailscaleServeEnabled, + servePort: state.tailscaleServePort, + networkInterfaces: currentNetworkInterfaces, + }).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); + +export const networkInterfacesLayer = Layer.succeed( + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesService.of({ + read: Effect.sync(() => NodeOS.networkInterfaces()), + }), +); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..612ef3bd73fa3b05bb5a66a3f86cd05504e8d267 --- /dev/null +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -0,0 +1,142 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + resolveTailscaleAdvertisedEndpoints, +} from "./tailscaleEndpointProvider.ts"; + +const unusedTailscaleExternalServicesLayer = Layer.mergeAll( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")), + ), +); + +describe("tailscale endpoint provider", () => { + it("detects Tailnet IPv4 addresses", () => { + assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); + assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); + assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); + assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); + }); + + it.effect("parses MagicDNS names from tailscale status", () => + Effect.gen(function* () { + const dnsName = yield* parseTailscaleMagicDnsName( + `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + ); + assert.equal(dnsName, "desktop.tail.ts.net"); + assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); + const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); + assert.isTrue(malformed._tag === "Failure"); + }), + ); + + it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + tailscale0: [ + { + address: "100.100.100.100", + family: "IPv4", + internal: false, + netmask: "255.192.0.0", + cidr: "100.100.100.100/10", + mac: "00:00:00:00:00:00", + }, + ], + }, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + }); + assert.deepEqual(endpoints, [ + { + id: "tailscale-ip:http://100.100.100.100:3773", + label: "Tailscale IP", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "http://100.100.100.100:3773/", + wsBaseUrl: "ws://100.100.100.100:3773/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }, + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "requires-configuration", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "unavailable", + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }, + ]); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); + + it.effect( + "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", + () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: {}, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + serveEnabled: true, + probe: () => Effect.succeed(true), + }); + assert.deepEqual(endpoints, [ + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "HTTPS endpoint served by Tailscale Serve.", + }, + ]); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); +}); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..d066f18b9686e7de622e58dc430c23640a442037 --- /dev/null +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -0,0 +1,138 @@ +import { createAdvertisedEndpoint } from "@t3tools/client-runtime"; +import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import { + buildTailscaleHttpsBaseUrl, + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + probeTailscaleHttpsEndpoint, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; + +export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; + +const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, +}; + +function resolveTailscaleIpAdvertisedEndpoints(input: { + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; +}): readonly AdvertisedEndpoint[] { + const seen = new Set(); + const endpoints: AdvertisedEndpoint[] = []; + + for (const interfaceAddresses of Object.values(input.networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isTailscaleIpv4Address(address.address)) continue; + if (seen.has(address.address)) continue; + seen.add(address.address); + + endpoints.push( + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + id: `tailscale-ip:http://${address.address}:${input.port}`, + label: "Tailscale IP", + httpBaseUrl: `http://${address.address}:${input.port}`, + reachability: "private-network", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }), + ); + } + } + + return endpoints; +} + +const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( + "resolveTailscaleMagicDnsAdvertisedEndpoint", +)(function* (input: { + readonly dnsName: string | null; + readonly serveEnabled: boolean; + readonly servePort?: number; + readonly probe?: (baseUrl: string) => Effect.Effect; +}): Effect.fn.Return, never, HttpClient.HttpClient> { + if (!input.dnsName) { + return Option.none(); + } + + const httpBaseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName: input.dnsName, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + }); + const probe = + input.probe?.(httpBaseUrl) ?? + probeTailscaleHttpsEndpoint({ + baseUrl: httpBaseUrl, + }); + const isReachable = input.serveEnabled ? yield* probe : false; + + return Option.some( + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + id: `tailscale-magicdns:${httpBaseUrl}`, + label: "Tailscale HTTPS", + httpBaseUrl, + reachability: "private-network", + hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", + status: isReachable ? "available" : "unavailable", + description: isReachable + ? "HTTPS endpoint served by Tailscale Serve." + : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }), + ); +}); + +export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( + function* (input: { + readonly port: number; + readonly serveEnabled?: boolean; + readonly servePort?: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly statusJson?: string | null; + readonly probe?: (baseUrl: string) => Effect.Effect; + }): Effect.fn.Return< + readonly AdvertisedEndpoint[], + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient + > { + const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); + const dnsName = + input.statusJson === undefined + ? yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.catch(() => Effect.succeed(null)), + ) + : input.statusJson + ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( + Effect.catch(() => Effect.succeed(null)), + ) + : null; + const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ + dnsName, + serveEnabled: input.serveEnabled === true, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.probe === undefined ? {} : { probe: input.probe }), + }); + + return Option.match(magicDnsEndpoint, { + onNone: () => ipEndpoints, + onSome: (endpoint) => [...ipEndpoints, endpoint], + }); + }, +); diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 2bbb08c3f752892e73cdb00fa47bc2455733435b..cd0f215e1938853b6af695db7d1cb5d5cd26ba5f 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -7,7 +7,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; -import * as DesktopServerExposure from "../../serverExposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../../backend/DesktopServerExposure.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 576c40ddd884a4c46edb1fa2d1eb5f938e83d8a6..f71ff1fbe67871c878e5d43e9de9eed7b61e3fc4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -32,7 +32,7 @@ import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; -import * as DesktopServerExposure from "./serverExposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts deleted file mode 100644 index 0f3e9eaeb45a535a9e882c0674b4c8ca877d2ba1..0000000000000000000000000000000000000000 --- a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { - DesktopEnvironment, - layer as makeDesktopEnvironmentLayer, -} from "../app/DesktopEnvironment.ts"; -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopServerExposure from "./DesktopServerExposure.ts"; -import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; -import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; - -const encoder = new TextEncoder(); - -const emptyNetworkInterfaces: DesktopNetworkInterfaces = {}; -const lanNetworkInterfaces: DesktopNetworkInterfaces = { - en0: [ - { - address: "192.168.1.20", - family: "IPv4", - internal: false, - }, - ], -}; - -const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { - tailscale0: [ - { - address: "100.90.1.2", - family: "IPv4", - internal: false, - }, - ], -}; - -function mockSpawnerLayer(statusJson = "{}") { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(statusJson)), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ), - ); -} - -function makeEnvironmentLayer(baseDir: string, env: Record = {}) { - return makeDesktopEnvironmentLayer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: baseDir, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })), - ), - ); -} - -function makeLayer(input: { - readonly baseDir: string; - readonly networkInterfaces?: DesktopNetworkInterfaces; - readonly env?: Record; -}) { - const env = { T3CODE_HOME: input.baseDir, ...input.env }; - const environmentLayer = makeEnvironmentLayer(input.baseDir, env); - const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { - read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), - }); - - return DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopAppSettings.layer), - Layer.provideMerge(NodeFileSystem.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(mockSpawnerLayer()), - Layer.provideMerge(networkLayer), - Layer.provideMerge(DesktopConfig.layerTest(env)), - Layer.provideMerge(environmentLayer), - ); -} - -const withHarness = ( - networkInterfaces: DesktopNetworkInterfaces, - effect: Effect.Effect< - A, - E, - | R - | DesktopEnvironment - | FileSystem.FileSystem - | DesktopServerExposure.DesktopServerExposure - | DesktopAppSettings.DesktopAppSettings - >, - env: Record = {}, -) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-server-exposure-test-", - }); - return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env }))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); - -describe("DesktopServerExposure", () => { - it.effect("falls back to local-only without losing the requested network preference", () => - withHarness( - emptyNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - - yield* settings.setServerExposureMode("network-accessible"); - - const state = yield* serverExposure.configureFromSettings({ port: 4173 }); - assert.equal(state.mode, "local-only"); - assert.equal(state.endpointUrl, null); - assert.equal((yield* settings.get).serverExposureMode, "network-accessible"); - - const backendConfig = yield* serverExposure.backendConfig; - assert.equal(backendConfig.bindHost, "127.0.0.1"); - assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); - }), - ), - ); - - it.effect("returns a typed error when network access is explicitly unavailable", () => - withHarness( - emptyNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); - assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); - assert.equal(error.port, 4173); - }), - ), - ); - - it.effect("persists network-accessible mode and updates backend binding state", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - - yield* settings.load; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.requiresRelaunch, true); - assert.deepEqual(change.state, { - mode: "network-accessible", - endpointUrl: "http://192.168.1.20:4173", - advertisedHost: "192.168.1.20", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); - - const backendConfig = yield* serverExposure.backendConfig; - assert.equal(backendConfig.bindHost, "0.0.0.0"); - assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); - - const persisted = yield* settings.get; - assert.equal(persisted.serverExposureMode, "network-accessible"); - }), - ), - ); - - it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => - withHarness( - emptyNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - - yield* settings.load; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const changed = yield* serverExposure.setTailscaleServeEnabled({ - enabled: true, - port: 8443, - }); - assert.equal(changed.requiresRelaunch, true); - assert.equal(changed.state.tailscaleServeEnabled, true); - assert.equal(changed.state.tailscaleServePort, 8443); - - const unchanged = yield* serverExposure.setTailscaleServeEnabled({ - enabled: true, - port: 8443, - }); - assert.equal(unchanged.requiresRelaunch, false); - - const persisted = yield* settings.get; - assert.equal(persisted.tailscaleServeEnabled, true); - assert.equal(persisted.tailscaleServePort, 8443); - }), - ), - ); - - it.effect("resolves advertised endpoints from the scoped runtime state", () => - withHarness( - { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - yield* serverExposure.setMode("network-accessible"); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"], - ); - }), - ), - ); - - it.effect("uses ConfigProvider desktop exposure overrides", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - const change = yield* serverExposure.setMode("network-accessible"); - - assert.equal(change.state.advertisedHost, "10.0.0.7"); - assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], - ); - }), - { - T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", - T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", - }, - ), - ); - - it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 3773 }); - yield* serverExposure.setMode("network-accessible"); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual(endpoints, [ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - description: "Loopback endpoint for this desktop app.", - }, - { - id: "desktop-lan:http://192.168.1.20:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://192.168.1.20:3773/", - wsBaseUrl: "ws://192.168.1.20:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }, - { - id: "manual:https://desktop.example.ts.net", - label: "Custom HTTPS", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "https://desktop.example.ts.net/", - wsBaseUrl: "wss://desktop.example.ts.net/", - reachability: "public", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured HTTPS endpoint for this desktop backend.", - }, - { - id: "manual:http://desktop.example.test:3773", - label: "Custom endpoint", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://desktop.example.test:3773/", - wsBaseUrl: "ws://desktop.example.test:3773/", - reachability: "public", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured endpoint for this desktop backend.", - }, - ]); - }), - { - T3CODE_DESKTOP_HTTPS_ENDPOINTS: - "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", - }, - ), - ); -}); diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.ts deleted file mode 100644 index b83355368a6ff671a63ca16ed6e3f12eb7754d73..0000000000000000000000000000000000000000 --- a/apps/desktop/src/serverExposure/DesktopServerExposure.ts +++ /dev/null @@ -1,548 +0,0 @@ -import * as NodeOS from "node:os"; - -import { - createAdvertisedEndpoint, - type CreateAdvertisedEndpointInput, -} from "@t3tools/client-runtime"; -import type { - AdvertisedEndpoint, - AdvertisedEndpointProvider, - DesktopServerExposureMode, - DesktopServerExposureState, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -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 { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; - -export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; - -export interface DesktopNetworkInterfaceInfo { - readonly address: string; - readonly family: string | number; - readonly internal: boolean; - readonly netmask?: string; - readonly mac?: string; - readonly cidr?: string | null; - readonly scopeid?: number; -} - -export type DesktopNetworkInterfaces = Readonly< - Record ->; - -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."); - -const isHttpsEndpointUrl = (value: string): boolean => { - try { - return new URL(value).protocol === "https:"; - } catch { - return false; - } -}; - -const resolveLanAdvertisedHost = ( - networkInterfaces: DesktopNetworkInterfaces, - 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; - 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; -}; - -type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; - -export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( - "DesktopServerExposureNoNetworkAddressError", -)<{ - readonly port: number; -}> { - override get message() { - return `No reachable network address is available for desktop network access on port ${this.port}.`; - } -} - -export class DesktopServerExposurePersistenceError extends Data.TaggedError( - "DesktopServerExposurePersistenceError", -)<{ - readonly operation: DesktopServerExposurePersistenceOperation; - readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; -}> { - override get message() { - return `Failed to persist desktop ${this.operation} settings.`; - } -} - -export type DesktopServerExposureSetModeError = - | DesktopServerExposureNoNetworkAddressError - | DesktopServerExposurePersistenceError; - -export type DesktopServerExposureError = DesktopServerExposureSetModeError; - -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 interface DesktopServerExposureShape { - 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; -} - -export class DesktopServerExposure extends Context.Service< - DesktopServerExposure, - DesktopServerExposureShape ->()("t3/desktop/ServerExposure") {} - -export interface DesktopNetworkInterfacesServiceShape { - readonly read: Effect.Effect; -} - -export class DesktopNetworkInterfacesService extends Context.Service< - DesktopNetworkInterfacesService, - DesktopNetworkInterfacesServiceShape ->()("t3/desktop/ServerExposure/NetworkInterfaces") {} - -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: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, - settings: DEFAULT_DESKTOP_SETTINGS, - exposure: resolveDesktopServerExposure({ - mode: 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: 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: DesktopSettings; - readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; - 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; - 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; - -const make = Effect.gen(function* () { - const config = yield* DesktopConfig.DesktopConfig; - const networkInterfaces = yield* DesktopNetworkInterfacesService; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const httpClient = yield* HttpClient.HttpClient; - const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; - const stateRef = yield* Ref.make(initialRuntimeState()); - - 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 DesktopServerExposurePersistenceError({ - operation: "server-exposure-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 DesktopServerExposurePersistenceError({ - operation: "tailscale-serve", - 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, - }); - const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: state.port, - serveEnabled: state.tailscaleServeEnabled, - servePort: state.tailscaleServePort, - networkInterfaces: currentNetworkInterfaces, - }).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); - -export const networkInterfacesLayer = Layer.succeed( - DesktopNetworkInterfacesService, - DesktopNetworkInterfacesService.of({ - read: Effect.sync(() => NodeOS.networkInterfaces()), - }), -); diff --git a/apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts b/apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts deleted file mode 100644 index 612ef3bd73fa3b05bb5a66a3f86cd05504e8d267..0000000000000000000000000000000000000000 --- a/apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { - isTailscaleIpv4Address, - parseTailscaleMagicDnsName, - resolveTailscaleAdvertisedEndpoints, -} from "./tailscaleEndpointProvider.ts"; - -const unusedTailscaleExternalServicesLayer = Layer.mergeAll( - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")), - ), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")), - ), -); - -describe("tailscale endpoint provider", () => { - it("detects Tailnet IPv4 addresses", () => { - assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); - assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); - assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); - assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); - }); - - it.effect("parses MagicDNS names from tailscale status", () => - Effect.gen(function* () { - const dnsName = yield* parseTailscaleMagicDnsName( - `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, - ); - assert.equal(dnsName, "desktop.tail.ts.net"); - assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); - const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); - assert.isTrue(malformed._tag === "Failure"); - }), - ); - - it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { - tailscale0: [ - { - address: "100.100.100.100", - family: "IPv4", - internal: false, - netmask: "255.192.0.0", - cidr: "100.100.100.100/10", - mac: "00:00:00:00:00:00", - }, - ], - }, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, - }); - assert.deepEqual(endpoints, [ - { - id: "tailscale-ip:http://100.100.100.100:3773", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.100.100.100:3773/", - wsBaseUrl: "ws://100.100.100.100:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "Reachable from devices on the same Tailnet.", - }, - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "requires-configuration", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "unavailable", - description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }, - ]); - }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), - ); - - it.effect( - "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", - () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: {}, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, - serveEnabled: true, - probe: () => Effect.succeed(true), - }); - assert.deepEqual(endpoints, [ - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "HTTPS endpoint served by Tailscale Serve.", - }, - ]); - }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), - ); -}); diff --git a/apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts b/apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts deleted file mode 100644 index d066f18b9686e7de622e58dc430c23640a442037..0000000000000000000000000000000000000000 --- a/apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createAdvertisedEndpoint } from "@t3tools/client-runtime"; -import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; -import { - buildTailscaleHttpsBaseUrl, - isTailscaleIpv4Address, - parseTailscaleMagicDnsName, - probeTailscaleHttpsEndpoint, - readTailscaleStatus, -} from "@t3tools/tailscale"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; - -export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; - -const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, -}; - -function resolveTailscaleIpAdvertisedEndpoints(input: { - readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; -}): readonly AdvertisedEndpoint[] { - const seen = new Set(); - const endpoints: AdvertisedEndpoint[] = []; - - for (const interfaceAddresses of Object.values(input.networkInterfaces)) { - if (!interfaceAddresses) continue; - - for (const address of interfaceAddresses) { - if (address.internal) continue; - if (address.family !== "IPv4") continue; - if (!isTailscaleIpv4Address(address.address)) continue; - if (seen.has(address.address)) continue; - seen.add(address.address); - - endpoints.push( - createAdvertisedEndpoint({ - provider: TAILSCALE_ENDPOINT_PROVIDER, - source: "desktop-addon", - id: `tailscale-ip:http://${address.address}:${input.port}`, - label: "Tailscale IP", - httpBaseUrl: `http://${address.address}:${input.port}`, - reachability: "private-network", - status: "available", - description: "Reachable from devices on the same Tailnet.", - }), - ); - } - } - - return endpoints; -} - -const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( - "resolveTailscaleMagicDnsAdvertisedEndpoint", -)(function* (input: { - readonly dnsName: string | null; - readonly serveEnabled: boolean; - readonly servePort?: number; - readonly probe?: (baseUrl: string) => Effect.Effect; -}): Effect.fn.Return, never, HttpClient.HttpClient> { - if (!input.dnsName) { - return Option.none(); - } - - const httpBaseUrl = buildTailscaleHttpsBaseUrl({ - magicDnsName: input.dnsName, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - }); - const probe = - input.probe?.(httpBaseUrl) ?? - probeTailscaleHttpsEndpoint({ - baseUrl: httpBaseUrl, - }); - const isReachable = input.serveEnabled ? yield* probe : false; - - return Option.some( - createAdvertisedEndpoint({ - provider: TAILSCALE_ENDPOINT_PROVIDER, - source: "desktop-addon", - id: `tailscale-magicdns:${httpBaseUrl}`, - label: "Tailscale HTTPS", - httpBaseUrl, - reachability: "private-network", - hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", - status: isReachable ? "available" : "unavailable", - description: isReachable - ? "HTTPS endpoint served by Tailscale Serve." - : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }), - ); -}); - -export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( - function* (input: { - readonly port: number; - readonly serveEnabled?: boolean; - readonly servePort?: number; - readonly networkInterfaces: DesktopNetworkInterfaces; - readonly statusJson?: string | null; - readonly probe?: (baseUrl: string) => Effect.Effect; - }): Effect.fn.Return< - readonly AdvertisedEndpoint[], - never, - ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient - > { - const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); - const dnsName = - input.statusJson === undefined - ? yield* readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.catch(() => Effect.succeed(null)), - ) - : input.statusJson - ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( - Effect.catch(() => Effect.succeed(null)), - ) - : null; - const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ - dnsName, - serveEnabled: input.serveEnabled === true, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - ...(input.probe === undefined ? {} : { probe: input.probe }), - }); - - return Option.match(magicDnsEndpoint, { - onNone: () => ipEndpoints, - onSome: (endpoint) => [...ipEndpoints, endpoint], - }); - }, -); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index e53a02f539489822e79177cf3153f1007eb21fa9..fbcc60934aa91147b912b29ab63aa165b4cec961 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -16,7 +16,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; const environmentInput = { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 2cf69ae8b7c48efbb4a51a1a5dff345b5092912d..8ebd1041c6bc027ee64e33dda66044f247f498bf 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -16,7 +16,7 @@ import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux