From 19a47f5d525b636363df13f30613d99a860c02e5 Mon Sep 17 00:00:00 2001 From: Josh Gratton Date: Mon, 11 May 2026 09:35:41 -0400 Subject: [PATCH 01/10] fix(server): fall back to direct fd read on EACCES bootstrap Stdin pipes inherited across the wsl.exe boundary fail to re-open via /proc/self/fd/0, so add EACCES to the codes that drop back to reading the fd directly. Without this the WSL desktop backend fails to load its bootstrap envelope with "Failed to duplicate bootstrap fd" and ends up in a scheduled-restart loop. --- apps/server/src/bootstrap.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 9ad6328798..f6df92b822 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -160,9 +160,12 @@ const makeDirectBootstrapStream = (fd: number): Readable => { } }; +// Stdin pipes inherited across the wsl.exe boundary report EACCES when we try +// to re-open them via /proc/self/fd/0 — fall back to reading the fd directly +// in that case, the same way we already do for ENXIO/EINVAL/EPERM. const isBootstrapFdPathDuplicationError = Predicate.compose( Predicate.hasProperty("code"), - (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", + (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM" || _.code === "EACCES", ); export function resolveFdPath( From 3ae1bb8380fc6f1307bf4e643571cb68d9105cda Mon Sep 17 00:00:00 2001 From: Josh Gratton Date: Mon, 11 May 2026 09:36:11 -0400 Subject: [PATCH 02/10] feat(desktop): add WSL backend mode Lets the desktop app launch the local backend inside a WSL distro instead of natively on Windows. Adds: - Backend plumbing (apps/desktop/src/wsl): pure path parsing utilities, a DesktopWslEnvironment Effect service wrapping wsl.exe operations (listDistros, preWarm, windowsToWslPath, ensureNodePty, isAvailable), and an explicit preflight that checks for missing node / build tools before spawning so the failure message names the actual problem. - Spawn path: DesktopBackendConfiguration branches on the new wslMode setting and assembles "wsl.exe -d -- node --bootstrap-fd 0" with the bootstrap envelope on stdin (wsl.exe drops additional file descriptors). Sensitive env vars forward via WSLENV; --dev-url is passed as a CLI flag so the WSL dev backend lands in dev/ instead of userdata/ deterministi- cally. The Windows-side T3CODE_HOME is scrubbed and extendEnv is disabled for WSL so the WSL backend cannot accidentally share a baseDir with the local backend via /mnt/c/... - Settings: wslMode + wslDistro on DesktopAppSettings, with validation that drops distro names containing control or shell meta characters. Contracts get DesktopWslMode / DesktopWslDistro / DesktopWslState schemas. - IPC: getWslState and setWslBackend on the desktop bridge. The setter pre-warms the WSL VM, persists settings, then drives an in-process backend stop + start with a 2-minute readiness wait and a rollback path that reverts to the previous mode if the new backend never reports ready. pickFolder defaults to the WSL home UNC path when wslMode is "wsl". - Web UI: backend-runtime selector in Connection Settings with a three-stage swap modal (restarting / re-establishing session / syncing) that suppresses the WS reconnect toast for the duration of the swap, waits for the new backend's welcome event before closing, and clears the previous env's store state so the side- bar does not render stale threads. New suppressReconnect helper on the connection-status atom plus exports for the descriptor refresh and reauth used by the swap flow. --- .../DesktopBackendConfiguration.test.ts | 6 + .../backend/DesktopBackendConfiguration.ts | 237 ++++++++- .../src/backend/DesktopBackendManager.test.ts | 4 + .../src/backend/DesktopBackendManager.ts | 74 ++- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/window.ts | 19 +- apps/desktop/src/ipc/methods/wsl.ts | 111 +++++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 2 + .../src/settings/DesktopAppSettings.test.ts | 51 ++ .../src/settings/DesktopAppSettings.ts | 46 ++ .../src/updates/DesktopUpdates.test.ts | 1 + .../src/wsl/DesktopWslEnvironment.test.ts | 75 +++ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 453 ++++++++++++++++++ apps/desktop/src/wsl/wslPathParsing.test.ts | 175 +++++++ apps/desktop/src/wsl/wslPathParsing.ts | 108 +++++ .../WebSocketConnectionSurface.logic.test.ts | 1 + .../components/WebSocketConnectionSurface.tsx | 14 +- .../settings/ConnectionsSettings.tsx | 259 ++++++++++ .../settings/SettingsPanels.browser.tsx | 12 + apps/web/src/environments/primary/auth.ts | 10 + apps/web/src/environments/primary/context.ts | 12 + apps/web/src/environments/primary/index.ts | 2 + apps/web/src/localApi.test.ts | 12 + apps/web/src/rpc/wsConnectionState.ts | 33 ++ packages/contracts/src/desktopBootstrap.ts | 5 +- packages/contracts/src/ipc.ts | 35 ++ 28 files changed, 1712 insertions(+), 53 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/wsl.ts create mode 100644 apps/desktop/src/wsl/DesktopWslEnvironment.test.ts create mode 100644 apps/desktop/src/wsl/DesktopWslEnvironment.ts create mode 100644 apps/desktop/src/wsl/wslPathParsing.test.ts create mode 100644 apps/desktop/src/wsl/wslPathParsing.ts diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 96e56a87c9..2c24abb4cb 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -9,6 +9,8 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const PersistedServerObservabilitySettingsDocument = Schema.Struct({ observability: Schema.Struct({ @@ -89,6 +91,8 @@ const withHarness = ( Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), ), ), @@ -181,6 +185,8 @@ describe("DesktopBackendConfiguration", () => { Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { isPackaged: false, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 42e4ada438..e77c5e8193 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -7,10 +7,14 @@ import * as Option from "effect/Option"; import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; +import serverPackageJson from "../../../server/package.json" with { type: "json" }; + import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; export interface DesktopBackendConfigurationShape { readonly resolve: Effect.Effect; @@ -44,6 +48,17 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; +// Sensitive env vars that the WSL backend needs but Windows process.env won't +// forward across the wsl.exe boundary without WSLENV. VITE_DEV_SERVER_URL is +// included so the WSL backend can tell it is running in dev mode and use the +// `dev/` subdir instead of `userdata/` for its state — otherwise the dev +// build collides with the user's daily-driver packaged WSL backend. +const WSL_FORWARDED_ENV_NAMES = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "VITE_DEV_SERVER_URL", +] as const; + const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); @@ -97,48 +112,211 @@ const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootst }, ); +interface ResolveBackendStartConfigInput { + readonly bootstrapToken: string; + readonly observabilitySettings: BackendObservabilitySettings; + readonly wslMode: "local" | "wsl"; + readonly wslDistro: string | null; +} + +interface WslPreflightOutcome { + readonly _tag: "Ready"; + readonly linuxEntryPath: string; +} + +interface WslPreflightFailure { + readonly _tag: "Failed"; + readonly reason: string; +} + +const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { + readonly distro: string | null; + readonly windowsEntryPath: string; + readonly windowsRepoRoot: string; + readonly allowBuild: boolean; +}): Effect.fn.Return< + WslPreflightOutcome | WslPreflightFailure, + never, + DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem +> { + const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + + const wslAvailable = yield* wslEnv.isAvailable; + if (!wslAvailable) { + return { _tag: "Failed", reason: "WSL is not available on this system" } as const; + } + + const entryExists = yield* fileSystem + .exists(input.windowsEntryPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!entryExists) { + return { + _tag: "Failed", + reason: `missing server entry at ${input.windowsEntryPath}`, + } as const; + } + + const linuxEntry = yield* wslEnv.windowsToWslPath(input.distro, input.windowsEntryPath); + if (Option.isNone(linuxEntry)) { + return { + _tag: "Failed", + reason: `wslpath conversion failed for ${input.windowsEntryPath}`, + } as const; + } + + const nodePtyResult = yield* wslEnv.ensureNodePty(input.distro, input.windowsRepoRoot, { + allowBuild: input.allowBuild, + nodeEngineRange: serverPackageJson.engines.node, + }); + if (!nodePtyResult.ok) { + return { + _tag: "Failed", + reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`, + } as const; + } + + return { _tag: "Ready", linuxEntryPath: linuxEntry.value } as const; +}); + const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( - function* (input: { - readonly bootstrapToken: string; - readonly observabilitySettings: BackendObservabilitySettings; - }): Effect.fn.Return< + function* ( + input: ResolveBackendStartConfigInput, + ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, never, - DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure + | DesktopEnvironment.DesktopEnvironment + | DesktopServerExposure.DesktopServerExposure + | DesktopWslEnvironment.DesktopWslEnvironment + | FileSystem.FileSystem > { const environment = yield* DesktopEnvironment.DesktopEnvironment; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const backendExposure = yield* serverExposure.backendConfig; - return { - executablePath: process.execPath, + const useWsl = input.wslMode === "wsl" && environment.platform === "win32"; + + const bootstrap = { + mode: "desktop" as const, + noBrowser: true, + port: backendExposure.port, + // Omit t3Home for WSL mode so the Linux backend uses its own home dir + // instead of the Windows-side baseDir (which would be a /mnt/c path). + ...(useWsl ? {} : { t3Home: environment.baseDir }), + host: backendExposure.bindHost, + desktopBootstrapToken: input.bootstrapToken, + tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, + tailscaleServePort: backendExposure.tailscaleServePort, + ...Option.match(input.observabilitySettings.otlpTracesUrl, { + onNone: () => ({}), + onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), + }), + ...Option.match(input.observabilitySettings.otlpMetricsUrl, { + onNone: () => ({}), + onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), + }), + }; + + if (!useWsl) { + return { + executablePath: process.execPath, + args: [environment.backendEntryPath, "--bootstrap-fd", "3"], + entryPath: environment.backendEntryPath, + cwd: environment.backendCwd, + env: { + ...backendChildEnvPatch(), + ELECTRON_RUN_AS_NODE: "1", + }, + // Local mode wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). + extendEnv: true, + bootstrap, + bootstrapDelivery: "fd3", + httpBaseUrl: backendExposure.httpBaseUrl, + captureOutput: true, + preflightFailure: Option.none(), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; + } + + const preflight = yield* runWslPreflight({ + distro: input.wslDistro, + windowsEntryPath: environment.backendEntryPath, + windowsRepoRoot: environment.appRoot, + allowBuild: !environment.isPackaged, + }); + + const distroArgs = input.wslDistro ? ["-d", input.wslDistro] : []; + const forwardedEnv: Record = {}; + const forwardedEnvNames: string[] = []; + for (const name of WSL_FORWARDED_ENV_NAMES) { + const value = process.env[name]; + if (value !== undefined && value.length > 0) { + forwardedEnv[name] = value; + forwardedEnvNames.push(name); + } + } + const wslEnvNames = [...new Set(forwardedEnvNames)]; + + // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner + // exports the Windows-side base dir for the local backend; if it leaks + // into the WSL backend the Linux side ends up sharing C:\Users\...\.t3 + // via /mnt/c, which means both backends are reading/writing the same + // database and the env-id never differs across the swap). + const parentEnvWithoutT3Home: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key === "T3CODE_HOME") continue; + parentEnvWithoutT3Home[key] = value; + } + + const baseConfig = { + executablePath: "wsl.exe", entryPath: environment.backendEntryPath, cwd: environment.backendCwd, env: { + ...parentEnvWithoutT3Home, ...backendChildEnvPatch(), - ELECTRON_RUN_AS_NODE: "1", - }, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: backendExposure.port, - t3Home: environment.baseDir, - host: backendExposure.bindHost, - desktopBootstrapToken: input.bootstrapToken, - tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, - tailscaleServePort: backendExposure.tailscaleServePort, - ...Option.match(input.observabilitySettings.otlpTracesUrl, { - onNone: () => ({}), - onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), - }), - ...Option.match(input.observabilitySettings.otlpMetricsUrl, { - onNone: () => ({}), - onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), - }), + ...forwardedEnv, + ...(wslEnvNames.length > 0 ? { WSLENV: wslEnvNames.join(":") } : {}), }, + // env is already a complete process.env minus T3CODE_HOME; pass it + // verbatim instead of letting the spawner re-merge process.env on top. + extendEnv: false, + bootstrap, + bootstrapDelivery: "stdin" as const, httpBaseUrl: backendExposure.httpBaseUrl, captureOutput: true, }; + + // Forward the dev-server URL as an explicit CLI flag so the WSL backend's + // config resolution lands in dev/ instead of userdata/. Inheriting through + // WSLENV is unreliable in practice (URL-shaped values with colons / + // slashes get translated unpredictably depending on flags), and the + // packaged build leaves devServerUrl as None anyway. + const devUrlArgs = Option.match(environment.devServerUrl, { + onNone: () => [] as ReadonlyArray, + onSome: (url) => ["--dev-url", url.href], + }); + + if (preflight._tag === "Failed") { + return { + ...baseConfig, + args: [...distroArgs, "--", "node", "--version"], + preflightFailure: Option.some(preflight.reason), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; + } + + return { + ...baseConfig, + args: [ + ...distroArgs, + "--", + "node", + preflight.linuxEntryPath, + "--bootstrap-fd", + "0", + ...devUrlArgs, + ], + preflightFailure: Option.none(), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; }, ); @@ -148,6 +326,8 @@ export const layer = Layer.effect( const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const tokenRef = yield* Ref.make(Option.none()); return DesktopBackendConfiguration.of({ @@ -157,12 +337,17 @@ export const layer = Layer.effect( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); + const settings = yield* appSettings.get; return yield* resolveBackendStartConfig({ bootstrapToken, observabilitySettings, + wslMode: settings.wslMode, + wslDistro: settings.wslDistro, }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(FileSystem.FileSystem, fileSystem), ); }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 6c5109c871..d6d04bcfc3 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -31,6 +31,7 @@ const decodeDesktopBackendBootstrap = Schema.decodeEffect( const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", + args: ["/server/bin.mjs", "--bootstrap-fd", "3"], entryPath: "/server/bin.mjs", cwd: "/server", env: { ELECTRON_RUN_AS_NODE: "1" }, @@ -44,8 +45,11 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { tailscaleServeEnabled: false, tailscaleServePort: 443, }, + bootstrapDelivery: "fd3", + extendEnv: true, httpBaseUrl: new URL("http://127.0.0.1:3773"), captureOutput: true, + preflightFailure: Option.none(), }; const configWithObservability: DesktopBackendBootstrapValue = { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 97931f42db..f049bfc7a8 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -43,14 +43,23 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; +export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; + export interface DesktopBackendStartConfig { readonly executablePath: string; + readonly args: ReadonlyArray; readonly entryPath: string; readonly cwd: string; readonly env: Record; + // When true the spawner merges the desktop process.env on top of `env`; + // when false `env` is passed verbatim. WSL mode opts out so a leaking + // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. + readonly extendEnv: boolean; readonly bootstrap: DesktopBackendBootstrapValue; + readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; readonly httpBaseUrl: URL; readonly captureOutput: boolean; + readonly preflightFailure: Option.Option; } interface BackendProcessExit { @@ -111,6 +120,10 @@ export interface DesktopBackendManagerShape { readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; + // Polls desiredRunning + ready until the backend reports ready, or the + // timeout elapses. Returns true on ready, false on timeout. Used by the + // WSL backend swap to drive its rollback path. + readonly waitForReady: (timeout: Duration.Duration) => Effect.Effect; } export class DesktopBackendManager extends Context.Service< @@ -233,28 +246,25 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), ); const onOutput = options.onOutput ?? (() => Effect.void); - const command = ChildProcess.make( - options.executablePath, - [options.entryPath, "--bootstrap-fd", "3"], - { - cwd: options.cwd, - env: options.env, - extendEnv: true, - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. - stdin: "ignore", - stdout: options.captureOutput ? "pipe" : "inherit", - stderr: options.captureOutput ? "pipe" : "inherit", - killSignal: "SIGTERM", - forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, - additionalFds: { - fd3: { - type: "input", - stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), - }, - }, - }, - ); + const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`)); + const command = ChildProcess.make(options.executablePath, options.args, { + cwd: options.cwd, + env: options.env, + extendEnv: options.extendEnv, + // In Electron main, process.execPath points to the Electron binary. + // Run the child in Node mode so this backend process does not become a GUI app instance. + stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", + stdout: options.captureOutput ? "pipe" : "inherit", + stderr: options.captureOutput ? "pipe" : "inherit", + killSignal: "SIGTERM", + forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, + // wsl.exe drops additional file descriptors when forwarding to the Linux + // side, so the WSL spawn path delivers the bootstrap envelope via stdin + // (`--bootstrap-fd 0`) instead. + ...(options.bootstrapDelivery === "fd3" + ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } } + : {}), + }); const handle = yield* spawner .spawn(command) @@ -342,6 +352,11 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio config: Option.some(config), })); + if (Option.isSome(config.preflightFailure)) { + yield* scheduleRestart(config.preflightFailure.value); + return; + } + if (!entryExists) { yield* scheduleRestart(`missing server entry at ${config.entryPath}`); return; @@ -583,6 +598,20 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); + const waitForReady = (timeout: Duration.Duration): Effect.Effect => + Effect.gen(function* () { + const ready = yield* Ref.get(desktopState.backendReady); + if (ready) return true; + return false; + }).pipe( + Effect.repeat({ + until: (ready) => ready, + schedule: Schedule.spaced(Duration.millis(100)), + }), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ); + yield* Effect.addFinalizer(() => stop()); return DesktopBackendManager.of({ @@ -590,6 +619,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio stop, currentConfig, snapshot, + waitForReady, }); }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8717c87795..33e2acec2a 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -41,6 +41,7 @@ import { setTheme, showContextMenu, } from "./methods/window.ts"; +import { getWslState, setWslBackend } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.gen(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -70,6 +71,9 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setTailscaleServeEnabled); yield* ipc.handle(getAdvertisedEndpoints); + yield* ipc.handle(getWslState); + yield* ipc.handle(setWslBackend); + yield* ipc.handle(pickFolder); yield* ipc.handle(confirm); yield* ipc.handle(setTheme); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 2715b20cb3..098bf7cc70 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -32,4 +32,6 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; +export const SET_WSL_BACKEND_CHANNEL = "desktop:set-wsl-backend"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 1cb4d7265a..031ddbb327 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -11,6 +11,8 @@ import * as Schema from "effect/Schema"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; @@ -18,6 +20,7 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; +import { resolveWslPickFolderDefaultPath } from "../../wsl/wslPathParsing.ts"; const ContextMenuPosition = Schema.Struct({ x: Schema.Number, @@ -72,9 +75,23 @@ export const pickFolder = makeIpcMethod({ const dialog = yield* ElectronDialog.ElectronDialog; const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* appSettings.get; + const wslAvailable = yield* wslEnvironment.isAvailable; + const useWsl = settings.wslMode === "wsl" && wslAvailable; + const defaultPath = useWsl + ? Option.fromNullishOr( + resolveWslPickFolderDefaultPath( + options, + { enabled: true, distro: settings.wslDistro }, + yield* wslEnvironment.listDistros, + ), + ) + : environment.resolvePickFolderDefaultPath(options); const selectedPath = yield* dialog.pickFolder({ owner: yield* electronWindow.focusedMainOrFirst, - defaultPath: environment.resolvePickFolderDefaultPath(options), + defaultPath, }); return Option.getOrNull(selectedPath); }), diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts new file mode 100644 index 0000000000..68fe19bbe3 --- /dev/null +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -0,0 +1,111 @@ +import { + DesktopWslModeSchema, + DesktopWslStateSchema, + type DesktopWslState, +} from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +// Cap how long we wait for the new backend to come up before rolling back to +// the previous mode. Generous enough to cover cold WSL boots (VM spin-up, +// initial wslhost forwarding handshake) and node-pty preparation on a fresh +// distro; tight enough that a truly stuck swap doesn't strand the user. +const SWAP_READINESS_TIMEOUT = Duration.minutes(2); + +const SetWslBackendInput = Schema.Struct({ + mode: DesktopWslModeSchema, + distro: Schema.NullOr(Schema.String), +}); + +class WslBackendSwapError extends Data.TaggedError("WslBackendSwapError")<{ + readonly message: string; +}> {} + +const readWslState: Effect.Effect< + DesktopWslState, + never, + DesktopAppSettings.DesktopAppSettings | DesktopWslEnvironment.DesktopWslEnvironment +> = Effect.gen(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* appSettings.get; + const available = yield* wslEnvironment.isAvailable; + // Only enumerate distros when WSL is actually available — listDistros on a + // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + const distros = available ? yield* wslEnvironment.listDistros : []; + return { + mode: settings.wslMode, + distro: settings.wslDistro, + available, + distros, + }; +}); + +export const getWslState = makeIpcMethod({ + channel: IpcChannels.GET_WSL_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.getState")(function* () { + return yield* readWslState; + }), +}); + +export const setWslBackend = makeIpcMethod({ + channel: IpcChannels.SET_WSL_BACKEND_CHANNEL, + payload: SetWslBackendInput, + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.setBackend")(function* (input) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + + // Pre-warm the WSL VM before swapping so the new backend boot doesn't + // race wsl.exe's first-spawn cold start against the HTTP readiness probe. + if (input.mode === "wsl") { + yield* wslEnvironment.preWarm(input.distro); + } + + const previousSettings = yield* appSettings.get; + const change = yield* appSettings.setWslMode({ mode: input.mode, distro: input.distro }); + + if (!change.changed) { + return yield* readWslState; + } + + // In-process swap: stop the running backend, then start it again. The + // backend manager re-resolves config on start, so the new wslMode picks + // up automatically. + yield* backendManager.stop(); + yield* backendManager.start; + + // Bounded readiness wait — if the new backend doesn't come up in time + // (bad distro, missing node-pty, preflight failure that scheduled + // restarts forever) revert to the previous mode so the user isn't stuck. + const ready = yield* backendManager.waitForReady(SWAP_READINESS_TIMEOUT); + if (!ready) { + yield* appSettings.setWslMode({ + mode: previousSettings.wslMode, + distro: previousSettings.wslDistro, + }); + yield* backendManager.stop(); + yield* backendManager.start; + yield* backendManager.waitForReady(SWAP_READINESS_TIMEOUT); + return yield* new WslBackendSwapError({ + message: + input.mode === "wsl" + ? "The WSL backend didn't come up. Rolled back to the previous mode — check that the chosen distro is healthy and try again." + : "The local backend didn't come up. Rolled back to the previous mode.", + }); + } + + return yield* readWslState; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0bc1badff2..b2d0bee1ba 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -44,6 +44,7 @@ import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -130,6 +131,7 @@ const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopSe const desktopBackendLayer = DesktopBackendManager.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), + Layer.provideMerge(DesktopWslEnvironment.layer), Layer.provideMerge(desktopWindowLayer), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 173be8fb54..0361423953 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -87,6 +87,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { setTailscaleServeEnabled: (input) => ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), + getWslState: () => ipcRenderer.invoke(IpcChannels.GET_WSL_STATE_CHANNEL), + setWslBackend: (input) => ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_CHANNEL, input), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f..15d11b8286 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -21,6 +21,8 @@ const DesktopSettingsPatch = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), + wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), + wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), }); const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); @@ -95,6 +97,8 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + wslMode: "local", + wslDistro: null, } satisfies DesktopSettingsValue); }); @@ -116,6 +120,8 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, + wslMode: "local", + wslDistro: null, } satisfies DesktopSettingsValue); const exposure = yield* settings.setServerExposureMode("local-only"); @@ -195,6 +201,8 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslMode: "local", + wslDistro: null, } satisfies DesktopSettingsValue); }), ), @@ -234,6 +242,8 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + wslMode: "local", + wslDistro: null, } satisfies DesktopSettingsValue); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -256,6 +266,8 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, + wslMode: "local", + wslDistro: null, } satisfies DesktopSettingsValue); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -277,8 +289,47 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslMode: "local", + wslDistro: null, } satisfies DesktopSettingsValue); }), ), ); + + it.effect("persists wsl mode and normalizes invalid distro names", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const enable = yield* settings.setWslMode({ mode: "wsl", distro: "Ubuntu-22.04" }); + assert.isTrue(enable.changed); + assert.equal(enable.settings.wslMode, "wsl"); + assert.equal(enable.settings.wslDistro, "Ubuntu-22.04"); + + const reloaded = yield* settings.load; + assert.equal(reloaded.wslMode, "wsl"); + assert.equal(reloaded.wslDistro, "Ubuntu-22.04"); + + const reject = yield* settings.setWslMode({ mode: "wsl", distro: "bad name!" }); + assert.equal(reject.settings.wslDistro, null); + + const noop = yield* settings.setWslMode({ mode: "wsl", distro: null }); + assert.isFalse(noop.changed); + }), + ), + ); + + it.effect("drops invalid persisted wsl distro values on load", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + wslMode: "wsl", + wslDistro: "bad/name", + }); + const loaded = yield* settings.load; + assert.equal(loaded.wslMode, "wsl"); + assert.equal(loaded.wslDistro, null); + }), + ), + ); }); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 177f05a4b2..bde4b1c4f8 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -1,8 +1,10 @@ import { DesktopServerExposureModeSchema, DesktopUpdateChannelSchema, + DesktopWslModeSchema, type DesktopServerExposureMode, type DesktopUpdateChannel, + type DesktopWslMode, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; @@ -19,6 +21,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; +import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; @@ -26,6 +29,8 @@ export interface DesktopSettings { readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; + readonly wslMode: DesktopWslMode; + readonly wslDistro: string | null; } export interface DesktopSettingsChange { @@ -41,6 +46,8 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslMode: "local", + wslDistro: null, }; const DesktopSettingsDocument = Schema.Struct({ @@ -49,6 +56,8 @@ const DesktopSettingsDocument = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), + wslMode: Schema.optionalKey(DesktopWslModeSchema), + wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -84,6 +93,10 @@ export interface DesktopAppSettingsShape { readonly setUpdateChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; + readonly setWslMode: (input: { + readonly mode: DesktopWslMode; + readonly distro: string | null; + }) => Effect.Effect; } export class DesktopAppSettings extends Context.Service< @@ -104,6 +117,10 @@ function normalizeTailscaleServePort(value: unknown): number { : DEFAULT_TAILSCALE_SERVE_PORT; } +function normalizeWslDistro(value: unknown): string | null { + return typeof value === "string" && isValidDistroName(value) ? value : null; +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, @@ -124,6 +141,8 @@ function normalizeDesktopSettingsDocument( ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) : defaultSettings.updateChannel, updateChannelConfiguredByUser, + wslMode: parsed.wslMode === "wsl" ? "wsl" : "local", + wslDistro: normalizeWslDistro(parsed.wslDistro), }; } @@ -148,6 +167,12 @@ function toDesktopSettingsDocument( if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; } + if (settings.wslMode !== defaults.wslMode) { + document.wslMode = settings.wslMode; + } + if (settings.wslDistro !== defaults.wslDistro) { + document.wslDistro = settings.wslDistro; + } return document; } @@ -194,6 +219,20 @@ function setUpdateChannel( }; } +function setWslMode( + settings: DesktopSettings, + input: { readonly mode: DesktopWslMode; readonly distro: string | null }, +): DesktopSettings { + const distro = normalizeWslDistro(input.distro); + return settings.wslMode === input.mode && settings.wslDistro === distro + ? settings + : { + ...settings, + wslMode: input.mode, + wslDistro: distro, + }; +} + function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, @@ -285,6 +324,12 @@ export const layer = Layer.effect( persist((settings) => setUpdateChannel(settings, channel)).pipe( Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), ), + setWslMode: (input) => + persist((settings) => setWslMode(settings, input)).pipe( + Effect.withSpan("desktop.settings.setWslMode", { + attributes: { mode: input.mode, distro: input.distro ?? null }, + }), + ), }); }), ); @@ -313,6 +358,7 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), + setWslMode: (input) => update((settings) => setWslMode(settings, input)), }); }), ); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 34d18f11a7..384743ee91 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -112,6 +112,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { restartAttempt: 0, restartScheduled: false, }), + waitForReady: () => Effect.succeed(true), }); const environmentLayer = DesktopEnvironment.layer({ diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts new file mode 100644 index 0000000000..9a881318ad --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; + +import { formatMissingToolsReason, parseToolchainReport } from "./DesktopWslEnvironment.ts"; + +describe("parseToolchainReport", () => { + it("returns no missing tools and no node version on empty output", () => { + expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); + }); + + it("collects all missing: lines", () => { + const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); + expect(parseToolchainReport(stdout)).toEqual({ + missingTools: ["make", "g++"], + nodeVersion: "24.10.0", + }); + }); + + it("ignores blank lines and trims whitespace", () => { + const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); + expect(parseToolchainReport(stdout)).toEqual({ + missingTools: ["python3"], + nodeVersion: "v22.16.0", + }); + }); + + it("returns null node version when value after prefix is empty", () => { + expect(parseToolchainReport("nodeVersion:")).toEqual({ + missingTools: [], + nodeVersion: null, + }); + }); +}); + +describe("formatMissingToolsReason", () => { + it("returns null when everything is present and node is in range", () => { + expect( + formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), + ).toBeNull(); + }); + + it("returns null when range is not specified and tools are present", () => { + expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); + }); + + it("flags missing node first", () => { + const reason = formatMissingToolsReason( + { missingTools: ["node", "make"], nodeVersion: null }, + "^24.10", + ); + expect(reason).toContain("node"); + expect(reason).toContain("^24.10"); + expect(reason).toContain("make"); + expect(reason).toContain("nvm"); + }); + + it("flags an out-of-range node version with the actual version surfaced", () => { + const reason = formatMissingToolsReason( + { missingTools: [], nodeVersion: "20.0.0" }, + "^24.10 || ^22.16", + ); + expect(reason).toContain("node 20.0.0"); + expect(reason).toContain("requires ^24.10 || ^22.16"); + }); + + it("flags missing build tools without node when node is fine", () => { + const reason = formatMissingToolsReason( + { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, + "^24.10", + ); + expect(reason).toContain("g++"); + expect(reason).toContain("python3"); + expect(reason).toContain("build-essential"); + expect(reason).not.toContain("nvm"); + }); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts new file mode 100644 index 0000000000..dc48882abc --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -0,0 +1,453 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { satisfiesSemverRange } from "@t3tools/shared/semver"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; + +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); +const LIST_TIMEOUT = Duration.seconds(8); +const PRE_WARM_TIMEOUT = Duration.seconds(10); +const WSLPATH_TIMEOUT = Duration.seconds(10); +const PROBE_TIMEOUT = Duration.seconds(10); +const TOOLCHAIN_TIMEOUT = Duration.seconds(10); +const BUILD_TIMEOUT = Duration.minutes(5); + +export interface EnsureWslNodePtyOptions { + readonly allowBuild?: boolean; + readonly nodeEngineRange?: string | null; +} + +export type EnsureWslNodePtyResult = + | { readonly ok: true } + | { readonly ok: false; readonly reason: string }; + +export interface DesktopWslEnvironmentShape { + readonly isAvailable: Effect.Effect; + readonly listDistros: Effect.Effect; + readonly preWarm: (distro: string | null) => Effect.Effect; + readonly windowsToWslPath: ( + distro: string | null, + windowsPath: string, + ) => Effect.Effect>; + readonly ensureNodePty: ( + distro: string | null, + windowsRepoRoot: string, + options?: EnsureWslNodePtyOptions, + ) => Effect.Effect; +} + +export class DesktopWslEnvironment extends Context.Service< + DesktopWslEnvironment, + DesktopWslEnvironmentShape +>()("t3/desktop/WslEnvironment") {} + +const buildDistroArgs = (distro: string | null): ReadonlyArray => + distro ? ["-d", distro] : []; + +const concatChunks = (arrays: ReadonlyArray): Uint8Array => { + let totalLength = 0; + for (const arr of arrays) totalLength += arr.byteLength; + const out = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + out.set(arr, offset); + offset += arr.byteLength; + } + return out; +}; + +const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); + +interface ShellResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +const TIMEOUT_RESULT: ShellResult = { exitCode: 124, stdout: "", stderr: "\n[timeout]" }; + +// wsl.exe re-escapes args before forwarding them to the Linux side, which +// mangles quotes inside `bash -lc "