| 1 | import * as NodeOS from "node:os"; |
| 2 | |
| 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 4 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; |
| 5 | import * as Clock from "effect/Clock"; |
| 6 | import * as Context from "effect/Context"; |
| 7 | import * as Crypto from "effect/Crypto"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Hex from "effect/encoding/Hex"; |
| 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as Option from "effect/Option"; |
| 13 | import * as PlatformError from "effect/PlatformError"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 16 | |
| 17 | import serverPackageJson from "../../../server/package.json" with { type: "json" }; |
| 18 | |
| 19 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 20 | import * as DesktopCliShim from "../app/DesktopCliShim.ts"; |
| 21 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 22 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 23 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 24 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; |
| 25 | import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; |
| 26 | |
| 27 | export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedError<DesktopBackendObservabilitySettingsReadError>()( |
| 28 | "DesktopBackendObservabilitySettingsReadError", |
| 29 | { |
| 30 | settingsPath: Schema.String, |
| 31 | cause: Schema.Defect(), |
| 32 | }, |
| 33 | ) { |
| 34 | override get message(): string { |
| 35 | return `Failed to read persisted backend observability settings at ${this.settingsPath}.`; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | export class DesktopBackendConfiguration extends Context.Service< |
| 40 | DesktopBackendConfiguration, |
| 41 | { |
| 42 | // Build the Windows-native primary backend's start config. Reads the |
| 43 | // primary's port/host/exposure from DesktopServerExposure. Can fail |
| 44 | // with PlatformError because bootstrap token generation now uses |
| 45 | // crypto.randomBytes under the hood (post Effect 4 migration). |
| 46 | readonly resolvePrimary: Effect.Effect< |
| 47 | DesktopBackendManager.DesktopBackendStartConfig, |
| 48 | PlatformError.PlatformError |
| 49 | >; |
| 50 | // Build a WSL backend start config for the given distro on the given |
| 51 | // port. The WSL backend is always loopback-only (the primary owns LAN |
| 52 | // exposure when the user opts in), so this takes the port directly and |
| 53 | // hardcodes 127.0.0.1. Distro=null means "WSL default distro" and is |
| 54 | // forwarded to wsl.exe with no -d flag. |
| 55 | readonly resolveWsl: (input: { |
| 56 | readonly port: number; |
| 57 | readonly distro: string | null; |
| 58 | }) => Effect.Effect< |
| 59 | DesktopBackendManager.DesktopBackendStartConfig, |
| 60 | PlatformError.PlatformError |
| 61 | >; |
| 62 | // The renderer-facing label for the primary instance, derived from the |
| 63 | // same decision resolvePrimary makes (including the WSL-availability |
| 64 | // fall-back to Windows), so the env switcher can't show "WSL" for a |
| 65 | // backend that actually resolved to Windows. |
| 66 | readonly resolvePrimaryLabel: Effect.Effect<string>; |
| 67 | // The bootstrap token the renderer should present right now. It rotates |
| 68 | // every window (derived from the secret every backend was launched with), |
| 69 | // so the renderer never holds one long-lived admin credential. |
| 70 | readonly currentBootstrapToken: Effect.Effect<string, PlatformError.PlatformError>; |
| 71 | } |
| 72 | >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} |
| 73 | |
| 74 | interface BackendObservabilitySettings { |
| 75 | readonly otlpTracesUrl: Option.Option<string>; |
| 76 | readonly otlpMetricsUrl: Option.Option<string>; |
| 77 | readonly otlpLogsUrl: Option.Option<string>; |
| 78 | } |
| 79 | |
| 80 | const emptyBackendObservabilitySettings: BackendObservabilitySettings = { |
| 81 | otlpTracesUrl: Option.none(), |
| 82 | otlpMetricsUrl: Option.none(), |
| 83 | otlpLogsUrl: Option.none(), |
| 84 | }; |
| 85 | |
| 86 | const DESKTOP_BACKEND_ENV_NAMES = [ |
| 87 | "T3CODE_PORT", |
| 88 | "T3CODE_MODE", |
| 89 | "T3CODE_NO_BROWSER", |
| 90 | "T3CODE_HOST", |
| 91 | "T3CODE_DESKTOP_WS_URL", |
| 92 | "T3CODE_DESKTOP_LAN_ACCESS", |
| 93 | "T3CODE_DESKTOP_LAN_HOST", |
| 94 | "T3CODE_DESKTOP_HTTPS_ENDPOINTS", |
| 95 | "T3CODE_TAILSCALE_SERVE", |
| 96 | "T3CODE_TAILSCALE_SERVE_PORT", |
| 97 | ] as const; |
| 98 | |
| 99 | // Env vars that the WSL backend needs but Windows process.env won't forward |
| 100 | // across the wsl.exe boundary without WSLENV. The dev-server URL travels as |
| 101 | // the `--dev-url` CLI flag instead. |
| 102 | const WSL_FORWARDED_ENV_NAMES = [ |
| 103 | "OPENAI_API_KEY", |
| 104 | "ANTHROPIC_API_KEY", |
| 105 | // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. |
| 106 | "T3CODE_OTEL_SDK_DISABLED", |
| 107 | "OTEL_SDK_DISABLED", |
| 108 | "T3CODE_OTLP_HEADERS", |
| 109 | "T3CODE_OTLP_PROTOCOL", |
| 110 | // Forwarded without a WSLENV flag, so the values arrive untranslated. The |
| 111 | // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs |
| 112 | // travel as variables to keep winning inside the distro as they do on Windows. |
| 113 | "T3CODE_OTLP_TRACES_URL", |
| 114 | "T3CODE_OTLP_METRICS_URL", |
| 115 | "T3CODE_OTLP_LOGS_URL", |
| 116 | "OTEL_EXPORTER_OTLP_ENDPOINT", |
| 117 | "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", |
| 118 | "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", |
| 119 | "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", |
| 120 | "OTEL_EXPORTER_OTLP_HEADERS", |
| 121 | "OTEL_EXPORTER_OTLP_TRACES_HEADERS", |
| 122 | "OTEL_EXPORTER_OTLP_METRICS_HEADERS", |
| 123 | "OTEL_EXPORTER_OTLP_LOGS_HEADERS", |
| 124 | "OTEL_EXPORTER_OTLP_PROTOCOL", |
| 125 | "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", |
| 126 | "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", |
| 127 | "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", |
| 128 | "OTEL_TRACES_EXPORTER", |
| 129 | "OTEL_METRICS_EXPORTER", |
| 130 | "OTEL_LOGS_EXPORTER", |
| 131 | ] as const; |
| 132 | |
| 133 | const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
| 134 | |
| 135 | const nodeBinDirOf = (nodePath: string): string => { |
| 136 | const lastSlash = nodePath.lastIndexOf("/"); |
| 137 | return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin"; |
| 138 | }; |
| 139 | |
| 140 | const backendChildEnvPatch = (): Record<string, string | undefined> => |
| 141 | Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); |
| 142 | |
| 143 | const getWslEnvEntryName = (entry: string): string => { |
| 144 | const slashIndex = entry.indexOf("/"); |
| 145 | return slashIndex === -1 ? entry : entry.slice(0, slashIndex); |
| 146 | }; |
| 147 | |
| 148 | const mergeWslEnv = ( |
| 149 | existingWslEnv: string | undefined, |
| 150 | forwardedEnvNames: ReadonlyArray<string>, |
| 151 | ): string | undefined => { |
| 152 | const existing = existingWslEnv?.trim() ?? ""; |
| 153 | |
| 154 | // Names already declared, so we don't forward a duplicate. We parse the |
| 155 | // existing value only for this membership test — the string itself is |
| 156 | // preserved verbatim below rather than re-serialized. |
| 157 | const seenNames = new Set( |
| 158 | existing |
| 159 | .split(":") |
| 160 | .map((entry) => getWslEnvEntryName(entry.trim())) |
| 161 | .filter((name) => name.length > 0), |
| 162 | ); |
| 163 | |
| 164 | const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); |
| 165 | |
| 166 | // Preserve the user's WSLENV exactly as Windows handed it to us — empty |
| 167 | // "::" segments and duplicate entries are harmless no-ops to WSL and not |
| 168 | // ours to normalize — and only append the secrets we need to forward |
| 169 | // across the wsl.exe boundary. |
| 170 | const parts = [existing, ...additions].filter((part) => part.length > 0); |
| 171 | return parts.length > 0 ? parts.join(":") : undefined; |
| 172 | }; |
| 173 | |
| 174 | const logBackendObservabilitySettingsReadFailure = ( |
| 175 | settingsPath: string, |
| 176 | cause: PlatformError.PlatformError, |
| 177 | ) => { |
| 178 | const error = new DesktopBackendObservabilitySettingsReadError({ settingsPath, cause }); |
| 179 | return Effect.logWarning(error).pipe( |
| 180 | Effect.annotateLogs({ |
| 181 | component: "desktop-backend-configuration", |
| 182 | error, |
| 183 | }), |
| 184 | ); |
| 185 | }; |
| 186 | |
| 187 | function resourceMonitorBinaryName(platform: NodeJS.Platform): string { |
| 188 | return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; |
| 189 | } |
| 190 | |
| 191 | const resolveResourceMonitorPath = Effect.fn( |
| 192 | "desktop.backendConfiguration.resolveResourceMonitorPath", |
| 193 | )(function* () { |
| 194 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 195 | const fileSystem = yield* FileSystem.FileSystem; |
| 196 | const binaryName = resourceMonitorBinaryName(environment.platform); |
| 197 | const candidates = environment.isDevelopment |
| 198 | ? [ |
| 199 | environment.path.join( |
| 200 | environment.rootDir, |
| 201 | "native/resource-monitor/target/release", |
| 202 | binaryName, |
| 203 | ), |
| 204 | environment.path.join( |
| 205 | environment.rootDir, |
| 206 | "native/resource-monitor/target/debug", |
| 207 | binaryName, |
| 208 | ), |
| 209 | ] |
| 210 | : environment.isPackaged |
| 211 | ? [environment.path.join(environment.resourcesPath, "resource-monitor", binaryName)] |
| 212 | : environment.resolveResourcePathCandidates( |
| 213 | environment.path.join("resource-monitor", binaryName), |
| 214 | ); |
| 215 | |
| 216 | for (const candidate of candidates) { |
| 217 | if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { |
| 218 | return Option.some(candidate); |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | return Option.none<string>(); |
| 223 | }); |
| 224 | |
| 225 | const readPersistedBackendObservabilitySettings = Effect.gen(function* () { |
| 226 | const fileSystem = yield* FileSystem.FileSystem; |
| 227 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 228 | const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe( |
| 229 | Effect.asSome, |
| 230 | Effect.catchTags({ |
| 231 | PlatformError: (cause) => |
| 232 | cause.reason._tag === "NotFound" |
| 233 | ? Effect.succeedNone |
| 234 | : logBackendObservabilitySettingsReadFailure(environment.serverSettingsPath, cause).pipe( |
| 235 | Effect.as(Option.none()), |
| 236 | ), |
| 237 | }), |
| 238 | ); |
| 239 | if (Option.isNone(raw)) { |
| 240 | return emptyBackendObservabilitySettings; |
| 241 | } |
| 242 | |
| 243 | const parsed = parsePersistedServerObservabilitySettings(raw.value); |
| 244 | return { |
| 245 | otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), |
| 246 | otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), |
| 247 | otlpLogsUrl: Option.fromNullishOr(parsed.otlpLogsUrl), |
| 248 | }; |
| 249 | }); |
| 250 | |
| 251 | // The bootstrap carries the OTLP endpoints to every backend, including a WSL |
| 252 | // child that lacks the variables. The T3 URLs also travel as variables in |
| 253 | // WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats |
| 254 | // the persisted settings file, matching the precedence resolveServerConfig and |
| 255 | // DesktopObservability apply. |
| 256 | const readBackendObservabilitySettings = Effect.gen(function* () { |
| 257 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 258 | const persisted = yield* readPersistedBackendObservabilitySettings; |
| 259 | return { |
| 260 | otlpTracesUrl: Option.orElse(environment.otlpTracesUrl, () => persisted.otlpTracesUrl), |
| 261 | otlpMetricsUrl: Option.orElse(environment.otlpMetricsUrl, () => persisted.otlpMetricsUrl), |
| 262 | otlpLogsUrl: Option.orElse(environment.otlpLogsUrl, () => persisted.otlpLogsUrl), |
| 263 | } satisfies BackendObservabilitySettings; |
| 264 | }); |
| 265 | |
| 266 | interface SharedBootstrapInput { |
| 267 | readonly bootstrapToken: string; |
| 268 | readonly bootstrapSecret: string; |
| 269 | readonly observabilitySettings: BackendObservabilitySettings; |
| 270 | } |
| 271 | |
| 272 | // What the launch runs inside the distro. The staged runtime is the release's |
| 273 | // self-contained `t3` executable (Node inside); the mounted server tree is a |
| 274 | // script that needs the distro's own Node. |
| 275 | type WslPreflightRuntime = |
| 276 | | { |
| 277 | readonly kind: "executable"; |
| 278 | readonly entryPath: string; |
| 279 | } |
| 280 | | { |
| 281 | readonly kind: "node-script"; |
| 282 | // Absolute path to the node binary the preflight validated after the |
| 283 | // shared remote resolver repaired PATH. The launch must use this exact |
| 284 | // path so it doesn't fall through to a different/old node than the one |
| 285 | // node-pty was probed with. |
| 286 | readonly nodePath: string; |
| 287 | readonly linuxEntryPath: string; |
| 288 | }; |
| 289 | |
| 290 | interface WslPreflightSuccess { |
| 291 | readonly _tag: "Ready"; |
| 292 | readonly runningDistro: string; |
| 293 | readonly windowsEntryPath: string; |
| 294 | readonly runtime: WslPreflightRuntime; |
| 295 | // PATH captured from the user's login shell. The launch forwards this value |
| 296 | // directly without a shell so the server can spawn provider CLIs by name. |
| 297 | readonly resolvedPath: string; |
| 298 | // Identifies the distro-local runtime cache selected from the packaged archive. |
| 299 | readonly runtimeId?: string; |
| 300 | } |
| 301 | |
| 302 | interface WslPreflightFailure { |
| 303 | readonly _tag: "Failed"; |
| 304 | readonly reason: string; |
| 305 | // Fatal: the WSL distro is misconfigured (no node, wrong version, missing |
| 306 | // build tools) and retrying won't help — surface it and (wsl-only) fall back |
| 307 | // to Windows. Non-fatal: transient (WSL not ready yet, wslpath while it |
| 308 | // boots), with a bounded window for self-healing before fallback. |
| 309 | readonly fatal: boolean; |
| 310 | readonly retryLimit?: number; |
| 311 | } |
| 312 | |
| 313 | const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12; |
| 314 | const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; |
| 315 | const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; |
| 316 | const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; |
| 317 | |
| 318 | const parseWslRuntimeArchiveHash = (value: string): string | null => { |
| 319 | const trimmed = value.trim(); |
| 320 | return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; |
| 321 | }; |
| 322 | |
| 323 | type FailedNodePtyResult = Extract< |
| 324 | DesktopWslEnvironment.EnsureWslNodePtyResult, |
| 325 | { readonly ok: false } |
| 326 | >; |
| 327 | |
| 328 | const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { |
| 329 | readonly distro: string | null; |
| 330 | readonly runtimeArchive: DesktopWslEnvironment.WslRuntimeArchive | null; |
| 331 | readonly allowBuild: boolean; |
| 332 | }): Effect.fn.Return< |
| 333 | WslPreflightSuccess | WslPreflightFailure, |
| 334 | never, |
| 335 | | DesktopEnvironment.DesktopEnvironment |
| 336 | | DesktopWslEnvironment.DesktopWslEnvironment |
| 337 | | DesktopWslServerTree.DesktopWslServerTree |
| 338 | | FileSystem.FileSystem |
| 339 | > { |
| 340 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 341 | const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment; |
| 342 | const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; |
| 343 | const fileSystem = yield* FileSystem.FileSystem; |
| 344 | |
| 345 | const wslAvailable = yield* wslEnv.isAvailable; |
| 346 | if (!wslAvailable) { |
| 347 | return { |
| 348 | _tag: "Failed", |
| 349 | reason: "WSL is not available on this system", |
| 350 | fatal: false, |
| 351 | } as const; |
| 352 | } |
| 353 | |
| 354 | const distroProbe = yield* wslEnv.probeDistros.pipe( |
| 355 | Effect.map((distros) => ({ _tag: "Success", distros }) as const), |
| 356 | Effect.catch((error) => Effect.succeed({ _tag: "Failure", error } as const)), |
| 357 | ); |
| 358 | if (distroProbe._tag === "Failure") { |
| 359 | return { |
| 360 | _tag: "Failed", |
| 361 | reason: `Unable to list WSL distributions: ${distroProbe.error.message}`, |
| 362 | fatal: false, |
| 363 | } as const; |
| 364 | } |
| 365 | |
| 366 | const installedDistros = distroProbe.distros; |
| 367 | const runningDistro = input.distro |
| 368 | ? (installedDistros.find( |
| 369 | (installed) => installed.name.toLowerCase() === input.distro?.toLowerCase(), |
| 370 | )?.name ?? null) |
| 371 | : (installedDistros.find((installed) => installed.isDefault)?.name ?? null); |
| 372 | if (runningDistro === null) { |
| 373 | return { |
| 374 | _tag: "Failed", |
| 375 | reason: input.distro |
| 376 | ? `WSL distro is not installed: ${input.distro}` |
| 377 | : installedDistros.length === 0 |
| 378 | ? "WSL has no installed distributions" |
| 379 | : "WSL has no default distribution", |
| 380 | fatal: true, |
| 381 | } as const; |
| 382 | } |
| 383 | |
| 384 | const nodePtyOptions = { |
| 385 | allowBuild: input.allowBuild, |
| 386 | nodeEngineRange: serverPackageJson.engines.node, |
| 387 | }; |
| 388 | const failedNodePty = (result: FailedNodePtyResult) => |
| 389 | ({ |
| 390 | _tag: "Failed", |
| 391 | reason: `WSL node-pty unavailable: ${result.reason}`, |
| 392 | fatal: result.fatal, |
| 393 | ...(result.retryLimit === undefined ? {} : { retryLimit: result.retryLimit }), |
| 394 | }) as const; |
| 395 | |
| 396 | // The mounted server tree is the fallback runtime: the Windows-side copy the |
| 397 | // distro reads over /mnt. Slower to launch from, but always installed. |
| 398 | const resolveMountedAppRoot = Effect.gen(function* () { |
| 399 | const serverTree = yield* wslServerTree.ensure; |
| 400 | if (!serverTree.ok) { |
| 401 | return { ok: false, reason: serverTree.reason, fatal: serverTree.fatal } as const; |
| 402 | } |
| 403 | const windowsEntryPath = environment.path.join(serverTree.root, "apps/server/dist/bin.mjs"); |
| 404 | const entryExists = yield* fileSystem |
| 405 | .exists(windowsEntryPath) |
| 406 | .pipe(Effect.orElseSucceed(() => false)); |
| 407 | if (!entryExists) { |
| 408 | return { |
| 409 | ok: false, |
| 410 | reason: `missing server entry at ${windowsEntryPath}`, |
| 411 | fatal: true, |
| 412 | } as const; |
| 413 | } |
| 414 | const mountedAppRoot = yield* wslEnv.windowsToWslPath(runningDistro, serverTree.root); |
| 415 | return Option.isNone(mountedAppRoot) |
| 416 | ? ({ |
| 417 | ok: false, |
| 418 | reason: `wslpath conversion failed for ${serverTree.root}`, |
| 419 | fatal: false, |
| 420 | } as const) |
| 421 | : ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const); |
| 422 | }); |
| 423 | |
| 424 | // Set once a staged runtime has been ruled out by the probe, and carried |
| 425 | // through the mounted attempt: if the mounted tree works the cache is the |
| 426 | // broken part and gets invalidated, and if the mounted tree returns its own |
| 427 | // fatal verdict the cached reason is the more actionable one to report. |
| 428 | // A transient mounted failure is neither — it rules nothing out, so it stays |
| 429 | // retryable and the staged verdict waits for an attempt that can answer. |
| 430 | let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; |
| 431 | const failedStaged = (failure: { readonly reason: string }) => |
| 432 | ({ |
| 433 | _tag: "Failed", |
| 434 | reason: `WSL runtime unavailable: ${failure.reason}`, |
| 435 | fatal: true, |
| 436 | }) as const; |
| 437 | |
| 438 | if (input.runtimeArchive !== null) { |
| 439 | const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); |
| 440 | if (runtime.ok) { |
| 441 | // The staged runtime supplies its own Node and node-pty. Provider PATH |
| 442 | // discovery must not require either dependency for runtime readiness. |
| 443 | const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); |
| 444 | if (stagedProbe.ok) { |
| 445 | yield* wslServerTree.cleanupLegacy; |
| 446 | return { |
| 447 | _tag: "Ready", |
| 448 | runningDistro, |
| 449 | windowsEntryPath: environment.backendEntryPath, |
| 450 | runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` }, |
| 451 | resolvedPath: stagedProbe.resolvedPath, |
| 452 | runtimeId: input.runtimeArchive.runtimeId, |
| 453 | } as const; |
| 454 | } |
| 455 | yield* Effect.logWarning( |
| 456 | "The staged WSL runtime did not start; retrying from the mounted server tree.", |
| 457 | { reason: stagedProbe.reason }, |
| 458 | ); |
| 459 | stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason }; |
| 460 | } else { |
| 461 | yield* Effect.logWarning( |
| 462 | "Could not stage the WSL runtime; launching from the mounted server tree instead.", |
| 463 | { reason: runtime.reason }, |
| 464 | ); |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | const mounted = yield* resolveMountedAppRoot; |
| 469 | if (!mounted.ok) { |
| 470 | return stagedFailure && mounted.fatal |
| 471 | ? failedStaged(stagedFailure) |
| 472 | : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); |
| 473 | } |
| 474 | |
| 475 | const nodePtyResult = yield* wslEnv.ensureNodePty( |
| 476 | runningDistro, |
| 477 | mounted.linuxAppRoot, |
| 478 | nodePtyOptions, |
| 479 | ); |
| 480 | if (!nodePtyResult.ok) { |
| 481 | // Substituting the staged verdict for a transient mounted failure would |
| 482 | // turn a retryable failure into a fatal one, ending the WSL attempt (and, |
| 483 | // in wsl-only mode, persisting Windows) before the slow /mnt path had a |
| 484 | // chance to answer and clear the bad cache. |
| 485 | return stagedFailure && nodePtyResult.fatal |
| 486 | ? failedStaged(stagedFailure) |
| 487 | : failedNodePty(nodePtyResult); |
| 488 | } |
| 489 | |
| 490 | // The mounted tree runs what the cache could not, so the cache is the broken |
| 491 | // copy: revoke its ready marker so the next launch reinstalls it instead of |
| 492 | // reusing a tree that has already been proven unloadable. |
| 493 | if (stagedFailure) { |
| 494 | yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId); |
| 495 | } |
| 496 | |
| 497 | return { |
| 498 | _tag: "Ready", |
| 499 | runningDistro, |
| 500 | windowsEntryPath: mounted.windowsEntryPath, |
| 501 | runtime: { |
| 502 | kind: "node-script", |
| 503 | nodePath: nodePtyResult.nodePath, |
| 504 | linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, |
| 505 | }, |
| 506 | resolvedPath: nodePtyResult.resolvedPath, |
| 507 | } as const; |
| 508 | }); |
| 509 | |
| 510 | // True when the given IPv4 belongs to a Windows-side network |
| 511 | // interface. In WSL2 mirrored mode the distro's eth0 IP equals the |
| 512 | // host's, which is the signature we use to detect that mode and |
| 513 | // switch the renderer URL to loopback. |
| 514 | const isLocalHostIpv4 = (ip: string): boolean => { |
| 515 | const interfaces = NodeOS.networkInterfaces(); |
| 516 | for (const list of Object.values(interfaces)) { |
| 517 | if (!list) continue; |
| 518 | for (const entry of list) { |
| 519 | // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on |
| 520 | // the Node build Electron ships (41 / Node 22, verified), but some Node |
| 521 | // builds report the numeric 4. Normalize to a string so a future runtime |
| 522 | // bump can't silently break mirrored-mode detection and leave the |
| 523 | // renderer pointed at the distro IP instead of loopback. |
| 524 | const family = String(entry.family); |
| 525 | if ((family === "IPv4" || family === "4") && entry.address === ip) return true; |
| 526 | } |
| 527 | } |
| 528 | return false; |
| 529 | }; |
| 530 | |
| 531 | const buildObservabilityFragment = (observabilitySettings: BackendObservabilitySettings) => ({ |
| 532 | ...Option.match(observabilitySettings.otlpTracesUrl, { |
| 533 | onNone: () => ({}), |
| 534 | onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), |
| 535 | }), |
| 536 | ...Option.match(observabilitySettings.otlpMetricsUrl, { |
| 537 | onNone: () => ({}), |
| 538 | onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), |
| 539 | }), |
| 540 | ...Option.match(observabilitySettings.otlpLogsUrl, { |
| 541 | onNone: () => ({}), |
| 542 | onSome: (otlpLogsUrl) => ({ otlpLogsUrl }), |
| 543 | }), |
| 544 | }); |
| 545 | |
| 546 | const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( |
| 547 | function* ( |
| 548 | input: SharedBootstrapInput & { |
| 549 | readonly resourceMonitorPath: Option.Option<string>; |
| 550 | readonly cliPath: Option.Option<string>; |
| 551 | }, |
| 552 | ): Effect.fn.Return< |
| 553 | DesktopBackendManager.DesktopBackendStartConfig, |
| 554 | never, |
| 555 | DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure |
| 556 | > { |
| 557 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 558 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 559 | const backendExposure = yield* serverExposure.backendConfig; |
| 560 | |
| 561 | const bootstrap = { |
| 562 | mode: "desktop" as const, |
| 563 | noBrowser: true, |
| 564 | port: backendExposure.port, |
| 565 | t3Home: environment.baseDir, |
| 566 | host: backendExposure.bindHost, |
| 567 | desktopBootstrapToken: input.bootstrapToken, |
| 568 | desktopBootstrapSecret: input.bootstrapSecret, |
| 569 | // DesktopApp installs the shell environment into process.env before any |
| 570 | // backend starts, and this child extends it (extendEnv below). WSL gets |
| 571 | // a different environment, so its bootstrap never sets this. |
| 572 | shellEnvironmentPrepared: true, |
| 573 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, |
| 574 | tailscaleServePort: backendExposure.tailscaleServePort, |
| 575 | desktopTelemetryFd: 4, |
| 576 | desktopTelemetryControlFd: 5, |
| 577 | desktopBrowserFd: 6, |
| 578 | desktopBrowserControlFd: 7, |
| 579 | desktopTerminalFd: 8, |
| 580 | desktopTerminalControlFd: 9, |
| 581 | ...Option.match(input.resourceMonitorPath, { |
| 582 | onNone: () => ({}), |
| 583 | onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), |
| 584 | }), |
| 585 | ...buildObservabilityFragment(input.observabilitySettings), |
| 586 | }; |
| 587 | |
| 588 | return { |
| 589 | executablePath: process.execPath, |
| 590 | // Packaged builds only, so a dev instance never shares the cache with the |
| 591 | // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE, |
| 592 | // so the setting does not leak into the provider and terminal processes |
| 593 | // the backend starts. |
| 594 | args: [ |
| 595 | ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []), |
| 596 | environment.backendEntryPath, |
| 597 | "--bootstrap-fd", |
| 598 | "3", |
| 599 | ], |
| 600 | entryPath: environment.backendEntryPath, |
| 601 | cwd: environment.backendCwd, |
| 602 | env: { |
| 603 | ...backendChildEnvPatch(), |
| 604 | ELECTRON_RUN_AS_NODE: "1", |
| 605 | T3CODE_VERSION_OVERRIDE: environment.isPackaged ? environment.appVersion : undefined, |
| 606 | // The server names this launcher in commands it asks a person to run. |
| 607 | T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), |
| 608 | }, |
| 609 | // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). |
| 610 | extendEnv: true, |
| 611 | bootstrap, |
| 612 | bootstrapDelivery: "fd3", |
| 613 | httpBaseUrl: backendExposure.httpBaseUrl, |
| 614 | captureOutput: true, |
| 615 | preflightFailure: Option.none(), |
| 616 | } satisfies DesktopBackendManager.DesktopBackendStartConfig; |
| 617 | }, |
| 618 | ); |
| 619 | |
| 620 | const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl")(function* ( |
| 621 | input: SharedBootstrapInput & { |
| 622 | readonly port: number; |
| 623 | readonly distro: string | null; |
| 624 | }, |
| 625 | ): Effect.fn.Return< |
| 626 | DesktopBackendManager.DesktopBackendStartConfig, |
| 627 | never, |
| 628 | | DesktopEnvironment.DesktopEnvironment |
| 629 | | DesktopWslEnvironment.DesktopWslEnvironment |
| 630 | | DesktopWslServerTree.DesktopWslServerTree |
| 631 | | FileSystem.FileSystem |
| 632 | > { |
| 633 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 634 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; |
| 635 | const fileSystem = yield* FileSystem.FileSystem; |
| 636 | |
| 637 | // Bind to 0.0.0.0 inside WSL so the backend is reachable both via |
| 638 | // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 |
| 639 | // -> WSL 127.0.0.1) AND via the distro's eth0 IP directly from |
| 640 | // Windows. wslhost forwarding is unreliable on some Windows hosts: |
| 641 | // the desktop's readiness probe and the renderer's saved-env-style |
| 642 | // fetch both saw "Failed to fetch" when the backend only bound to |
| 643 | // 127.0.0.1 inside WSL. Binding to 0.0.0.0 plus advertising the |
| 644 | // WSL IP as the renderer-visible URL avoids that dependency. |
| 645 | // Security-wise this is acceptable for the local-only WSL backend: |
| 646 | // the network it exposes on is the WSL-vEthernet network, not the |
| 647 | // LAN; the primary owns LAN exposure when the user opts in. |
| 648 | const wslBindHost = "0.0.0.0"; |
| 649 | |
| 650 | const bootstrap = { |
| 651 | mode: "desktop" as const, |
| 652 | noBrowser: true, |
| 653 | port: input.port, |
| 654 | // Omit t3Home so the Linux backend uses its own home dir instead of |
| 655 | // the Windows-side baseDir (which would be a /mnt/c path and share |
| 656 | // the SQLite file with the primary). |
| 657 | host: wslBindHost, |
| 658 | desktopBootstrapToken: input.bootstrapToken, |
| 659 | desktopBootstrapSecret: input.bootstrapSecret, |
| 660 | // PortSchema rejects 0, so when tailscale serve is disabled we still |
| 661 | // need a valid number in this slot. The backend reads tailscaleServePort |
| 662 | // only when tailscaleServeEnabled is true, so the actual value here is |
| 663 | // inert. |
| 664 | tailscaleServeEnabled: false, |
| 665 | tailscaleServePort: 443, |
| 666 | // The packaged sidecar is a Windows executable and cannot run inside the |
| 667 | // Linux WSL backend. Keep the field absent instead of passing an unusable |
| 668 | // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable. |
| 669 | // See docs/internals/resource-telemetry.md. |
| 670 | ...buildObservabilityFragment(input.observabilitySettings), |
| 671 | }; |
| 672 | |
| 673 | // The archive is the primary packaged WSL path: it installs directly into |
| 674 | // the distro's ext4 filesystem. The server.asar extraction service is only |
| 675 | // consulted lazily if the archive is unavailable or cannot be staged. |
| 676 | const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME); |
| 677 | const archiveHashPath = environment.path.join( |
| 678 | environment.resourcesPath, |
| 679 | WSL_RUNTIME_ARCHIVE_HASH_NAME, |
| 680 | ); |
| 681 | |
| 682 | const hasArchive = environment.isPackaged |
| 683 | ? yield* fileSystem.exists(archivePath).pipe(Effect.orElseSucceed(() => false)) |
| 684 | : false; |
| 685 | const archiveHash = hasArchive |
| 686 | ? yield* fileSystem.readFileString(archiveHashPath).pipe( |
| 687 | Effect.map(parseWslRuntimeArchiveHash), |
| 688 | Effect.orElseSucceed(() => null), |
| 689 | ) |
| 690 | : null; |
| 691 | if (hasArchive && archiveHash === null) { |
| 692 | yield* Effect.logWarning( |
| 693 | "Ignoring the WSL runtime archive because its SHA-256 identity is missing or invalid; launching from the mounted server tree instead.", |
| 694 | { hashPath: archiveHashPath }, |
| 695 | ); |
| 696 | } |
| 697 | |
| 698 | const preflight = yield* runWslPreflight({ |
| 699 | distro: input.distro, |
| 700 | runtimeArchive: |
| 701 | archiveHash === null |
| 702 | ? null |
| 703 | : { |
| 704 | windowsPath: archivePath, |
| 705 | // The verified archive bytes are the cache identity. Release builds |
| 706 | // embed the release version and pnpm install metadata, so the |
| 707 | // archive changes on every update even when application logic does |
| 708 | // not. Later launches of that update still reuse this directory. |
| 709 | runtimeId: `sha256-${archiveHash}`, |
| 710 | sha256: archiveHash, |
| 711 | }, |
| 712 | // Packaged builds run the self-contained Linux runtime and, on fallback, |
| 713 | // whatever Linux node-pty the mounted tree carries, so the WSL backend never |
| 714 | // needs a compiler, node-gyp, or network on first launch. Compiling from |
| 715 | // source is a dev-only convenience: a checkout has no Linux binary, and |
| 716 | // developers have the toolchain. In packaged builds we instead surface a |
| 717 | // clear diagnostic if the binary can't load (unsupported arch/distro), |
| 718 | // rather than silently dropping into a fragile runtime build. |
| 719 | allowBuild: !environment.isPackaged, |
| 720 | }); |
| 721 | |
| 722 | // Every operation after preflight uses the same concrete distro. In |
| 723 | // default-tracking mode this closes the race where the system default |
| 724 | // changes between probing and spawning the backend. |
| 725 | const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; |
| 726 | const distroForConfig = runningDistro ?? input.distro; |
| 727 | |
| 728 | // Resolve the selected distro's IPv4 address. In mirrored mode the distro |
| 729 | // reports a host interface, so use loopback instead; a failed probe also |
| 730 | // falls back to loopback and preserves the previous behavior. |
| 731 | const distroIp = yield* wslEnvironment.getDistroIp(distroForConfig); |
| 732 | const usesSharedNetworkStack = Option.match(distroIp, { |
| 733 | onNone: () => false, |
| 734 | onSome: (ip) => isLocalHostIpv4(ip), |
| 735 | }); |
| 736 | const rendererHost = usesSharedNetworkStack |
| 737 | ? "127.0.0.1" |
| 738 | : Option.getOrElse(distroIp, () => "127.0.0.1"); |
| 739 | const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`); |
| 740 | |
| 741 | const distroArgs = distroForConfig ? ["-d", distroForConfig] : []; |
| 742 | const forwardedEnv: Record<string, string> = {}; |
| 743 | const forwardedEnvNames: string[] = []; |
| 744 | for (const name of WSL_FORWARDED_ENV_NAMES) { |
| 745 | const value = process.env[name]; |
| 746 | if (value !== undefined && value.length > 0) { |
| 747 | forwardedEnv[name] = value; |
| 748 | forwardedEnvNames.push(name); |
| 749 | } |
| 750 | } |
| 751 | |
| 752 | // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner |
| 753 | // exports the Windows-side base dir for the primary; if it leaks into |
| 754 | // the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via |
| 755 | // /mnt/c, which means both backends read/write the same database and |
| 756 | // their env-ids collide). |
| 757 | const parentEnvWithoutT3Home: Record<string, string | undefined> = {}; |
| 758 | for (const [key, value] of Object.entries(process.env)) { |
| 759 | if (key === "T3CODE_HOME") continue; |
| 760 | parentEnvWithoutT3Home[key] = value; |
| 761 | } |
| 762 | const wslEnv = mergeWslEnv(parentEnvWithoutT3Home.WSLENV, forwardedEnvNames); |
| 763 | |
| 764 | const baseConfig = { |
| 765 | executablePath: "wsl.exe", |
| 766 | entryPath: |
| 767 | preflight._tag === "Ready" ? preflight.windowsEntryPath : environment.backendEntryPath, |
| 768 | cwd: environment.backendCwd, |
| 769 | env: { |
| 770 | ...parentEnvWithoutT3Home, |
| 771 | ...backendChildEnvPatch(), |
| 772 | ...forwardedEnv, |
| 773 | ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), |
| 774 | }, |
| 775 | // env is already a complete process.env minus T3CODE_HOME; pass it |
| 776 | // verbatim instead of letting the spawner re-merge process.env on top. |
| 777 | extendEnv: false, |
| 778 | bootstrap, |
| 779 | bootstrapDelivery: "stdin" as const, |
| 780 | httpBaseUrl, |
| 781 | captureOutput: true, |
| 782 | ...(runningDistro !== null ? { runningDistro } : {}), |
| 783 | }; |
| 784 | |
| 785 | // Forward the dev-server URL as an explicit CLI flag so the WSL backend's |
| 786 | // config resolution lands in dev/ instead of userdata/. The packaged build |
| 787 | // leaves devServerUrl as None. |
| 788 | const devUrlArgs = Option.match(environment.devServerUrl, { |
| 789 | onNone: () => [] as ReadonlyArray<string>, |
| 790 | onSome: (url) => ["--dev-url", url.href], |
| 791 | }); |
| 792 | |
| 793 | if (preflight._tag === "Failed") { |
| 794 | const retryLimit = |
| 795 | preflight.retryLimit ?? (preflight.fatal ? undefined : WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT); |
| 796 | return { |
| 797 | ...baseConfig, |
| 798 | args: [...distroArgs, "--", "node", "--version"], |
| 799 | preflightFailure: Option.some({ |
| 800 | reason: preflight.reason, |
| 801 | fatal: preflight.fatal, |
| 802 | ...(retryLimit === undefined ? {} : { retryLimit }), |
| 803 | }), |
| 804 | } satisfies DesktopBackendManager.DesktopBackendStartConfig; |
| 805 | } |
| 806 | |
| 807 | // The WSL server spawns commands its providers reference by name — `npm`/`npx` |
| 808 | // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those |
| 809 | // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on |
| 810 | // the process PATH, so `npm install -g ...` fails with NotFound. Pass the |
| 811 | // user PATH entries captured by the preflight. Every dynamic value is a |
| 812 | // separate argv entry under `wsl.exe --exec`; no shell command is involved, |
| 813 | // so Windows cannot mangle nested quotes and stdin remains reserved for the |
| 814 | // bootstrap envelope. A node-script runtime additionally leads with the |
| 815 | // probed Node's bin dir so the server cannot pick up a different node than |
| 816 | // the one node-pty was probed with. |
| 817 | const runtime = preflight.runtime; |
| 818 | const launchPath = |
| 819 | runtime.kind === "executable" |
| 820 | ? `${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}` |
| 821 | : `${nodeBinDirOf(runtime.nodePath)}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; |
| 822 | const command = |
| 823 | runtime.kind === "executable" |
| 824 | ? [runtime.entryPath] |
| 825 | : [runtime.nodePath, runtime.linuxEntryPath]; |
| 826 | |
| 827 | return { |
| 828 | ...baseConfig, |
| 829 | args: [ |
| 830 | ...distroArgs, |
| 831 | "--exec", |
| 832 | "env", |
| 833 | `PATH=${launchPath}`, |
| 834 | ...command, |
| 835 | "--bootstrap-fd", |
| 836 | "0", |
| 837 | ...devUrlArgs, |
| 838 | ], |
| 839 | preflightFailure: Option.none(), |
| 840 | ...(preflight.runtimeId === undefined ? {} : { wslRuntimeId: preflight.runtimeId }), |
| 841 | } satisfies DesktopBackendManager.DesktopBackendStartConfig; |
| 842 | }); |
| 843 | |
| 844 | /** @public Service construction is part of the canonical Effect module API. */ |
| 845 | export const make = Effect.gen(function* () { |
| 846 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 847 | const fileSystem = yield* FileSystem.FileSystem; |
| 848 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 849 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; |
| 850 | const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; |
| 851 | const settings = yield* DesktopAppSettings.DesktopAppSettings; |
| 852 | const crypto = yield* Crypto.Crypto; |
| 853 | // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. |
| 854 | // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can |
| 855 | // resolve concurrently; with a plain Ref both could observe None, generate |
| 856 | // distinct secrets, and one would overwrite the other — leaving the two |
| 857 | // backends deriving mismatched tokens and breaking the shared-token |
| 858 | // invariant the renderer relies on. modifyEffect serializes the whole |
| 859 | // get-or-create so the first caller wins and the rest reuse its secret. |
| 860 | const secretRef = yield* SynchronizedRef.make(Option.none<string>()); |
| 861 | const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => |
| 862 | Option.match(current, { |
| 863 | onSome: (secret) => Effect.succeed([secret, current] as const), |
| 864 | onNone: () => |
| 865 | crypto.randomBytes(32).pipe( |
| 866 | Effect.map((bytes) => { |
| 867 | const secret = Hex.encode(bytes); |
| 868 | return [secret, Option.some(secret)] as const; |
| 869 | }), |
| 870 | ), |
| 871 | }), |
| 872 | ); |
| 873 | |
| 874 | // Both resolvers share the same bootstrap token: the renderer holds a |
| 875 | // single token and uses it against whichever backend it's currently |
| 876 | // talking to. Observability settings get re-read each resolve so a |
| 877 | // hot-swap of the server-settings file is picked up on the next |
| 878 | // restart cycle without having to bounce the desktop process. |
| 879 | const sharedInputs = Effect.gen(function* () { |
| 880 | const bootstrapSecret = yield* getOrCreateBootstrapSecret; |
| 881 | const bootstrapToken = currentDesktopBootstrapToken( |
| 882 | bootstrapSecret, |
| 883 | yield* Clock.currentTimeMillis, |
| 884 | ); |
| 885 | const observabilitySettings = yield* readBackendObservabilitySettings.pipe( |
| 886 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 887 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 888 | ); |
| 889 | return { |
| 890 | bootstrapToken, |
| 891 | bootstrapSecret, |
| 892 | observabilitySettings, |
| 893 | } satisfies SharedBootstrapInput; |
| 894 | }); |
| 895 | |
| 896 | const buildWslPrimaryConfig = Effect.gen(function* () { |
| 897 | // wsl-only mode pipes the WSL backend through the same port the |
| 898 | // Windows primary would normally take. That way the renderer |
| 899 | // still loads from the local-only endpoint advertised by |
| 900 | // DesktopServerExposure, and primary-aware code paths (cookie |
| 901 | // auth, the env switcher's "primary" id) keep working without |
| 902 | // a parallel "secondary" registration. |
| 903 | const backendExposure = yield* serverExposure.backendConfig; |
| 904 | const persistedSettings = yield* settings.get; |
| 905 | const shared = yield* sharedInputs; |
| 906 | yield* wslEnvironment.preWarm(persistedSettings.wslDistro); |
| 907 | return yield* resolveWslStartConfig({ |
| 908 | ...shared, |
| 909 | port: backendExposure.port, |
| 910 | distro: persistedSettings.wslDistro, |
| 911 | }).pipe( |
| 912 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 913 | Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), |
| 914 | Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), |
| 915 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 916 | ); |
| 917 | }); |
| 918 | |
| 919 | const buildWindowsPrimaryConfig = Effect.gen(function* () { |
| 920 | const shared = yield* sharedInputs; |
| 921 | const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe( |
| 922 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 923 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 924 | ); |
| 925 | const cliPath = yield* DesktopCliShim.install.pipe( |
| 926 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 927 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 928 | ); |
| 929 | return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, cliPath }).pipe( |
| 930 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 931 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), |
| 932 | ); |
| 933 | }); |
| 934 | |
| 935 | // Single source of truth for what the primary actually runs as. Both |
| 936 | // the start-config dispatch and the renderer-facing label derive from |
| 937 | // this, so they can't disagree — e.g. the label reading "WSL" while the |
| 938 | // config silently fell back to Windows because WSL is unavailable. |
| 939 | // Dispatch happens at resolve time so toggling wsl-only between restarts |
| 940 | // is picked up on the next start cycle (the pool's primary instance is |
| 941 | // created once at layer init, but configResolve fires on each restart). |
| 942 | const describePrimary = Effect.gen(function* () { |
| 943 | const persistedSettings = yield* settings.get; |
| 944 | const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; |
| 945 | // Only honor wsl-only when WSL is actually usable. If the user |
| 946 | // persisted wsl-only but WSL has since become unavailable (wsl.exe |
| 947 | // removed, no distro), fall back to the Windows primary instead of |
| 948 | // looping forever on preflight failures: the Connections backend |
| 949 | // control is hidden while WSL is unavailable, so a stuck WSL primary |
| 950 | // would otherwise leave no in-app way back to Windows. |
| 951 | const useWsl = wslRequested && (yield* wslEnvironment.isAvailable); |
| 952 | return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; |
| 953 | }); |
| 954 | |
| 955 | const currentBootstrapToken = Effect.gen(function* () { |
| 956 | const secret = yield* getOrCreateBootstrapSecret; |
| 957 | return currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis); |
| 958 | }); |
| 959 | |
| 960 | return DesktopBackendConfiguration.of({ |
| 961 | currentBootstrapToken, |
| 962 | resolvePrimary: Effect.gen(function* () { |
| 963 | const { useWsl, wslRequested } = yield* describePrimary; |
| 964 | if (useWsl) { |
| 965 | return yield* buildWslPrimaryConfig; |
| 966 | } |
| 967 | if (wslRequested) { |
| 968 | yield* Effect.logWarning( |
| 969 | "WSL-only backend requested but WSL is unavailable; starting the Windows primary instead.", |
| 970 | ); |
| 971 | } |
| 972 | return yield* buildWindowsPrimaryConfig; |
| 973 | }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimary")), |
| 974 | resolvePrimaryLabel: Effect.gen(function* () { |
| 975 | const { useWsl, distro } = yield* describePrimary; |
| 976 | if (!useWsl) { |
| 977 | return environment.platform === "win32" ? "Windows" : "Local environment"; |
| 978 | } |
| 979 | return distro ? `WSL (${distro})` : "WSL"; |
| 980 | }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimaryLabel")), |
| 981 | resolveWsl: (input) => |
| 982 | Effect.gen(function* () { |
| 983 | const shared = yield* sharedInputs; |
| 984 | return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( |
| 985 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 986 | Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), |
| 987 | Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), |
| 988 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 989 | ); |
| 990 | }).pipe( |
| 991 | Effect.withSpan("desktop.backendConfiguration.resolveWsl", { |
| 992 | attributes: { port: input.port, distro: input.distro ?? null }, |
| 993 | }), |
| 994 | ), |
| 995 | }); |
| 996 | }); |
| 997 | |
| 998 | export const layer = Layer.effect(DesktopBackendConfiguration, make); |