diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
index 96e56a87c9d..d3c419798c1 100644
--- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
@@ -3,12 +3,15 @@ import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";
+import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
+import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
const PersistedServerObservabilitySettingsDocument = Schema.Struct({
observability: Schema.Struct({
@@ -41,12 +44,13 @@ function makeEnvironmentLayer(
options?: {
readonly isPackaged?: boolean;
readonly devServerUrl?: string;
+ readonly platform?: NodeJS.Platform;
},
) {
return DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/src",
homeDirectory: baseDir,
- platform: "darwin",
+ platform: options?.platform ?? "darwin",
processArch: "x64",
appVersion: "1.2.3",
appPath: "/repo",
@@ -69,6 +73,14 @@ function makeEnvironmentLayer(
);
}
+const restoreEnv = (name: string, value: string | undefined) => {
+ if (value === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = value;
+ }
+};
+
const withHarness = (
effect: Effect.Effect<
A,
@@ -89,6 +101,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 +195,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,
@@ -192,4 +208,58 @@ describe("DesktopBackendConfiguration", () => {
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
+
+ it.effect("preserves existing WSLENV entries when forwarding WSL backend secrets", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-backend-config-test-",
+ });
+
+ const previousWslEnv = process.env.WSLENV;
+ const previousOpenAiKey = process.env.OPENAI_API_KEY;
+ const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
+ try {
+ process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";
+ process.env.OPENAI_API_KEY = "openai-key";
+ process.env.ANTHROPIC_API_KEY = "anthropic-key";
+
+ yield* Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolve;
+
+ assert.equal(config.executablePath, "wsl.exe");
+ assert.equal(config.env.OPENAI_API_KEY, "openai-key");
+ assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key");
+ assert.equal(
+ config.env.WSLENV,
+ "GOPATH/p:OPENAI_API_KEY/u:EMPTY:AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY",
+ );
+ }).pipe(
+ Effect.provide(
+ DesktopBackendConfiguration.layer.pipe(
+ Layer.provideMerge(serverExposureLayer),
+ Layer.provideMerge(
+ DesktopAppSettings.layerTest({
+ ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
+ wslMode: "wsl",
+ }),
+ ),
+ Layer.provideMerge(
+ DesktopWslEnvironment.layerTest({
+ isAvailable: true,
+ windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"),
+ }),
+ ),
+ Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
+ ),
+ ),
+ );
+ } finally {
+ restoreEnv("WSLENV", previousWslEnv);
+ restoreEnv("OPENAI_API_KEY", previousOpenAiKey);
+ restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);
+ }
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
});
diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts
index 42e4ada438b..678603b4765 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,9 +48,48 @@ 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]));
+const getWslEnvEntryName = (entry: string): string => {
+ const slashIndex = entry.indexOf("/");
+ return slashIndex === -1 ? entry : entry.slice(0, slashIndex);
+};
+
+const mergeWslEnv = (
+ existingWslEnv: string | undefined,
+ forwardedEnvNames: ReadonlyArray,
+): string | undefined => {
+ const entries: string[] = [];
+ const seenNames = new Set();
+
+ for (const rawEntry of existingWslEnv?.split(":") ?? []) {
+ const entry = rawEntry.trim();
+ if (entry.length === 0) continue;
+
+ const name = getWslEnvEntryName(entry);
+ if (name.length === 0 || seenNames.has(name)) continue;
+
+ seenNames.add(name);
+ entries.push(entry);
+ }
+
+ for (const name of forwardedEnvNames) {
+ if (seenNames.has(name)) continue;
+
+ seenNames.add(name);
+ entries.push(name);
+ }
+
+ return entries.length > 0 ? entries.join(":") : undefined;
+};
+
const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger(
"desktop-backend-configuration",
);
@@ -97,48 +140,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);
+ }
+ }
+
+ // 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 wslEnv = mergeWslEnv(parentEnvWithoutT3Home.WSLENV, forwardedEnvNames);
+
+ 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,
+ ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}),
},
+ // 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 +354,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 +365,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 "