diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 96e56a87c9d..2c24abb4cb4 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 42e4ada438b..e447ca0090c 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,12 @@ 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. The dev-server URL is +// handled separately via a `--dev-url` CLI flag because WSLENV translation of +// URL-shaped values (colons / slashes) is unreliable. +const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; + const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); @@ -97,48 +107,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 +321,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 +332,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 6c5109c8714..d6d04bcfc3f 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 97931f42dbd..bd992c17265 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,24 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); + const waitForReady = (timeout: Duration.Duration): Effect.Effect => + Effect.gen(function* () { + const current = yield* Ref.get(state); + // Return false early if an external `stop()` flipped desiredRunning off + // — no point polling for a backend that is being torn down. + if (!current.desiredRunning) return { done: true, ready: false }; + const ready = yield* Ref.get(desktopState.backendReady); + return ready ? { done: true, ready: true } : { done: false, ready: false }; + }).pipe( + Effect.repeat({ + until: (status) => status.done, + schedule: Schedule.spaced(Duration.millis(100)), + }), + Effect.map((status) => status.ready), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ); + yield* Effect.addFinalizer(() => stop()); return DesktopBackendManager.of({ @@ -590,6 +623,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 8717c877951..33e2acec2a7 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 2715b20cb36..098bf7cc704 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 1cb4d7265a1..b3121a22c24 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,24 @@ 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, + { distro: settings.wslDistro }, + yield* wslEnvironment.listDistros, + Option.getOrNull(yield* wslEnvironment.getUserHome(settings.wslDistro)), + ), + ) + : 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 00000000000..4bbd70248ab --- /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; + const rolledBack = yield* backendManager.waitForReady(SWAP_READINESS_TIMEOUT); + const failedTarget = input.mode === "wsl" ? "WSL backend" : "local backend"; + return yield* new WslBackendSwapError({ + message: rolledBack + ? `The ${failedTarget} didn't come up. Rolled back to the previous mode — check that the chosen distro is healthy and try again.` + : `The ${failedTarget} didn't come up, and the rollback also failed to start. The app is in a degraded state — restart T3 Code to recover.`, + }); + } + + return yield* readWslState; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0bc1badff2d..b2d0bee1bae 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 173be8fb54a..0361423953d 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 db6194cf8f7..15d11b8286c 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 177f05a4b2b..bde4b1c4f88 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 34d18f11a77..384743ee913 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 00000000000..9a881318ad2 --- /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 00000000000..a501b969f34 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -0,0 +1,516 @@ +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); +const USER_HOME_TIMEOUT = Duration.seconds(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>; + // Resolves the user's Linux home dir inside the chosen distro (e.g. + // "/home/josh"). Used by the folder picker to expand `~` correctly. + readonly getUserHome: (distro: string | null) => 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 "