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