feat: add Electron local server probe actions

This commit is contained in:
LukeParkerDev
2026-04-16 14:27:00 +10:00
parent 4212931808
commit ec1a5c261e
6 changed files with 189 additions and 44 deletions

View File

@@ -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),

View File

@@ -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<ServerReadyData>
getLocalServerState: () => Promise<LocalServerState> | LocalServerState
setLocalServerConfig: (config: LocalServerConfig) => Promise<void> | void
runLocalServerStep: (step: LocalServerStep) => Promise<void> | void
cancelLocalServerJob: () => Promise<void> | void
onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void

View File

@@ -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<LocalServerDistroCheck["installed"]>,
onlineResult: PromiseSettledResult<LocalServerDistroCheck["online"]>,
) {
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
}

View File

@@ -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<WslRuntimeProbe> {
const version = await runWsl(["--version"]).catch((error) => ({
export async function probeWslRuntime(opts?: RunWslOptions): Promise<LocalServerWslCheck> {
const version = await runWsl(["--version"], opts).catch((error) => ({
code: 1,
signal: null,
stdout: "",
@@ -130,7 +107,7 @@ export async function probeWslRuntime(): Promise<WslRuntimeProbe> {
}
}
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<WslRuntimeProbe> {
}
}
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<WslDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name).catch((error) => ({
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<LocalServerDistroProbe> {
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<WslDistroProbe> {
}
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]
})
}

View File

@@ -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)

View File

@@ -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<LocalServerState>
setConfig: (config: LocalServerConfig) => Promise<void>
runStep: (step: LocalServerStep) => Promise<void>
cancelJob: () => Promise<void>
subscribe: (cb: (event: LocalServerEvent) => void) => () => void
}