diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index aa2be07e6f..edc6a08cde 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -255,6 +255,8 @@ registerIpcHandlers({ setLocalServerConfig: (config) => localServer.setConfig(config), runLocalServerStep: (step) => localServer.runStep(step), cancelLocalServerJob: () => localServer.cancelJob(), + installLocalServerWsl: () => localServer.installWsl(), + installLocalServerDistro: (name) => localServer.installDistro(name), openLocalServerTerminal: () => localServer.openTerminal(), onLocalServerEvent: (listener) => localServer.subscribe(listener), getDefaultServerUrl: () => getDefaultServerUrl(), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 7021921600..5c6a187e9f 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -27,6 +27,8 @@ type Deps = { setLocalServerConfig: (config: LocalServerConfig) => Promise | void runLocalServerStep: (step: LocalServerStep) => Promise | void cancelLocalServerJob: () => Promise | void + installLocalServerWsl: () => Promise | void + installLocalServerDistro: (name: string) => Promise | void openLocalServerTerminal: () => Promise | void onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null @@ -66,6 +68,10 @@ export function registerIpcHandlers(deps: Deps) { deps.runLocalServerStep(step), ) ipcMain.handle("local-server-cancel-job", () => deps.cancelLocalServerJob()) + ipcMain.handle("local-server-install-wsl", () => deps.installLocalServerWsl()) + ipcMain.handle("local-server-install-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.installLocalServerDistro(name), + ) ipcMain.handle("local-server-open-terminal", () => deps.openLocalServerTerminal()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts index 48e9c44378..d15c83238d 100644 --- a/packages/desktop-electron/src/main/local-server.ts +++ b/packages/desktop-electron/src/main/local-server.ts @@ -8,7 +8,16 @@ import type { } from "../preload/types" import { LOCAL_SERVER_KEY } from "./constants" import { store } from "./store" -import { listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, probeWslDistro, probeWslRuntime } from "./wsl" +import { + installWslDistro, + installWslRuntimeElevated, + listInstalledWslDistros, + listOnlineWslDistros, + openWslTerminal, + probeWslDistro, + probeWslRuntime, + wslNeedsRestart, +} from "./wsl" export function defaultLocalServerConfig(): LocalServerConfig { return { @@ -54,17 +63,22 @@ export function createLocalServerController() { }) } + const persistConfig = (config: LocalServerConfig) => { + const next = normalizeLocalServerConfig(config) + store.set(LOCAL_SERVER_KEY, next) + update({ + ...state, + config: next, + }) + return next + } + return { getState() { return state }, setConfig(config: LocalServerConfig) { - const next = normalizeLocalServerConfig(config) - store.set(LOCAL_SERVER_KEY, next) - update({ - ...state, - config: next, - }) + persistConfig(config) }, subscribe(listener: (event: LocalServerEvent) => void) { listeners.add(listener) @@ -180,6 +194,201 @@ export function createLocalServerController() { status: { kind: "idle" }, }) }, + async installWsl() { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + clearTranscript() + appendTranscript({ stream: "system", text: "Installing WSL runtime" }) + persistConfig({ + ...state.config, + mode: "wsl", + onboarding: { + ...state.config.onboarding, + step: "wsl", + complete: false, + pendingRestart: false, + }, + }) + update({ + ...state, + job: { step: "wsl", startedAt: Date.now() }, + status: { kind: "running", step: "wsl" }, + }) + + try { + const result = await installWslRuntimeElevated({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + if (result.code !== 0) throw new Error(commandFailure(result, "WSL installation failed")) + + const pendingRestart = wslNeedsRestart(result) + const nextConfig = persistConfig({ + ...state.config, + mode: "wsl", + onboarding: { + ...state.config.onboarding, + step: pendingRestart ? "wsl" : "distro", + complete: false, + pendingRestart, + }, + }) + + if (pendingRestart) { + const message = "Windows restart required to finish WSL installation" + update({ + ...state, + config: nextConfig, + job: null, + status: { kind: "failed", step: "wsl", message }, + checks: { + ...state.checks, + wsl: { + available: false, + version: null, + status: null, + error: message, + }, + }, + }) + return + } + + const wsl = await probeWslRuntime({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + update({ + ...state, + config: nextConfig, + job: null, + status: wsl.available + ? { kind: "ready" } + : { kind: "failed", step: "wsl", message: wsl.error ?? "WSL is unavailable" }, + checks: { + ...state.checks, + wsl, + }, + }) + } catch (error) { + if (jobAbort !== abort) return + if (error instanceof Error && error.name === "AbortError") { + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + return + } + update({ + ...state, + job: null, + status: { kind: "failed", step: "wsl", message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (jobAbort === abort) jobAbort = undefined + } + }, + async installDistro(name: string) { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + clearTranscript() + appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` }) + persistConfig({ + ...state.config, + mode: "wsl", + distro: name, + onboarding: { + ...state.config.onboarding, + step: "distro", + complete: false, + pendingRestart: false, + }, + }) + update({ + ...state, + job: { step: "distro", startedAt: Date.now() }, + status: { kind: "running", step: "distro" }, + }) + + try { + const result = await installWslDistro(name, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + if (result.code !== 0) throw new Error(commandFailure(result, `Failed to install distro: ${name}`)) + + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }), + listOnlineWslDistros({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }), + ]) + if (jobAbort !== abort) return + + const installed = installedResult.status === "fulfilled" ? installedResult.value : [] + const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + const selected = await probeWslDistro(name, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + + const error = distroError(name, installed, selected, installedResult, onlineResult) + const nextConfig = persistConfig({ + ...state.config, + mode: "wsl", + distro: name, + onboarding: { + ...state.config.onboarding, + step: error ? "distro" : "opencode", + complete: false, + pendingRestart: false, + }, + }) + update({ + ...state, + config: nextConfig, + job: null, + status: error ? { kind: "failed", step: "distro", message: error } : { kind: "ready" }, + checks: { + ...state.checks, + distro: { + installed, + online, + selected, + error, + }, + }, + }) + } catch (error) { + if (jobAbort !== abort) return + if (error instanceof Error && error.name === "AbortError") { + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + return + } + update({ + ...state, + job: null, + status: { kind: "failed", step: "distro", message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (jobAbort === abort) jobAbort = undefined + } + }, async openTerminal() { if (!state.config.distro) throw new Error("No WSL distro selected") await openWslTerminal(state.config.distro) @@ -299,3 +508,12 @@ function distroError( if (selected?.error) return selected.error return null } + +function commandFailure(result: { stdout: string; stderr: string }, fallback: string) { + const output = `${result.stderr}\n${result.stdout}` + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n") + return output || fallback +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 4ec38b9201..314f7bfd28 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -29,8 +29,20 @@ export function wslArgs(args: string[], distro?: string | null) { } export function runWsl(args: string[], opts: RunWslOptions = {}) { + return runCommand("wsl", args, opts) +} + +function runPowerShell(command: string, opts: RunWslOptions = {}) { + return runCommand( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command], + opts, + ) +} + +function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { return new Promise((resolve, reject) => { - const child = spawn("wsl", args, { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, signal: opts.signal, @@ -136,10 +148,23 @@ export async function installWslRuntime(opts?: RunWslOptions) { return runWsl(["--install", "--no-distribution"], opts) } +export async function installWslRuntimeElevated(opts?: RunWslOptions) { + const script = [ + "$ErrorActionPreference = 'Stop'", + "$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru", + "if ($null -ne $process.ExitCode) { exit $process.ExitCode }", + ].join("; ") + return runPowerShell(script, opts) +} + export async function installWslDistro(name: string, opts?: RunWslOptions) { return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) } +export function wslNeedsRestart(result: WslCommandResult) { + return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) +} + export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({ code: 1, diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 321d0fabeb..9b69a571e9 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -16,6 +16,8 @@ const api: ElectronAPI = { setConfig: (config) => ipcRenderer.invoke("local-server-set-config", config), runStep: (step) => ipcRenderer.invoke("local-server-run-step", step), cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"), + installWsl: () => ipcRenderer.invoke("local-server-install-wsl"), + installDistro: (name) => ipcRenderer.invoke("local-server-install-distro", name), openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"), subscribe: (cb) => { const handler = (_: unknown, event: LocalServerEvent) => cb(event) diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 81a9de3cb2..ec4de47023 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -92,6 +92,8 @@ export type LocalServerAPI = { setConfig: (config: LocalServerConfig) => Promise runStep: (step: LocalServerStep) => Promise cancelJob: () => Promise + installWsl: () => Promise + installDistro: (name: string) => Promise openTerminal: () => Promise subscribe: (cb: (event: LocalServerEvent) => void) => () => void }