diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index c9298e5546..53675dcebc 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -253,6 +253,8 @@ registerIpcHandlers({ }, getLocalServerState: () => localServer.getState(), setLocalServerConfig: (config) => localServer.setConfig(config), + runLocalServerStep: (step) => localServer.runStep(step), + cancelLocalServerJob: () => localServer.cancelJob(), onLocalServerEvent: (listener) => localServer.subscribe(listener), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 444fec5cbd..e8642bacfc 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -7,6 +7,7 @@ import type { LocalServerConfig, LocalServerEvent, LocalServerState, + LocalServerStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, @@ -24,6 +25,8 @@ type Deps = { awaitInitialization: (sendStep: (step: InitStep) => void) => Promise getLocalServerState: () => Promise | LocalServerState setLocalServerConfig: (config: LocalServerConfig) => Promise | void + runLocalServerStep: (step: LocalServerStep) => Promise | void + cancelLocalServerJob: () => Promise | void onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts index 843eb1deab..e16b881d7a 100644 --- a/packages/desktop-electron/src/main/local-server.ts +++ b/packages/desktop-electron/src/main/local-server.ts @@ -1,6 +1,13 @@ -import type { LocalServerConfig, LocalServerEvent, LocalServerState, LocalServerStep } from "../preload/types" +import type { + LocalServerConfig, + LocalServerDistroCheck, + LocalServerEvent, + LocalServerState, + LocalServerStep, +} from "../preload/types" import { LOCAL_SERVER_KEY } from "./constants" import { store } from "./store" +import { listInstalledWslDistros, listOnlineWslDistros, probeWslDistro, probeWslRuntime } from "./wsl" export function defaultLocalServerConfig(): LocalServerConfig { return { @@ -21,6 +28,7 @@ export function defaultLocalServerConfig(): LocalServerConfig { export function createLocalServerController() { let state = toState(readLocalServerConfig()) const listeners = new Set<(event: LocalServerEvent) => void>() + let jobAbort: AbortController | undefined const emit = (event: LocalServerEvent) => { for (const listener of listeners) listener(event) @@ -47,6 +55,101 @@ export function createLocalServerController() { listeners.add(listener) return () => listeners.delete(listener) }, + async runStep(step: LocalServerStep) { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + update({ + ...state, + job: { step, startedAt: Date.now() }, + status: { kind: "running", step }, + }) + + try { + if (step === "wsl") { + const wsl = await probeWslRuntime({ signal: abort.signal }) + if (jobAbort !== abort) return + update({ + ...state, + job: null, + status: wsl.available + ? { kind: "ready" } + : { kind: "failed", step, message: wsl.error ?? "WSL is unavailable" }, + checks: { + ...state.checks, + wsl, + }, + }) + return + } + + if (step === "distro") { + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros({ signal: abort.signal }), + listOnlineWslDistros({ signal: abort.signal }), + ]) + if (jobAbort !== abort) return + + const installed = installedResult.status === "fulfilled" ? installedResult.value : [] + const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + const selected = state.config.distro + ? await probeWslDistro(state.config.distro, { signal: abort.signal }) + : null + if (jobAbort !== abort) return + + const error = distroError(state.config.distro, installed, selected, installedResult, onlineResult) + const distro: LocalServerDistroCheck = { + installed, + online, + selected, + error, + } + + update({ + ...state, + job: null, + status: error ? { kind: "failed", step, message: error } : { kind: "ready" }, + checks: { + ...state.checks, + distro, + }, + }) + return + } + + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + } 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, message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (jobAbort === abort) jobAbort = undefined + } + }, + cancelJob() { + jobAbort?.abort() + jobAbort = undefined + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + }, setRuntime(runtime: LocalServerState["runtime"]) { update({ ...state, @@ -72,6 +175,7 @@ function toState(config: LocalServerConfig, current?: LocalServerState): LocalSe runtime: current?.runtime ?? windowsRuntime(), status: current?.status ?? { kind: "idle" }, job: current?.job ?? null, + checks: current?.checks ?? { wsl: null, distro: null }, } } @@ -140,3 +244,23 @@ function windowsRuntime(): LocalServerState["runtime"] { distro: null, } } + +function distroError( + configured: string | null, + installed: LocalServerDistroCheck["installed"], + selected: LocalServerDistroCheck["selected"], + installedResult: PromiseSettledResult, + onlineResult: PromiseSettledResult, +) { + if (installedResult.status === "rejected") { + return installedResult.reason instanceof Error ? installedResult.reason.message : String(installedResult.reason) + } + if (onlineResult.status === "rejected") { + return onlineResult.reason instanceof Error ? onlineResult.reason.message : String(onlineResult.reason) + } + if (configured && !installed.find((item) => item.name === configured)) { + return `Selected distro is not installed: ${configured}` + } + if (selected?.error) return selected.error + return null +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 3059e99a0e..e11918d188 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -1,4 +1,10 @@ import { spawn } from "node:child_process" +import type { + LocalServerDistroProbe, + LocalServerInstalledDistro, + LocalServerOnlineDistro, + LocalServerWslCheck, +} from "../preload/types" export type WslCommandLine = { stream: "stdout" | "stderr" @@ -12,35 +18,6 @@ export type WslCommandResult = { stderr: string } -export type WslRuntimeProbe = { - available: boolean - version: string | null - status: string | null - error: string | null -} - -export type WslInstalledDistro = { - name: string - state: string | null - version: number | null - isDefault: boolean -} - -export type WslOnlineDistro = { - name: string - label: string -} - -export type WslDistroProbe = { - name: string - canExecute: boolean - hasBash: boolean - hasCurl: boolean - username: string | null - isRoot: boolean | null - error: string | null -} - type RunWslOptions = { onLine?: (line: WslCommandLine) => void signal?: AbortSignal @@ -113,8 +90,8 @@ export function runWslBash(script: string, distro?: string | null, opts?: RunWsl return runWslInDistro(["bash", "-lc", script], distro, opts) } -export async function probeWslRuntime(): Promise { - const version = await runWsl(["--version"]).catch((error) => ({ +export async function probeWslRuntime(opts?: RunWslOptions): Promise { + const version = await runWsl(["--version"], opts).catch((error) => ({ code: 1, signal: null, stdout: "", @@ -130,7 +107,7 @@ export async function probeWslRuntime(): Promise { } } - const status = await runWsl(["--status"]).catch(() => undefined) + const status = await runWsl(["--status"], opts).catch(() => undefined) return { available: true, version: firstLine(version.stdout), @@ -139,16 +116,16 @@ export async function probeWslRuntime(): Promise { } } -export async function listInstalledWslDistros() { - const result = await runWsl(["--list", "--verbose"]) +export async function listInstalledWslDistros(opts?: RunWslOptions) { + const result = await runWsl(["--list", "--verbose"], opts) if (result.code !== 0) { throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros") } return parseInstalledDistros(result.stdout) } -export async function listOnlineWslDistros() { - const result = await runWsl(["--list", "--online"]) +export async function listOnlineWslDistros(opts?: RunWslOptions) { + const result = await runWsl(["--list", "--online"], opts) if (result.code !== 0) { throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros") } @@ -163,8 +140,8 @@ export async function installWslDistro(name: string, opts?: RunWslOptions) { return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) } -export async function probeWslDistro(name: string): Promise { - const executable = await runWslInDistro(["/bin/true"], name).catch((error) => ({ +export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { + const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({ code: 1, signal: null, stdout: "", @@ -183,9 +160,9 @@ export async function probeWslDistro(name: string): Promise { } const [bash, curl, user] = await Promise.all([ - runWslSh("command -v bash >/dev/null && printf yes || printf no", name), - runWslSh("command -v curl >/dev/null && printf yes || printf no", name), - runWslSh("id -un 2>/dev/null || true", name), + runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts), + runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts), + runWslSh("id -un 2>/dev/null || true", name, opts), ]) const username = summarize(user.stdout) @@ -228,7 +205,7 @@ function parseInstalledDistros(output: string) { state: state || null, version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10), isDefault: marker === "*", - } satisfies WslInstalledDistro, + } satisfies LocalServerInstalledDistro, ] }) } @@ -241,7 +218,7 @@ function parseOnlineDistros(output: string) { if (!match) return [] const [, name, label] = match if (/^name$/i.test(name)) return [] - return [{ name, label: label.trim() } satisfies WslOnlineDistro] + return [{ name, label: label.trim() } satisfies LocalServerOnlineDistro] }) } diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index d1d20efcce..c9dcc9a062 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -14,6 +14,8 @@ const api: ElectronAPI = { localServer: { getState: () => ipcRenderer.invoke("local-server-get-state"), 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"), subscribe: (cb) => { const handler = (_: unknown, event: LocalServerEvent) => cb(event) ipcRenderer.on("local-server-event", handler) diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 704125f34a..688fc6824d 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -14,6 +14,37 @@ export type LocalServerMismatchAcknowledgement = { path: string version: string } +export type LocalServerWslCheck = { + available: boolean + version: string | null + status: string | null + error: string | null +} +export type LocalServerInstalledDistro = { + name: string + state: string | null + version: number | null + isDefault: boolean +} +export type LocalServerOnlineDistro = { + name: string + label: string +} +export type LocalServerDistroProbe = { + name: string + canExecute: boolean + hasBash: boolean + hasCurl: boolean + username: string | null + isRoot: boolean | null + error: string | null +} +export type LocalServerDistroCheck = { + installed: LocalServerInstalledDistro[] + online: LocalServerOnlineDistro[] + selected: LocalServerDistroProbe | null + error: string | null +} export type LocalServerConfig = { mode: LocalServerMode distro: string | null @@ -41,6 +72,10 @@ export type LocalServerState = { } status: LocalServerStatus job: { step: LocalServerStep | null; startedAt: number } | null + checks: { + wsl: LocalServerWslCheck | null + distro: LocalServerDistroCheck | null + } } export type LocalServerEvent = { type: "state" @@ -49,6 +84,8 @@ export type LocalServerEvent = { export type LocalServerAPI = { getState: () => Promise setConfig: (config: LocalServerConfig) => Promise + runStep: (step: LocalServerStep) => Promise + cancelJob: () => Promise subscribe: (cb: (event: LocalServerEvent) => void) => () => void }