From 12fa7821372ac14352e4ca3053e844dbe19bd4aa Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:56:28 +1000 Subject: [PATCH] refactor: treat WSL servers as additive alongside windows local Local Server is always Windows-native now; WSL lives as a separate list of one-or-more distro-bound sidecars spawned alongside it. Manage Servers shows an Add WSL button on Windows, each WSL server appears as its own row with remove + retry, and the wizard runs scoped to a new distro. --- packages/app/src/app.tsx | 10 +- .../src/components/dialog-select-server.tsx | 285 +++----- ...local-server.tsx => dialog-wsl-server.tsx} | 402 +++++------ packages/app/src/context/platform.tsx | 139 ++-- packages/app/src/context/server.tsx | 7 + packages/app/src/index.ts | 18 +- .../desktop-electron/src/main/constants.ts | 3 +- packages/desktop-electron/src/main/index.ts | 124 ++-- packages/desktop-electron/src/main/ipc.ts | 76 +- .../desktop-electron/src/main/local-server.ts | 678 ------------------ packages/desktop-electron/src/main/server.ts | 86 ++- .../desktop-electron/src/main/wsl-servers.ts | 483 +++++++++++++ packages/desktop-electron/src/main/wsl.ts | 15 +- .../desktop-electron/src/preload/index.ts | 33 +- .../desktop-electron/src/preload/types.ts | 136 ++-- .../desktop-electron/src/renderer/env.d.ts | 2 +- .../desktop-electron/src/renderer/index.tsx | 109 +-- 17 files changed, 1165 insertions(+), 1441 deletions(-) rename packages/app/src/components/{dialog-local-server.tsx => dialog-wsl-server.tsx} (58%) delete mode 100644 packages/desktop-electron/src/main/local-server.ts create mode 100644 packages/desktop-electron/src/main/wsl-servers.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e0f8a1481..362ac271e 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -76,7 +76,7 @@ declare global { __OPENCODE__?: { updaterEnabled?: boolean deepLinks?: string[] - wsl?: boolean + activeServer?: string } api?: { setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise @@ -234,7 +234,7 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: const name = createMemo(() => server.name || server.key) const serverToken = "\u0000server\u0000" const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) - const canOpenLocalServer = createMemo(() => !!platform.localServer && server.current?.type === "sidecar") + const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl") const timer = setInterval(() => props.onRetry?.(), 1000) onCleanup(() => clearInterval(timer)) @@ -249,18 +249,18 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: {unreachable()[1]}

{language.t("app.server.retrying")}

- + diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 71b53bee1..01cb280e5 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -5,24 +5,22 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { List } from "@opencode-ai/ui/list" -import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" -import { DialogLocalServer } from "@/components/dialog-local-server" +import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" -import { type LocalServerConfig, usePlatform } from "@/context/platform" +import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" const DEFAULT_USERNAME = "opencode" interface DialogSelectServerProps { - initialView?: "list" | "local" - initialTargetMode?: "windows" | "wsl" + initialView?: "list" | "add-wsl" onNavigateHome?: () => void } @@ -197,10 +195,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { showForm: false, status: undefined as boolean | undefined, }, - localServer: { - showPage: props.initialView === "local", - targetMode: props.initialTargetMode as "windows" | "wsl" | undefined, - confirmSwapKey: undefined as ServerConnection.Key | undefined, + addWsl: { + showWizard: props.initialView === "add-wsl", }, editServer: { id: undefined as string | undefined, @@ -430,8 +426,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { ) } - const mode = createMemo<"list" | "local" | "add" | "edit">(() => { - if (store.localServer.showPage) return "local" + const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => { + if (store.addWsl.showWizard) return "add-wsl" if (store.editServer.id) return "edit" if (store.addServer.showForm) return "add" return "list" @@ -445,12 +441,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const resetForm = () => { resetAdd() resetEdit() - setStore("localServer", "showPage", false) - setStore("localServer", "targetMode", undefined) + setStore("addWsl", "showWizard", false) } const startAdd = () => { - setStore("localServer", "showPage", false) + setStore("addWsl", "showWizard", false) resetEdit() setStore("addServer", { showForm: true, @@ -464,7 +459,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } const startEdit = (conn: ServerConnection.Http) => { - setStore("localServer", "showPage", false) + setStore("addWsl", "showWizard", false) resetAdd() setStore("editServer", { id: conn.http.url, @@ -477,65 +472,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) } - const startLocal = (targetMode?: "windows" | "wsl") => { + const startAddWsl = () => { resetAdd() resetEdit() - setStore("localServer", "showPage", true) - setStore("localServer", "targetMode", targetMode) - setStore("localServer", "confirmSwapKey", undefined) - } - - const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(config) - - const swapLocalToWindows = async () => { - const localServer = platform.localServer - if (!localServer) return - - try { - const state = await localServer.getState() - const config = plainConfig(state.config) - await localServer.setConfig({ - ...config, - mode: "windows", - distro: null, - onboarding: { - ...config.onboarding, - complete: true, - pendingRestart: false, - step: null, - }, - }) - setStore("localServer", "confirmSwapKey", undefined) - showToast({ - variant: "success", - title: "Local Server set to Windows", - description: "Restart OpenCode to finish switching back to Windows.", - persistent: true, - actions: [ - { - label: "Restart", - onClick: () => void platform.restart(), - }, - ], - }) - } catch (err) { - console.error("Local Server request failed", err instanceof Error ? (err.stack ?? err.message) : String(err)) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } - } - - const localSwapLabel = (conn: ServerConnection.Any) => { - if (conn.type !== "sidecar") return "" - return conn.variant === "wsl" ? "Swap to Windows" : "Swap to WSL" - } - - const localSwapTarget = (conn: ServerConnection.Any) => { - if (conn.type !== "sidecar") return undefined - return conn.variant === "wsl" ? "windows" : "wsl" + setStore("addWsl", "showWizard", true) } const submitForm = () => { @@ -554,8 +494,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const isFormMode = createMemo(() => mode() !== "list") const isAddMode = createMemo(() => mode() === "add") - const isLocalMode = createMemo(() => mode() === "local") + const isAddWslMode = createMemo(() => mode() === "add-wsl") const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) + const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows") const formTitle = createMemo(() => { if (!isFormMode()) return language.t("dialog.server.title") @@ -563,8 +504,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
- {isLocalMode() - ? "Local Server" + {isAddWslMode() + ? "Add WSL server" : isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} @@ -579,21 +520,44 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { resetEdit() }) - async function handleRemove(url: ServerConnection.Key) { - server.remove(url) - if ((await platform.getDefaultServer?.()) === url) { + async function handleRemove(key: ServerConnection.Key) { + server.remove(key) + if ((await platform.getDefaultServer?.()) === key) { void platform.setDefaultServer?.(null) } } + async function handleRemoveWsl(conn: ServerConnection.Any) { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return + const key = ServerConnection.key(conn) + try { + await platform.wslServers?.removeServer(key) + server.remove(key) + if ((await platform.getDefaultServer?.()) === key) { + void platform.setDefaultServer?.(null) + } + } catch (err) { + showRequestError(language, err) + } + } + + async function handleRetryWsl(conn: ServerConnection.Any) { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return + try { + await platform.wslServers?.startServer(ServerConnection.key(conn)) + } catch (err) { + showRequestError(language, err) + } + } + return ( - +
} > - + } > @@ -625,7 +589,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { noInitialSelection emptyMessage={language.t("dialog.server.empty")} items={sortedItems} - key={(x) => x.http.url} + key={(x) => ServerConnection.key(x)} onSelect={(x) => { if (x) void select(x) }} @@ -634,6 +598,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { > {(i) => { const key = ServerConnection.key(i) + const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" return (
@@ -654,85 +619,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { showCredentials />
- - {(() => { - const sidecar = i as ServerConnection.Sidecar - if (sidecar.variant !== "wsl") { - return ( - - ) - } - - return ( - - setStore( - "localServer", - "confirmSwapKey", - open ? ServerConnection.key(sidecar) : undefined, - ) - } - triggerAs={Button} - triggerProps={{ - variant: "secondary", - size: "small", - class: "shrink-0", - onClick: (event: MouseEvent) => event.stopPropagation(), - onPointerDown: (event: PointerEvent) => event.stopPropagation(), - }} - trigger={localSwapLabel(sidecar)} - placement="bottom-end" - portal={false} - > -
-
Use Windows instead?
-
- Restart OpenCode after switching the Local Server back to Windows. -
-
- - -
-
-
- ) - })()} -
- - + - { - if (i.type !== "http") return - startEdit(i) - }} - > - {language.t("dialog.server.menu.edit")} - - + + { + if (i.type !== "http") return + startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + + void handleRetryWsl(i)}> + Retry start + + + setDefault(key)}> {language.t("dialog.server.menu.default")} - + setDefault(null)}> {language.t("dialog.server.menu.defaultRemove")} - - handleRemove(ServerConnection.key(i))} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - {language.t("dialog.server.menu.delete")} - + + + (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + + {language.t("dialog.server.menu.delete")} + + + @@ -786,18 +688,31 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
- + +
+ + + + +
} > diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx similarity index 58% rename from packages/app/src/components/dialog-local-server.tsx rename to packages/app/src/components/dialog-wsl-server.tsx index 9c8c9b0a6..49ac18023 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -1,29 +1,37 @@ import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" -import { createStore, reconcile, unwrap } from "solid-js/store" +import { createStore, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" -import type { LocalServerConfig, LocalServerMode, LocalServerState, LocalServerStep } from "@/context/platform" +import type { WslServerStep, WslServersState } from "@/context/platform" import { usePlatform } from "@/context/platform" -const WSL_STEPS: LocalServerStep[] = ["wsl", "distro", "opencode", "switch"] +const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"] -export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { +interface DialogWslServerProps { + onAdded?: () => void +} + +export function DialogWslServer(props: DialogWslServerProps = {}) { const language = useLanguage() const platform = usePlatform() + const dialog = useDialog() const [store, setStore] = createStore({ - state: undefined as LocalServerState | undefined, + state: undefined as WslServersState | undefined, loading: true, - step: undefined as LocalServerStep | undefined, + step: undefined as WslServerStep | undefined, + selectedDistro: null as string | null, installTarget: undefined as string | undefined, + adding: false, }) createEffect(() => { - const localServer = platform.localServer - if (!localServer) return + const wslServers = platform.wslServers + if (!wslServers) return let mounted = true - void localServer + void wslServers .getState() .then((state) => { if (!mounted) return @@ -34,7 +42,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { requestError(language, err) setStore("loading", false) }) - const off = localServer.subscribe((event) => { + const off = wslServers.subscribe((event) => { setStore("state", reconcile(event.state)) setStore("loading", false) }) @@ -45,25 +53,24 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { }) const current = () => store.state - const localServer = () => platform.localServer - const targetMode = createMemo<"windows" | "wsl">( - () => props.targetMode ?? (current()?.config.mode === "wsl" ? "wsl" : "wsl"), - ) - const configuredDistro = createMemo(() => current()?.config.distro ?? null) - const busy = createMemo(() => !!current()?.job) + const wslServers = () => platform.wslServers + const busy = createMemo(() => !!current()?.job || store.adding) + const selectedDistro = () => store.selectedDistro const selectedProbe = createMemo(() => { - const probe = current()?.checks.distro?.selected - return probe?.name === configuredDistro() ? probe : null + const distro = selectedDistro() + if (!distro) return null + return current()?.distroProbes[distro] ?? null }) - const selectedInstalled = createMemo(() => - (current()?.checks.distro?.installed ?? []).find((item) => item.name === current()?.config.distro), - ) - const defaultInstalledDistro = createMemo( - () => (current()?.checks.distro?.installed ?? []).find((item) => item.isDefault) ?? null, - ) + const selectedInstalled = createMemo(() => { + const distro = selectedDistro() + if (!distro) return null + return (current()?.installed ?? []).find((item) => item.name === distro) ?? null + }) + const defaultInstalledDistro = createMemo(() => (current()?.installed ?? []).find((item) => item.isDefault) ?? null) const opencodeCheck = createMemo(() => { - const check = current()?.checks.opencode - return check?.distro === configuredDistro() ? check : null + const distro = selectedDistro() + if (!distro) return null + return current()?.opencodeChecks[distro] ?? null }) const distroWarningProbe = createMemo(() => { const probe = selectedProbe() @@ -73,7 +80,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { }) const distroUnavailableMessage = createMemo(() => { const probe = distroWarningProbe() - const distro = configuredDistro() + const distro = selectedDistro() if (!probe || probe.canExecute || !distro) return null if (!selectedInstalled()) return `${distro} is not installed yet.` return `Open ${distro} once to finish setup.` @@ -88,36 +95,23 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const check = opencodeCheck() return check?.matchesDesktop === false ? check : null }) + const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro))) + const addableInstalledDistros = createMemo(() => { + return (current()?.installed ?? []).filter((item) => !existingServerDistros().has(item.name)) + }) const installableDistros = createMemo(() => { - const online = current()?.checks.distro?.online ?? [] - const installed = new Set((current()?.checks.distro?.installed ?? []).map((item) => item.name)) + const online = current()?.online ?? [] + const installed = new Set((current()?.installed ?? []).map((item) => item.name)) const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name)) return online .filter((item) => !installed.has(item.name)) .filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu)) }) const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null) - const configuredRuntime = createMemo(() => { - const state = current() - if (!state) return { mode: "windows" as const, distro: null as string | null } - if (state.config.mode === "wsl" && state.config.distro) { - return { mode: "wsl" as const, distro: state.config.distro } - } - return { mode: "windows" as const, distro: null as string | null } - }) - const configuredRuntimeLabel = createMemo(() => runtimeLabel(configuredRuntime().mode, configuredRuntime().distro)) - const currentRuntimeLabel = createMemo(() => - runtimeLabel(current()?.runtime.mode ?? "windows", current()?.runtime.distro ?? null), - ) - const needsRestart = createMemo(() => { - const state = current() - if (!state) return false - return state.runtime.mode !== configuredRuntime().mode || state.runtime.distro !== configuredRuntime().distro - }) - const wslReady = createMemo(() => !!current()?.checks.wsl?.available && !current()?.config.onboarding.pendingRestart) + const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart) const distroReady = createMemo(() => { const probe = selectedProbe() - if (!probe || !current()?.config.distro) return false + if (!probe || !selectedDistro()) return false if (selectedInstalled()?.version === 1) return false return probe.canExecute && probe.hasBash && probe.hasCurl }) @@ -125,15 +119,13 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const check = opencodeCheck() return !!check?.resolvedPath && !check.error }) - const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) - const recommendedStep = createMemo(() => { - if (targetMode() === "windows") return "switch" + const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) + const recommendedStep = createMemo(() => { if (!wslReady()) return "wsl" if (!distroReady()) return "distro" - if (!opencodeReady()) return "opencode" - return "switch" + return "opencode" }) - const activeStep = createMemo(() => current()?.job?.step ?? store.step ?? recommendedStep()) + const activeStep = createMemo(() => store.step ?? recommendedStep()) createEffect( on(recommendedStep, (next) => { @@ -143,17 +135,20 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const autoProbe = createMemo(() => { const state = current() - if (!state || !localServer() || busy() || targetMode() === "windows") return null - if (state.config.onboarding.pendingRestart) return null - if (!state.checks.wsl) return { key: "wsl", step: "wsl" as const } + if (!state || !wslServers() || busy()) return null + if (state.pendingRestart) return null + if (!state.runtime) return { key: "runtime", run: () => wslServers()!.probeRuntime() } if (!wslReady()) return null - if (!state.checks.distro) return { key: "distro:list", step: "distro" as const } - if (state.config.distro && !selectedProbe()) { - return { key: `distro:${state.config.distro}`, step: "distro" as const } + if (!state.installed.length && !state.online.length) { + return { key: "distros", run: () => wslServers()!.refreshDistros() } } - if (!state.config.distro || !distroReady()) return null - if (!opencodeCheck()) { - return { key: `opencode:${state.config.distro}`, step: "opencode" as const } + const distro = selectedDistro() + if (distro && !state.distroProbes[distro]) { + return { key: `probe-distro:${distro}`, run: () => wslServers()!.probeDistro(distro) } + } + if (!distro || !distroReady()) return null + if (!state.opencodeChecks[distro]) { + return { key: `probe-opencode:${distro}`, run: () => wslServers()!.probeOpencode(distro) } } return null }) @@ -163,15 +158,16 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const probe = autoProbe() if (!probe || probe.key === lastAutoProbe) return lastAutoProbe = probe.key - void run(() => localServer()!.runStep(probe.step)) + void run(probe.run) }) createEffect(() => { const state = current() const distro = defaultInstalledDistro() - if (!state || !distro || !localServer() || busy() || targetMode() !== "wsl") return - if (state.config.distro) return - void selectDistro(distro.name) + if (!state || !distro || busy()) return + if (selectedDistro()) return + if (existingServerDistros().has(distro.name)) return + setStore("selectedDistro", distro.name) }) createEffect(() => { @@ -186,43 +182,43 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const wslMessage = createMemo(() => { const state = current() - if (!state || state.job?.step === "wsl") return "Checking WSL..." - if (state.config.onboarding.pendingRestart) return "Windows needs a restart to finish installing WSL." - if (state.checks.wsl?.available) return state.checks.wsl.version ?? "WSL is ready." - return state.checks.wsl?.error ?? "WSL is required to continue." + if (!state || state.job?.kind === "runtime") return "Checking WSL..." + if (state.pendingRestart) return "Windows needs a restart to finish installing WSL." + if (state.runtime?.available) return state.runtime.version ?? "WSL is ready." + return state.runtime?.error ?? "WSL is required to continue." }) const distroMessage = createMemo(() => { const state = current() if (!state) return "Checking distros..." - if (state.job?.step === "distro") { - if (state.config.distro && !selectedInstalled()) return `Installing ${state.config.distro}...` - return state.config.distro ? `Checking ${state.config.distro}...` : "Checking distros..." - } + const distro = selectedDistro() + if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...` + if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...` + if (state.job?.kind === "distros") return "Listing distros..." if (distroUnavailableMessage()) return distroUnavailableMessage()! - if (state.checks.distro?.error && !selectedProbe()) return state.checks.distro.error if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.` - if (state.config.distro) return `Finishing setup for ${state.config.distro}.` + if (distro) return `Finishing setup for ${distro}.` return "Pick a distro or install one below." }) const opencodeMessage = createMemo(() => { const state = current() if (!state) return "Checking OpenCode..." - if (state.job?.step === "opencode") { - return state.config.distro ? `Checking OpenCode in ${state.config.distro}...` : "Checking OpenCode..." + const distro = selectedDistro() + if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") { + return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..." } if (opencodeCheck()?.error) return opencodeCheck()!.error if (opencodeCheck()?.matchesDesktop === false) { - return state.config.distro ? `Update OpenCode in ${state.config.distro}.` : "Update OpenCode." + return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode." } - if (opencodeReady()) - return state.config.distro ? `OpenCode is ready in ${state.config.distro}.` : "OpenCode is ready." - return state.config.distro ? `Install OpenCode in ${state.config.distro}.` : "Choose a distro first." + if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready." + return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first." }) + const installProgress = createMemo(() => { const state = current() - if (!state?.job || state.status.kind !== "running") return null + if (!state?.job) return null const transcript = state.transcript.filter((line) => line.text.trim()) const title = transcript[0]?.text if (!title?.startsWith("Installing ")) return null @@ -240,48 +236,30 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { } } - const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(unwrap(config)) - - const selectDistro = async (name: string) => { - const state = current() - if (!state || !localServer()) return - const config = plainConfig(state.config) + const selectDistro = (name: string) => { + setStore("selectedDistro", name) setStore("step", "distro") - await run(() => - localServer()!.setConfig({ - ...config, - mode: "wsl", - distro: name, - onboarding: { - ...config.onboarding, - complete: false, - step: "distro", - }, - }), - ) } - const swapToWindows = async () => { - const state = current() - if (!state || !localServer()) return - const config = plainConfig(state.config) - await run(() => - localServer()!.setConfig({ - ...config, - mode: "windows", - distro: null, - onboarding: { - ...config.onboarding, - complete: true, - pendingRestart: false, - step: null, - }, - }), - ) + const finish = async () => { + const distro = selectedDistro() + if (!distro) return + const api = wslServers() + if (!api) return + setStore("adding", true) + try { + await api.addServer(distro) + props.onAdded?.() + dialog.close() + } catch (err) { + requestError(language, err) + } finally { + setStore("adding", false) + } } const steps = createMemo(() => - WSL_STEPS.filter((step) => targetMode() === "wsl" || step === "switch").map((step) => ({ + STEPS.map((step) => ({ step, title: stepTitle(step), state: stepState(step, { @@ -290,8 +268,6 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { distroReady: distroReady(), opencodeReady: opencodeReady(), opencodeMismatch: opencodeCheck()?.matchesDesktop === false, - switchReady: switchReady(), - needsRestart: needsRestart(), }), locked: stepIndex(step) > stepIndex(recommendedStep()), })), @@ -299,51 +275,46 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { return (
- Loading local server...
} - > - -
- - {(item) => ( - - )} - -
-
+ Loading...
}> +
+ + {(item) => ( + + )} + +
WSL
- +
{wslMessage()}
- +
Windows restart required.
@@ -472,7 +447,11 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { variant="secondary" size="large" disabled={busy() || !selectedInstalled()} - onClick={() => void run(() => localServer()!.openTerminal())} + onClick={() => { + const distro = selectedDistro() + if (!distro) return + void run(() => wslServers()!.openTerminal(distro)) + }} > Open terminal @@ -488,7 +467,11 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { variant="secondary" size="large" disabled={busy()} - onClick={() => void run(() => localServer()!.installOpencode())} + onClick={() => { + const distro = selectedDistro() + if (!distro) return + void run(() => wslServers()!.installOpencode(distro)) + }} > {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"} @@ -513,51 +496,6 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
- - -
-
-
Switch
-
- - - - -
-
- -
- {targetMode() === "windows" - ? needsRestart() - ? "Restart OpenCode to switch back to Windows." - : "Windows Local Server is active." - : needsRestart() - ? `Restart OpenCode to start using ${configuredRuntimeLabel()}.` - : `${configuredRuntimeLabel()} is active.`} -
- -
-
- After restart: {configuredRuntimeLabel()} -
-
- Using now: {currentRuntimeLabel()} -
- -
Complete the earlier steps first.
-
-
-
-
@@ -595,7 +533,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { )} - 0}> + 0}>
Diagnostics
@@ -603,13 +541,27 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
+ +
+ + +
) } function requestError(language: ReturnType, err: unknown) { - console.error("Local Server request failed", err instanceof Error ? (err.stack ?? err.message) : String(err)) + console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err)) showToast({ variant: "error", title: language.t("common.requestFailed"), @@ -617,47 +569,35 @@ function requestError(language: ReturnType, err: unknown) { }) } -function stepIndex(step: LocalServerStep) { - return WSL_STEPS.indexOf(step) +function stepIndex(step: WslServerStep) { + return STEPS.indexOf(step) } -function stepTitle(step: LocalServerStep) { +function stepTitle(step: WslServerStep) { if (step === "wsl") return "WSL" if (step === "distro") return "Choose distro" - if (step === "opencode") return "OpenCode" - return "Switch" -} - -function runtimeLabel(mode: LocalServerMode, distro: string | null) { - if (mode === "windows") return "Windows" - return distro ? `WSL on ${distro}` : "WSL" + return "OpenCode" } function stepState( - step: LocalServerStep, + step: WslServerStep, state: { - active: LocalServerStep + active: WslServerStep wslReady: boolean distroReady: boolean opencodeReady: boolean opencodeMismatch: boolean - switchReady: boolean - needsRestart: boolean }, ) { if (state.active === step) return "current" if (step === "wsl") return state.wslReady ? "done" : "warning" if (step === "distro") return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" - if (step === "opencode") - return state.opencodeMismatch - ? "warning" - : state.opencodeReady - ? "done" - : stepIndex(step) > stepIndex(state.active) - ? "locked" - : "warning" - if (state.switchReady && !state.needsRestart) return "done" - if (stepIndex(step) > stepIndex(state.active)) return "locked" - return "warning" + return state.opencodeMismatch + ? "warning" + : state.opencodeReady + ? "done" + : stepIndex(step) > stepIndex(state.active) + ? "locked" + : "warning" } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 0c78e65eb..75e04a4a5 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -9,29 +9,25 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } -export type LocalServerMode = "windows" | "wsl" -export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch" -export type LocalServerMismatchAcknowledgement = { - path: string - version: string -} -export type LocalServerWslCheck = { +export type WslServerStep = "wsl" | "distro" | "opencode" + +export type WslRuntimeCheck = { available: boolean version: string | null status: string | null error: string | null } -export type LocalServerInstalledDistro = { +export type WslInstalledDistro = { name: string state: string | null version: number | null isDefault: boolean } -export type LocalServerOnlineDistro = { +export type WslOnlineDistro = { name: string label: string } -export type LocalServerDistroProbe = { +export type WslDistroProbe = { name: string canExecute: boolean hasBash: boolean @@ -40,73 +36,82 @@ export type LocalServerDistroProbe = { isRoot: boolean | null error: string | null } -export type LocalServerDistroCheck = { - installed: LocalServerInstalledDistro[] - online: LocalServerOnlineDistro[] - selected: LocalServerDistroProbe | null - error: string | null -} -export type LocalServerOpencodeCheck = { - distro: string | null +export type WslOpencodeCheck = { + distro: string resolvedPath: string | null version: string | null expectedVersion: string | null matchesDesktop: boolean | null error: string | null } -export type LocalServerTranscriptLine = { +export type WslTranscriptLine = { stream: "stdout" | "stderr" | "system" text: string at: number } -export type LocalServerConfig = { - mode: LocalServerMode - distro: string | null - onboarding: { - step: LocalServerStep | null - complete: boolean - pendingRestart: boolean - } - acknowledgements: { - root: string[] - mismatch: LocalServerMismatchAcknowledgement[] - } + +export type WslServerAcknowledgements = { + root: boolean + mismatch: { path: string; version: string } | null } -export type LocalServerStatus = - | { kind: "idle" } - | { kind: "ready" } - | { kind: "running"; step: LocalServerStep | null } - | { kind: "failed"; step: LocalServerStep | null; message: string } -export type LocalServerState = { - config: LocalServerConfig - runtime: { - key: string - mode: LocalServerMode - distro: string | null - } - status: LocalServerStatus - job: { step: LocalServerStep | null; startedAt: number } | null - checks: { - wsl: LocalServerWslCheck | null - distro: LocalServerDistroCheck | null - opencode: LocalServerOpencodeCheck | null - } - transcript: LocalServerTranscriptLine[] + +export type WslServerConfig = { + id: string + distro: string + acknowledgements: WslServerAcknowledgements } -export type LocalServerEvent = { - type: "state" - state: LocalServerState + +export type WslServerRuntime = + | { kind: "starting" } + | { kind: "ready"; url: string; username: string | null; password: string | null } + | { kind: "failed"; message: string } + | { kind: "stopped" } + +export type WslServerItem = { + config: WslServerConfig + runtime: WslServerRuntime } -export type LocalServerPlatform = { - getState(): Promise - setConfig(config: LocalServerConfig): Promise - runStep(step: LocalServerStep): Promise - cancelJob(): Promise + +export type WslJob = + | { kind: "runtime"; startedAt: number } + | { kind: "distros"; startedAt: number } + | { kind: "install-wsl"; startedAt: number } + | { kind: "install-distro"; distro: string; startedAt: number } + | { kind: "probe-distro"; distro: string; startedAt: number } + | { kind: "probe-opencode"; distro: string; startedAt: number } + | { kind: "install-opencode"; distro: string; startedAt: number } + +export type WslServersState = { + runtime: WslRuntimeCheck | null + installed: WslInstalledDistro[] + online: WslOnlineDistro[] + distroProbes: Record + opencodeChecks: Record + pendingRestart: boolean + servers: WslServerItem[] + job: WslJob | null + transcript: WslTranscriptLine[] + lastError: string | null +} +export type WslServersEvent = { type: "state"; state: WslServersState } + +export type WslServersPlatform = { + getState(): Promise + subscribe(cb: (event: WslServersEvent) => void): () => void + probeRuntime(): Promise + refreshDistros(): Promise installWsl(): Promise installDistro(name: string): Promise - installOpencode(): Promise - openTerminal(): Promise - subscribe(cb: (event: LocalServerEvent) => void): () => void + probeDistro(name: string): Promise + probeOpencode(name: string): Promise + installOpencode(name: string): Promise + openTerminal(name: string): Promise + addServer(distro: string): Promise + removeServer(id: string): Promise + startServer(id: string): Promise + stopServer(id: string): Promise + cancelJob(): Promise + updateAcknowledgements(id: string, acks: Partial): Promise } export type Platform = { @@ -164,14 +169,8 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServer?(url: ServerConnection.Key | null): Promise | void - /** Manage the desktop Local Server lifecycle (desktop only) */ - localServer?: LocalServerPlatform - - /** Get the configured WSL integration (desktop only) */ - getWslEnabled?(): Promise - - /** Set the configured WSL integration (desktop only) */ - setWslEnabled?(config: boolean): Promise | void + /** Manage WSL sidecar servers (Electron on Windows only) */ + wslServers?: WslServersPlatform /** Get the preferred display backend (desktop only) */ getDisplayBackend?(): Promise | DisplayBackend | null diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 5c86c9f4b..f68031548 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -214,6 +214,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( onCleanup(startHealthPolling(current_)) }) + createEffect(() => { + const key = state.active + if (typeof window === "undefined") return + window.__OPENCODE__ ??= {} + window.__OPENCODE__.activeServer = key + }) + const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const current: Accessor = createMemo( diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 969732dda..4173cf9ca 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,19 +1,21 @@ export { AppBaseProviders, AppInterface } from "./app" -export { DialogLocalServer } from "./components/dialog-local-server" +export { DialogWslServer } from "./components/dialog-wsl-server" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { type DisplayBackend, - type LocalServerConfig, - type LocalServerEvent, - type LocalServerMode, - type LocalServerOpencodeCheck, - type LocalServerPlatform, - type LocalServerState, - type LocalServerStep, type Platform, PlatformProvider, + type WslInstalledDistro, + type WslOnlineDistro, + type WslOpencodeCheck, + type WslServerConfig, + type WslServerItem, + type WslServersEvent, + type WslServersPlatform, + type WslServersState, + type WslServerStep, } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts index 4619a336a..9a6bb53c6 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -6,5 +6,6 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod export const SETTINGS_STORE = "opencode.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" -export const LOCAL_SERVER_KEY = "localServer" +export const WSL_SERVERS_KEY = "wslServers" +export const LEGACY_LOCAL_SERVER_KEY = "localServer" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 5d09a8f64..84720b303 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto" import { EventEmitter } from "node:events" import { existsSync } from "node:fs" -import { createServer } from "node:net" import { homedir } from "node:os" import { join } from "node:path" import type { Event } from "electron" @@ -36,14 +35,14 @@ const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" -import { CHANNEL, LOCAL_SERVER_KEY, UPDATER_ENABLED } from "./constants" +import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" -import { createLocalServerController } from "./local-server" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslLocalServer } from "./server" +import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server" import { store } from "./store" +import { createWslServersController } from "./wsl-servers" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" const initEmitter = new EventEmitter() @@ -57,7 +56,13 @@ const pendingDeepLinks: string[] = [] const serverReady = defer() void serverReady.promise.catch(() => undefined) -const localServer = createLocalServerController(app.getVersion()) +const wslServers = createWslServersController(app.getVersion(), async (distro) => { + const logger = initLogging() + logger.log("spawning wsl sidecar", { distro }) + return spawnWslSidecar(distro, { + onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), + }) +}) const logger = initLogging() logger.log("app starting", { @@ -67,8 +72,8 @@ logger.log("app starting", { logger.log("config paths", { userData: app.getPath("userData"), settingsStore: store.path, - localServerKey: LOCAL_SERVER_KEY, - localServer: store.get(LOCAL_SERVER_KEY) ?? null, + wslServersKey: WSL_SERVERS_KEY, + wslServers: store.get(WSL_SERVERS_KEY) ?? null, }) setupApp() @@ -107,15 +112,18 @@ function setupApp() { app.on("before-quit", () => { killSidecar() + wslServers.stopAll() }) app.on("will-quit", () => { killSidecar() + wslServers.stopAll() }) for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { killSidecar() + wslServers.stopAll() app.exit(0) }) } @@ -151,57 +159,39 @@ async function initialize() { const sqliteDone = needsMigration ? defer() : undefined let overlay: BrowserWindow | null = null - const port = await getSidecarPort() + const port = await allocatePort() const hostname = "127.0.0.1" const url = `http://${hostname}:${port}` const password = randomUUID() - const config = localServer.getState().config - const runtime = - config.mode === "wsl" && config.distro - ? { - key: `local:wsl:${config.distro}`, - mode: "wsl" as const, - distro: config.distro, - } - : { - key: "local:windows", - mode: "windows" as const, - distro: null, - } + const key = "local:windows" - logger.log("spawning sidecar", { url }) - localServer.setRuntime(runtime) - localServer.setStatus({ kind: "running", step: null }) - const startupData = { + logger.log("spawning windows sidecar", { url }) + const startupData: ServerReadyData = { url, username: "opencode", password, - local: runtime, + local: { + key, + url, + username: "opencode", + password, + }, } let startupError: Error | null = null const startup = await (async () => { try { - if (runtime.mode === "wsl") { - if (!runtime.distro) throw new Error("No WSL distro selected") - return await spawnWslLocalServer(runtime.distro, port, password, { - onLine: (line) => - logger.log("wsl sidecar startup", { distro: runtime.distro, stream: line.stream, text: line.text }), - }) - } return await spawnLocalServer(hostname, port, password) } catch (error) { startupError = asError(error) - localServer.setStatus({ - kind: "failed", - step: null, - message: startupError.message, - }) - logger.error("local server startup failed", startupError) + logger.error("windows sidecar startup failed", startupError) return undefined } })() server = startup?.listener ?? null + // Initialize WSL sidecars in parallel; failures do not block app startup. + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error))) + const loadingTask = (async () => { logger.log("sidecar connection started", { url }) @@ -224,16 +214,10 @@ async function initialize() { }), ]) .then(() => { - localServer.setStatus({ kind: "ready" }) serverReady.resolve(startupData) }) .catch((error) => { startupError = asError(error) - localServer.setStatus({ - kind: "failed", - step: null, - message: startupError.message, - }) logger.error("sidecar health check failed", startupError) serverReady.reject(startupError) }) @@ -321,6 +305,7 @@ function wireMenu() { reload: () => mainWindow?.reload(), relaunch: () => { killSidecar() + wslServers.stopAll() app.relaunch() app.exit(0) }, @@ -342,15 +327,22 @@ registerIpcHandlers({ initEmitter.off("step", listener) } }, - getLocalServerState: () => localServer.getState(), - setLocalServerConfig: (config) => localServer.setConfig(config), - runLocalServerStep: (step) => localServer.runStep(step), - cancelLocalServerJob: () => localServer.cancelJob(), - installLocalServerWsl: () => localServer.installWsl(), - installLocalServerDistro: (name) => localServer.installDistro(name), - installLocalServerOpencode: () => localServer.installOpencode(), - openLocalServerTerminal: () => localServer.openTerminal(), - onLocalServerEvent: (listener) => localServer.subscribe(listener), + getWslServersState: () => wslServers.getState(), + onWslServersEvent: (listener) => wslServers.subscribe(listener), + wslServersProbeRuntime: () => wslServers.probeRuntime(), + wslServersRefreshDistros: () => wslServers.refreshDistros(), + wslServersInstallWsl: () => wslServers.installWsl(), + wslServersInstallDistro: (name) => wslServers.installDistro(name), + wslServersProbeDistro: (name) => wslServers.probeDistro(name), + wslServersProbeOpencode: (name) => wslServers.probeOpencode(name), + wslServersInstallOpencode: (name) => wslServers.installOpencode(name), + wslServersOpenTerminal: (name) => wslServers.openTerminal(name), + wslServersAddServer: (distro) => wslServers.addServer(distro), + wslServersRemoveServer: (id) => wslServers.removeServer(id), + wslServersStartServer: (id) => wslServers.startServer(id), + wslServersStopServer: (id) => wslServers.stopServer(id), + wslServersCancelJob: () => wslServers.cancelJob(), + wslServersUpdateAcknowledgements: (id, acks) => wslServers.updateAcknowledgements(id, acks), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), getDisplayBackend: async () => null, @@ -392,29 +384,6 @@ function ensureLoopbackNoProxy() { upsert("no_proxy") } -async function getSidecarPort() { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed - } - - return await new Promise((resolve, reject) => { - const server = createServer() - server.on("error", reject) - server.listen(0, "127.0.0.1", () => { - const address = server.address() - if (typeof address !== "object" || !address) { - server.close() - reject(new Error("Failed to get port")) - return - } - const port = address.port - server.close(() => resolve(port)) - }) - }) -} - function sqliteFileExists() { const xdg = process.env.XDG_DATA_HOME const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") @@ -478,6 +447,7 @@ async function checkUpdate() { async function installUpdate() { if (!updateReady) return killSidecar() + wslServers.stopAll() autoUpdater.quitAndInstall() } diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 8c325bb6d..5134866ba 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -4,13 +4,13 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { InitStep, - LocalServerConfig, - LocalServerEvent, - LocalServerState, - LocalServerStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, + WslServerAcknowledgements, + WslServerConfig, + WslServersEvent, + WslServersState, } from "../preload/types" import { getStore } from "./store" import { setTitlebar } from "./windows" @@ -23,15 +23,22 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise - getLocalServerState: () => Promise | LocalServerState - setLocalServerConfig: (config: LocalServerConfig) => Promise | void - runLocalServerStep: (step: LocalServerStep) => Promise | void - cancelLocalServerJob: () => Promise | void - installLocalServerWsl: () => Promise | void - installLocalServerDistro: (name: string) => Promise | void - installLocalServerOpencode: () => Promise | void - openLocalServerTerminal: () => Promise | void - onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void + getWslServersState: () => Promise | WslServersState + onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void + wslServersProbeRuntime: () => Promise | void + wslServersRefreshDistros: () => Promise | void + wslServersInstallWsl: () => Promise | void + wslServersInstallDistro: (name: string) => Promise | void + wslServersProbeDistro: (name: string) => Promise | void + wslServersProbeOpencode: (name: string) => Promise | void + wslServersInstallOpencode: (name: string) => Promise | void + wslServersOpenTerminal: (name: string) => Promise | void + wslServersAddServer: (distro: string) => Promise | WslServerConfig + wslServersRemoveServer: (id: string) => Promise | void + wslServersStartServer: (id: string) => Promise | void + wslServersStopServer: (id: string) => Promise | void + wslServersCancelJob: () => Promise | void + wslServersUpdateAcknowledgements: (id: string, acks: Partial) => Promise | void getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void getDisplayBackend: () => Promise @@ -48,33 +55,48 @@ type Deps = { } export function registerIpcHandlers(deps: Deps) { - const offLocalServer = deps.onLocalServerEvent((payload) => { + const offWslServers = deps.onWslServersEvent((payload) => { for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed()) continue - win.webContents.send("local-server-event", payload) + win.webContents.send("wsl-servers-event", payload) } }) - app.once("will-quit", offLocalServer) + app.once("will-quit", offWslServers) ipcMain.handle("kill-sidecar", () => deps.killSidecar()) ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) }) - ipcMain.handle("local-server-get-state", () => deps.getLocalServerState()) - ipcMain.handle("local-server-set-config", (_event: IpcMainInvokeEvent, config: LocalServerConfig) => - deps.setLocalServerConfig(config), + ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState()) + ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) + ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) + ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) + ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersInstallDistro(name), ) - ipcMain.handle("local-server-run-step", (_event: IpcMainInvokeEvent, step: LocalServerStep) => - deps.runLocalServerStep(step), + ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersProbeDistro(name), ) - 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("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersProbeOpencode(name), + ) + ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersInstallOpencode(name), + ) + ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersOpenTerminal(name), + ) + ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(distro)) + ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(id)) + ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(id)) + ipcMain.handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStopServer(id)) + ipcMain.handle("wsl-servers-cancel", () => deps.wslServersCancelJob()) + ipcMain.handle( + "wsl-servers-update-acknowledgements", + (_event: IpcMainInvokeEvent, id: string, acks: Partial) => + deps.wslServersUpdateAcknowledgements(id, acks), ) - ipcMain.handle("local-server-install-opencode", () => deps.installLocalServerOpencode()) - 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) => deps.setDefaultServerUrl(url), diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts deleted file mode 100644 index f414014d2..000000000 --- a/packages/desktop-electron/src/main/local-server.ts +++ /dev/null @@ -1,678 +0,0 @@ -import type { - LocalServerConfig, - LocalServerDistroCheck, - LocalServerEvent, - LocalServerOpencodeCheck, - LocalServerState, - LocalServerStep, - LocalServerTranscriptLine, -} from "../preload/types" -import { LOCAL_SERVER_KEY } from "./constants" -import { store } from "./store" -import { - installWslDistro, - installWslOpencode, - installWslRuntimeElevated, - listInstalledWslDistros, - listOnlineWslDistros, - openWslTerminal, - probeWslDistro, - probeWslRuntime, - readWslCommandVersion, - resolveWslOpencode, - upgradeWslOpencode, - wslNeedsRestart, -} from "./wsl" - -export function defaultLocalServerConfig(): LocalServerConfig { - return { - mode: "windows", - distro: null, - onboarding: { - step: null, - complete: true, - pendingRestart: false, - }, - acknowledgements: { - root: [], - mismatch: [], - }, - } -} - -export function createLocalServerController(appVersion: string) { - 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) - } - - const update = (next: LocalServerState) => { - state = next - emit({ type: "state", state }) - } - - const appendTranscript = (line: Omit) => { - update({ - ...state, - transcript: [...state.transcript, { ...line, at: Date.now() }], - }) - } - - const clearTranscript = () => { - update({ - ...state, - transcript: [], - }) - } - - 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) { - persistConfig(config) - }, - subscribe(listener: (event: LocalServerEvent) => void) { - listeners.add(listener) - return () => listeners.delete(listener) - }, - async runStep(step: LocalServerStep) { - jobAbort?.abort() - const abort = new AbortController() - jobAbort = abort - clearTranscript() - appendTranscript({ stream: "system", text: `Running local server step: ${step}` }) - update({ - ...state, - job: { step, startedAt: Date.now() }, - status: { kind: "running", step }, - }) - - try { - if (step === "wsl") { - const wsl = await probeWslRuntime({ - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - 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, - 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 = state.config.distro - ? await probeWslDistro(state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : 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 - } - - if (step === "opencode") { - if (!state.config.distro) { - update({ - ...state, - job: null, - status: { kind: "failed", step, message: "No WSL distro selected" }, - }) - return - } - - const resolvedPath = await resolveWslOpencode(state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - const version = resolvedPath - ? await readWslCommandVersion(resolvedPath, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : null - if (jobAbort !== abort) return - - const opencode = opencodeCheck(resolvedPath, version, appVersion, state.config.distro) - update({ - ...state, - job: null, - status: opencode.error ? { kind: "failed", step, message: opencode.error } : { kind: "ready" }, - checks: { - ...state.checks, - opencode, - }, - }) - 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 - appendTranscript({ stream: "system", text: "Canceled local server job" }) - update({ - ...state, - job: null, - 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 installOpencode() { - if (!state.config.distro) throw new Error("No WSL distro selected") - jobAbort?.abort() - const abort = new AbortController() - jobAbort = abort - clearTranscript() - appendTranscript({ stream: "system", text: `Installing OpenCode in ${state.config.distro}` }) - update({ - ...state, - job: { step: "opencode", startedAt: Date.now() }, - status: { kind: "running", step: "opencode" }, - }) - - try { - const resolvedPath = await resolveWslOpencode(state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - const currentVersion = resolvedPath - ? await readWslCommandVersion(resolvedPath, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : null - if (jobAbort !== abort) return - - const result = - resolvedPath && currentVersion - ? await upgradeWslOpencode(appVersion, resolvedPath, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : await installWslOpencode(appVersion, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - if (result.code !== 0) throw new Error(commandFailure(result, "OpenCode installation failed")) - - const nextPath = await resolveWslOpencode(state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - const nextVersion = nextPath - ? await readWslCommandVersion(nextPath, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : null - if (jobAbort !== abort) return - - const opencode = opencodeCheck(nextPath, nextVersion, appVersion, state.config.distro) - update({ - ...state, - job: null, - status: opencode.error ? { kind: "failed", step: "opencode", message: opencode.error } : { kind: "ready" }, - checks: { - ...state.checks, - opencode, - }, - }) - } 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: "opencode", 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) - }, - setRuntime(runtime: LocalServerState["runtime"]) { - update({ - ...state, - runtime, - }) - }, - setStatus(status: LocalServerState["status"]) { - update({ - ...state, - status, - }) - }, - } -} - -function readLocalServerConfig() { - return normalizeLocalServerConfig(store.get(LOCAL_SERVER_KEY)) -} - -function toState(config: LocalServerConfig, current?: LocalServerState): LocalServerState { - return { - config, - runtime: current?.runtime ?? windowsRuntime(), - status: current?.status ?? { kind: "idle" }, - job: current?.job ?? null, - checks: current?.checks ?? { wsl: null, distro: null, opencode: null }, - transcript: current?.transcript ?? [], - } -} - -function normalizeLocalServerConfig(value: unknown): LocalServerConfig { - const fallback = defaultLocalServerConfig() - if (!value || typeof value !== "object") return fallback - const record = value as Record - const mode = record.mode === "wsl" ? "wsl" : "windows" - const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null - return { - mode, - distro, - onboarding: normalizeOnboarding(record.onboarding, mode), - acknowledgements: normalizeAcknowledgements(record.acknowledgements), - } -} - -function normalizeOnboarding(value: unknown, mode: LocalServerConfig["mode"]): LocalServerConfig["onboarding"] { - const record = value && typeof value === "object" ? (value as Record) : {} - return { - step: normalizeStep(record.step), - complete: typeof record.complete === "boolean" ? record.complete : mode === "windows", - pendingRestart: typeof record.pendingRestart === "boolean" ? record.pendingRestart : false, - } -} - -function normalizeAcknowledgements(value: unknown): LocalServerConfig["acknowledgements"] { - const record = value && typeof value === "object" ? (value as Record) : {} - return { - root: Array.isArray(record.root) - ? record.root.filter((item): item is string => typeof item === "string" && item.length > 0) - : [], - mismatch: Array.isArray(record.mismatch) - ? record.mismatch.flatMap((item) => { - if (!item || typeof item !== "object") return [] - const path = typeof item.path === "string" ? item.path : "" - const version = typeof item.version === "string" ? item.version : "" - if (!path || !version) return [] - return [{ path, version }] - }) - : [], - } -} - -function normalizeStep(value: unknown): LocalServerStep | null { - if (value === "wsl") return value - if (value === "distro") return value - if (value === "opencode") return value - if (value === "switch") return value - return null -} - -function localServerKey(config: LocalServerConfig) { - if (config.mode === "windows") return "local:windows" - if (!config.distro) return "local:wsl" - return `local:wsl:${config.distro}` -} - -function windowsRuntime(): LocalServerState["runtime"] { - return { - key: localServerKey({ - ...defaultLocalServerConfig(), - mode: "windows", - }), - mode: "windows", - 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 -} - -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 -} - -function opencodeCheck( - resolvedPath: string | null, - version: string | null, - expectedVersion: string, - distro: string | null, -): LocalServerOpencodeCheck { - if (!resolvedPath) { - return { - distro, - resolvedPath: null, - version: null, - expectedVersion, - matchesDesktop: null, - error: "opencode is not installed in the selected distro", - } - } - if (!version) { - return { - distro, - resolvedPath, - version: null, - expectedVersion, - matchesDesktop: null, - error: "opencode is installed but could not run in the selected distro", - } - } - return { - distro, - resolvedPath, - version, - expectedVersion, - matchesDesktop: version ? version === expectedVersion : null, - error: null, - } -} diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 7ccb33b2f..da856cf55 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process" +import { randomUUID } from "node:crypto" +import { createServer } from "node:net" import { app } from "electron" import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" @@ -21,6 +23,28 @@ export function setDefaultServerUrl(url: string | null) { getStore().delete(DEFAULT_SERVER_URL_KEY) } +export async function allocatePort() { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + return new Promise((resolve, reject) => { + const server = createServer() + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (typeof address !== "object" || !address) { + server.close() + reject(new Error("Failed to get port")) + return + } + const port = address.port + server.close(() => resolve(port)) + }) + }) +} + export async function spawnLocalServer(hostname: string, port: number, password: string) { prepareServerEnv(password) const { Log, Server } = await import("virtual:opencode-server") @@ -48,21 +72,30 @@ export async function spawnLocalServer(hostname: string, port: number, password: return { listener, health: { wait } } } -export async function spawnWslLocalServer( +export type WslSidecar = { + listener: { stop: () => void } + url: string + username: string | null + password: string +} + +export async function spawnWslSidecar( distro: string, - port: number, - password: string, - opts: { onLine?: (line: WslCommandLine) => void } = {}, -) { + opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, +): Promise { const opencode = await resolveWslOpencode(distro) if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`) + const port = await allocatePort() + const password = randomUUID() + const username = "opencode" + const script = [ "set -euo pipefail", "export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true", "export OPENCODE_EXPERIMENTAL_FILEWATCHER=true", "export OPENCODE_CLIENT=desktop", - `export OPENCODE_SERVER_USERNAME=${shellEscape("opencode")}`, + `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`, `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, 'export XDG_STATE_HOME="$HOME/.local/state"', `exec ${shellEscape(opencode)} --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`, @@ -93,26 +126,41 @@ export async function spawnWslLocalServer( }) }) - const wait = Promise.race([ - (async () => { - const url = `http://127.0.0.1:${port}` - while (true) { - await new Promise((resolve) => setTimeout(resolve, 100)) - if (await checkHealth(url, password)) return - } - })(), - exit, - ]).finally(() => { - settled = true + const url = `http://127.0.0.1:${port}` + const healthPromise = (async () => { + while (true) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if (await checkHealth(url, password)) return + } + })() + + const timeoutMs = opts.healthTimeoutMs ?? 30_000 + const timeout = new Promise((_, reject) => { + const id = setTimeout( + () => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)), + timeoutMs, + ) + void healthPromise.finally(() => clearTimeout(id)) }) + try { + await Promise.race([healthPromise, exit, timeout]) + } catch (error) { + child.kill() + throw error + } finally { + settled = true + } + return { listener: { stop() { child.kill() }, }, - health: { wait }, + url, + username, + password, } } @@ -156,7 +204,7 @@ function forwardLines( function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) { const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : "" - return `WSL local server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}` + return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}` } export async function checkHealth(url: string, password?: string | null): Promise { diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts new file mode 100644 index 000000000..5e2438aa0 --- /dev/null +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -0,0 +1,483 @@ +import type { + WslDistroProbe, + WslInstalledDistro, + WslJob, + WslOnlineDistro, + WslOpencodeCheck, + WslRuntimeCheck, + WslServerAcknowledgements, + WslServerConfig, + WslServerItem, + WslServerRuntime, + WslServersEvent, + WslServersState, + WslTranscriptLine, +} from "../preload/types" +import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants" +import { spawnWslSidecar } from "./server" +import { store } from "./store" +import type { WslCommandLine } from "./wsl" +import { + installWslDistro, + installWslOpencode, + installWslRuntimeElevated, + listInstalledWslDistros, + listOnlineWslDistros, + openWslTerminal, + probeWslDistro, + probeWslRuntime, + readWslCommandVersion, + resolveWslOpencode, + upgradeWslOpencode, + wslNeedsRestart, +} from "./wsl" + +type RunningSidecar = { + listener: { stop: () => void } + url: string + username: string | null + password: string +} + +type SpawnSidecar = (distro: string) => Promise + +export type WslServersController = ReturnType + +export function wslServerIdForDistro(distro: string) { + return `wsl:${distro}` +} + +export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar) { + let state: WslServersState = initialState() + const listeners = new Set<(event: WslServersEvent) => void>() + const sidecars = new Map() + let jobAbort: AbortController | undefined + + const emit = () => { + for (const listener of listeners) listener({ type: "state", state }) + } + + const setState = (next: Partial) => { + state = { ...state, ...next } + emit() + } + + const appendTranscript = (line: Omit) => { + setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] }) + } + + const clearTranscript = () => setState({ transcript: [] }) + + const persistServers = (servers: WslServerConfig[]) => { + store.set(WSL_SERVERS_KEY, { servers }) + } + + const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => { + const next = state.servers.map((item) => (item.config.id === id ? update(item) : item)) + setState({ servers: next }) + } + + const beginJob = (job: WslJob, opts: { keepTranscript?: boolean } = {}): AbortController => { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + if (!opts.keepTranscript) clearTranscript() + setState({ job, lastError: null }) + return abort + } + + const endJob = (abort: AbortController, error?: Error | null) => { + if (jobAbort !== abort) return + jobAbort = undefined + setState({ job: null, lastError: error?.message ?? null }) + } + + const onLine = (line: WslCommandLine) => appendTranscript(line) + + const refreshFromStore = () => { + const persisted = readPersistedServers() + const items: WslServerItem[] = persisted.map((config) => { + const existing = state.servers.find((item) => item.config.id === config.id) + return { + config, + runtime: existing?.runtime ?? { kind: "stopped" }, + } + }) + setState({ servers: items }) + } + + const setRuntime = (id: string, runtime: WslServerRuntime) => { + updateServer(id, (item) => ({ ...item, runtime })) + } + + const startServer = async (id: string) => { + const item = state.servers.find((x) => x.config.id === id) + if (!item) return + await stopServerInternal(id) + setRuntime(id, { kind: "starting" }) + try { + const sidecar = await spawnSidecar(item.config.distro) + sidecars.set(id, sidecar) + setRuntime(id, { + kind: "ready", + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, + }) + } catch (error) { + setRuntime(id, { + kind: "failed", + message: error instanceof Error ? error.message : String(error), + }) + } + } + + const stopServerInternal = async (id: string) => { + const existing = sidecars.get(id) + if (!existing) return + try { + existing.listener.stop() + } catch { + // ignore stop errors + } + sidecars.delete(id) + } + + const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => { + const abort = beginJob(job) + try { + const value = await runner(abort) + endJob(abort) + return value + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + endJob(abort) + return undefined + } + const err = error instanceof Error ? error : new Error(String(error)) + endJob(abort, err) + throw err + } + } + + return { + getState() { + return state + }, + subscribe(listener: (event: WslServersEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + + async initialize() { + refreshFromStore() + await Promise.all(state.servers.map((item) => startServer(item.config.id))) + }, + + async probeRuntime() { + await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: "Checking WSL runtime" }) + const runtime = await probeWslRuntime({ signal: abort.signal, onLine }) + setState({ + runtime, + pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false, + }) + }) + }, + + async refreshDistros() { + await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: "Listing WSL distros" }) + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros({ signal: abort.signal, onLine }), + listOnlineWslDistros({ signal: abort.signal, onLine }), + ]) + const installed = installedResult.status === "fulfilled" ? installedResult.value : [] + const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + setState({ installed, online }) + }) + }, + + async installWsl() { + await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: "Installing WSL runtime" }) + const result = await installWslRuntimeElevated({ signal: abort.signal, onLine }) + if (result.code !== 0) { + const message = summarize(result.stderr || result.stdout) || "WSL installation failed" + throw new Error(message) + } + const pendingRestart = wslNeedsRestart(result) + setState({ pendingRestart }) + if (!pendingRestart) { + const runtime = await probeWslRuntime({ signal: abort.signal, onLine }) + setState({ runtime }) + } + }) + }, + + async installDistro(name: string) { + await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` }) + const result = await installWslDistro(name, { signal: abort.signal, onLine }) + if (result.code !== 0) { + const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}` + throw new Error(message) + } + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros({ signal: abort.signal, onLine }), + listOnlineWslDistros({ signal: abort.signal, onLine }), + ]) + const installed = installedResult.status === "fulfilled" ? installedResult.value : [] + const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) + setState({ + installed, + online, + distroProbes: { ...state.distroProbes, [name]: probe }, + }) + }) + }, + + async probeDistro(name: string) { + await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: `Checking ${name}` }) + const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) + setState({ distroProbes: { ...state.distroProbes, [name]: probe } }) + }) + }, + + async probeOpencode(name: string) { + await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: `Checking OpenCode in ${name}` }) + const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine }) + const version = resolved ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) : null + setState({ + opencodeChecks: { + ...state.opencodeChecks, + [name]: opencodeCheck(name, resolved, version, appVersion), + }, + }) + }) + }, + + async installOpencode(name: string) { + await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: `Installing OpenCode in ${name}` }) + const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine }) + const existingVersion = resolved + ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) + : null + const result = + resolved && existingVersion + ? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal, onLine }) + : await installWslOpencode(appVersion, name, { signal: abort.signal, onLine }) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed") + } + const nextPath = await resolveWslOpencode(name, { signal: abort.signal, onLine }) + const nextVersion = nextPath + ? await readWslCommandVersion(nextPath, name, { signal: abort.signal, onLine }) + : null + setState({ + opencodeChecks: { + ...state.opencodeChecks, + [name]: opencodeCheck(name, nextPath, nextVersion, appVersion), + }, + }) + }) + }, + + async openTerminal(name: string) { + await openWslTerminal(name) + }, + + async cancelJob() { + jobAbort?.abort() + jobAbort = undefined + appendTranscript({ stream: "system", text: "Canceled" }) + setState({ job: null }) + }, + + async addServer(distro: string): Promise { + const id = wslServerIdForDistro(distro) + if (state.servers.some((item) => item.config.id === id)) { + throw new Error(`${distro} is already added`) + } + const config: WslServerConfig = { + id, + distro, + acknowledgements: { root: false, mismatch: null }, + } + persistServers([...readPersistedServers(), config]) + setState({ + servers: [...state.servers, { config, runtime: { kind: "starting" } }], + }) + void startServer(id) + return config + }, + + async removeServer(id: string) { + await stopServerInternal(id) + const remaining = readPersistedServers().filter((item) => item.id !== id) + persistServers(remaining) + setState({ servers: state.servers.filter((item) => item.config.id !== id) }) + }, + + startServer, + + async stopServer(id: string) { + await stopServerInternal(id) + setRuntime(id, { kind: "stopped" }) + }, + + async updateAcknowledgements(id: string, acks: Partial) { + const persisted = readPersistedServers() + const next = persisted.map((config) => + config.id === id ? { ...config, acknowledgements: { ...config.acknowledgements, ...acks } } : config, + ) + persistServers(next) + refreshFromStore() + }, + + stopAll() { + for (const [id] of sidecars) { + const existing = sidecars.get(id) + try { + existing?.listener.stop() + } catch { + // ignore + } + } + sidecars.clear() + }, + } +} + +function initialState(): WslServersState { + return { + runtime: null, + installed: [], + online: [], + distroProbes: {}, + opencodeChecks: {}, + pendingRestart: false, + servers: [], + job: null, + transcript: [], + lastError: null, + } +} + +function readPersistedServers(): WslServerConfig[] { + const existing = store.get(WSL_SERVERS_KEY) + if (existing && typeof existing === "object") { + const record = existing as { servers?: unknown } + const list = Array.isArray(record.servers) ? record.servers : [] + return list.flatMap(normalizePersistedServer) + } + const migrated = migrateLegacyLocalServer() + if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated }) + return migrated +} + +function migrateLegacyLocalServer(): WslServerConfig[] { + const legacy = store.get(LEGACY_LOCAL_SERVER_KEY) + if (!legacy || typeof legacy !== "object") return [] + const record = legacy as Record + if (record.mode !== "wsl") return [] + const distro = typeof record.distro === "string" ? record.distro : null + if (!distro) return [] + return [ + { + id: wslServerIdForDistro(distro), + distro, + acknowledgements: { root: false, mismatch: null }, + }, + ] +} + +function normalizePersistedServer(value: unknown): WslServerConfig[] { + if (!value || typeof value !== "object") return [] + const record = value as Record + const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null + if (!distro) return [] + const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro) + return [ + { + id, + distro, + acknowledgements: normalizeAcks(record.acknowledgements), + }, + ] +} + +function normalizeAcks(value: unknown): WslServerAcknowledgements { + const record = value && typeof value === "object" ? (value as Record) : {} + const mismatch = + record.mismatch && typeof record.mismatch === "object" ? (record.mismatch as Record) : null + return { + root: record.root === true, + mismatch: + mismatch && typeof mismatch.path === "string" && typeof mismatch.version === "string" + ? { path: mismatch.path, version: mismatch.version } + : null, + } +} + +function opencodeCheck( + distro: string, + resolvedPath: string | null, + version: string | null, + expectedVersion: string, +): WslOpencodeCheck { + if (!resolvedPath) { + return { + distro, + resolvedPath: null, + version: null, + expectedVersion, + matchesDesktop: null, + error: "opencode is not installed in this distro", + } + } + if (!version) { + return { + distro, + resolvedPath, + version: null, + expectedVersion, + matchesDesktop: null, + error: "opencode is installed but could not run", + } + } + return { + distro, + resolvedPath, + version, + expectedVersion, + matchesDesktop: version === expectedVersion, + error: null, + } +} + +function summarize(value: string) { + return value + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n") +} + +// Re-export types used by callers +export type { + WslInstalledDistro, + WslOnlineDistro, + WslRuntimeCheck, + WslDistroProbe, + WslOpencodeCheck, + WslServerConfig, + WslServerItem, + WslServerRuntime, + WslServersEvent, + WslServersState, +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index c11a2bef9..6f5c2bca1 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -1,10 +1,5 @@ import { spawn } from "node:child_process" -import type { - LocalServerDistroProbe, - LocalServerInstalledDistro, - LocalServerOnlineDistro, - LocalServerWslCheck, -} from "../preload/types" +import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types" export type WslCommandLine = { stream: "stdout" | "stderr" @@ -135,7 +130,7 @@ export function runWslBash(script: string, distro?: string | null, opts?: RunWsl return runWslInDistro(["bash", "-lc", script], distro, opts) } -export async function probeWslRuntime(opts?: RunWslOptions): Promise { +export async function probeWslRuntime(opts?: RunWslOptions): Promise { const version = await runWsl(["--version"], opts).catch((error) => ({ code: 1, signal: null, @@ -206,7 +201,7 @@ export function wslNeedsRestart(result: WslCommandResult) { return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) } -export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { +export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({ code: 1, signal: null, @@ -298,7 +293,7 @@ function parseInstalledDistros(output: string) { state: state || null, version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10), isDefault: marker === "*", - } satisfies LocalServerInstalledDistro, + } satisfies WslInstalledDistro, ] }) } @@ -311,7 +306,7 @@ function parseOnlineDistros(output: string) { if (!match) return [] const [, name, label] = match if (/^name$/i.test(name)) return [] - return [{ name, label: label.trim() } satisfies LocalServerOnlineDistro] + return [{ name, label: label.trim() } satisfies WslOnlineDistro] }) } diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 3d7db1e6d..faf0d692c 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron" -import type { ElectronAPI, InitStep, LocalServerEvent, SqliteMigrationProgress } from "./types" +import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types" const api: ElectronAPI = { killSidecar: () => ipcRenderer.invoke("kill-sidecar"), @@ -11,20 +11,27 @@ const api: ElectronAPI = { ipcRenderer.removeListener("init-step", handler) }) }, - 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"), - installWsl: () => ipcRenderer.invoke("local-server-install-wsl"), - installDistro: (name) => ipcRenderer.invoke("local-server-install-distro", name), - installOpencode: () => ipcRenderer.invoke("local-server-install-opencode"), - openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"), + wslServers: { + getState: () => ipcRenderer.invoke("wsl-servers-get-state"), subscribe: (cb) => { - const handler = (_: unknown, event: LocalServerEvent) => cb(event) - ipcRenderer.on("local-server-event", handler) - return () => ipcRenderer.removeListener("local-server-event", handler) + const handler = (_: unknown, event: WslServersEvent) => cb(event) + ipcRenderer.on("wsl-servers-event", handler) + return () => ipcRenderer.removeListener("wsl-servers-event", handler) }, + probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"), + refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"), + installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"), + installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name), + probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name), + probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name), + installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name), + openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name), + addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro), + removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id), + startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id), + stopServer: (id) => ipcRenderer.invoke("wsl-servers-stop", id), + cancelJob: () => ipcRenderer.invoke("wsl-servers-cancel"), + updateAcknowledgements: (id, acks) => ipcRenderer.invoke("wsl-servers-update-acknowledgements", id, acks), }, getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 78998e747..18183868a 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -6,36 +6,33 @@ export type ServerReadyData = { password: string | null local: { key: string - mode: LocalServerMode - distro: string | null + url: string + username: string | null + password: string | null } } export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } -export type LocalServerMode = "windows" | "wsl" -export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch" -export type LocalServerMismatchAcknowledgement = { - path: string - version: string -} -export type LocalServerWslCheck = { +export type WslServerStep = "wsl" | "distro" | "opencode" + +export type WslRuntimeCheck = { available: boolean version: string | null status: string | null error: string | null } -export type LocalServerInstalledDistro = { +export type WslInstalledDistro = { name: string state: string | null version: number | null isDefault: boolean } -export type LocalServerOnlineDistro = { +export type WslOnlineDistro = { name: string label: string } -export type LocalServerDistroProbe = { +export type WslDistroProbe = { name: string canExecute: boolean hasBash: boolean @@ -44,73 +41,82 @@ export type LocalServerDistroProbe = { isRoot: boolean | null error: string | null } -export type LocalServerDistroCheck = { - installed: LocalServerInstalledDistro[] - online: LocalServerOnlineDistro[] - selected: LocalServerDistroProbe | null - error: string | null -} -export type LocalServerOpencodeCheck = { - distro: string | null +export type WslOpencodeCheck = { + distro: string resolvedPath: string | null version: string | null expectedVersion: string | null matchesDesktop: boolean | null error: string | null } -export type LocalServerTranscriptLine = { +export type WslTranscriptLine = { stream: "stdout" | "stderr" | "system" text: string at: number } -export type LocalServerConfig = { - mode: LocalServerMode - distro: string | null - onboarding: { - step: LocalServerStep | null - complete: boolean - pendingRestart: boolean - } - acknowledgements: { - root: string[] - mismatch: LocalServerMismatchAcknowledgement[] - } + +export type WslServerAcknowledgements = { + root: boolean + mismatch: { path: string; version: string } | null } -export type LocalServerStatus = - | { kind: "idle" } - | { kind: "ready" } - | { kind: "running"; step: LocalServerStep | null } - | { kind: "failed"; step: LocalServerStep | null; message: string } -export type LocalServerState = { - config: LocalServerConfig - runtime: { - key: string - mode: LocalServerMode - distro: string | null - } - status: LocalServerStatus - job: { step: LocalServerStep | null; startedAt: number } | null - checks: { - wsl: LocalServerWslCheck | null - distro: LocalServerDistroCheck | null - opencode: LocalServerOpencodeCheck | null - } - transcript: LocalServerTranscriptLine[] + +export type WslServerConfig = { + id: string + distro: string + acknowledgements: WslServerAcknowledgements } -export type LocalServerEvent = { - type: "state" - state: LocalServerState + +export type WslServerRuntime = + | { kind: "starting" } + | { kind: "ready"; url: string; username: string | null; password: string | null } + | { kind: "failed"; message: string } + | { kind: "stopped" } + +export type WslServerItem = { + config: WslServerConfig + runtime: WslServerRuntime } -export type LocalServerAPI = { - getState: () => Promise - setConfig: (config: LocalServerConfig) => Promise - runStep: (step: LocalServerStep) => Promise - cancelJob: () => Promise + +export type WslJob = + | { kind: "runtime"; startedAt: number } + | { kind: "distros"; startedAt: number } + | { kind: "install-wsl"; startedAt: number } + | { kind: "install-distro"; distro: string; startedAt: number } + | { kind: "probe-distro"; distro: string; startedAt: number } + | { kind: "probe-opencode"; distro: string; startedAt: number } + | { kind: "install-opencode"; distro: string; startedAt: number } + +export type WslServersState = { + runtime: WslRuntimeCheck | null + installed: WslInstalledDistro[] + online: WslOnlineDistro[] + distroProbes: Record + opencodeChecks: Record + pendingRestart: boolean + servers: WslServerItem[] + job: WslJob | null + transcript: WslTranscriptLine[] + lastError: string | null +} +export type WslServersEvent = { type: "state"; state: WslServersState } + +export type WslServersAPI = { + getState: () => Promise + subscribe: (cb: (event: WslServersEvent) => void) => () => void + probeRuntime: () => Promise + refreshDistros: () => Promise installWsl: () => Promise installDistro: (name: string) => Promise - installOpencode: () => Promise - openTerminal: () => Promise - subscribe: (cb: (event: LocalServerEvent) => void) => () => void + probeDistro: (name: string) => Promise + probeOpencode: (name: string) => Promise + installOpencode: (name: string) => Promise + openTerminal: (name: string) => Promise + addServer: (distro: string) => Promise + removeServer: (id: string) => Promise + startServer: (id: string) => Promise + stopServer: (id: string) => Promise + cancelJob: () => Promise + updateAcknowledgements: (id: string, acks: Partial) => Promise } export type LinuxDisplayBackend = "wayland" | "auto" @@ -122,7 +128,7 @@ export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: (onStep: (step: InitStep) => void) => Promise - localServer: LocalServerAPI + wslServers: WslServersAPI getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise getDisplayBackend: () => Promise diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts index d1590ff04..3dbd50f61 100644 --- a/packages/desktop-electron/src/renderer/env.d.ts +++ b/packages/desktop-electron/src/renderer/env.d.ts @@ -5,8 +5,8 @@ declare global { api: ElectronAPI __OPENCODE__?: { updaterEnabled?: boolean - wsl?: boolean deepLinks?: string[] + activeServer?: string } } } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index df6101867..edf948d4d 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -5,7 +5,6 @@ import { ACCEPTED_FILE_TYPES, AppBaseProviders, AppInterface, - DialogLocalServer, handleNotificationClick, loadLocaleDict, normalizeLocale, @@ -14,10 +13,12 @@ import { PlatformProvider, ServerConnection, useCommand, + type WslServersEvent, + type WslServersState, } from "@opencode-ai/app" import type { AsyncStorage } from "@solid-primitives/storage" import { MemoryRouter } from "@solidjs/router" -import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" @@ -25,8 +26,6 @@ import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Dialog } from "@opencode-ai/ui/dialog" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme" @@ -54,27 +53,14 @@ const listenForDeepLinks = () => { } function LocalServerStartupError(props: { message: string }) { - const dialog = useDialog() - return (

Local Server failed to start

{props.message}

-
@@ -90,21 +76,20 @@ const createPlatform = (): Platform => { return undefined })() - const wslDistro = async () => { - if (os !== "windows") return - const state = await window.api.localServer.getState().catch(() => null) - if (state?.config.mode !== "wsl") return - return state.config.distro + const activeWslDistro = () => { + const key = window.__OPENCODE__?.activeServer + if (!key || !key.startsWith("wsl:")) return undefined + return key.slice("wsl:".length) } const wslHome = async () => { - const distro = await wslDistro() + const distro = activeWslDistro() if (!distro) return undefined return window.api.wslPath("~", "windows", distro).catch(() => undefined) } const handleWslPicker = async (result: T | null): Promise => { - const distro = await wslDistro() + const distro = activeWslDistro() if (!result || !distro) return result if (Array.isArray(result)) { return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any @@ -139,6 +124,8 @@ const createPlatform = (): Platform => { } })() + const wslServersApi = os === "windows" ? window.api.wslServers : undefined + return { platform: "desktop", os, @@ -179,7 +166,7 @@ const createPlatform = (): Platform => { if (os === "windows") { const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null const resolvedPath = await (async () => { - const distro = await wslDistro() + const distro = activeWslDistro() if (distro) { const converted = await window.api.wslPath(path, "windows", distro).catch(() => null) if (converted) return converted @@ -247,17 +234,7 @@ const createPlatform = (): Platform => { await window.api.setDefaultServerUrl(url) }, - localServer: { - getState: () => window.api.localServer.getState(), - setConfig: (config) => window.api.localServer.setConfig(config), - runStep: (step) => window.api.localServer.runStep(step), - cancelJob: () => window.api.localServer.cancelJob(), - installWsl: () => window.api.localServer.installWsl(), - installDistro: (name) => window.api.localServer.installDistro(name), - installOpencode: () => window.api.localServer.installOpencode(), - openTerminal: () => window.api.localServer.openTerminal(), - subscribe: (cb) => window.api.localServer.subscribe(cb), - }, + wslServers: wslServersApi, getDisplayBackend: async () => { return window.api.getDisplayBackend().catch(() => null) @@ -329,22 +306,52 @@ render(() => { ) const [locale] = createResource(loadLocale) + const [wslServers, setWslServers] = createSignal(null) + if (platform.wslServers) { + void platform.wslServers.getState().then((state) => setWslServers(state)) + const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state)) + onCleanup(off) + } + const servers = () => { const data = startup.latest?.sidecar - if (!data) return [] - const server: ServerConnection.Sidecar = { - displayName: "Local Server", - type: "sidecar", - ...(data.local.mode === "wsl" && data.local.distro - ? { variant: "wsl", distro: data.local.distro } - : { variant: "base" }), - http: { - url: data.url, - username: data.username ?? undefined, - password: data.password ?? undefined, - }, + const list: ServerConnection.Any[] = [] + if (data) { + list.push({ + displayName: "Local Server", + type: "sidecar", + variant: "base", + http: { + url: data.local.url, + username: data.local.username ?? undefined, + password: data.local.password ?? undefined, + }, + }) } - return [server] as ServerConnection.Any[] + const wsl = wslServers() + if (wsl) { + for (const item of wsl.servers) { + const runtime = item.runtime + const http = + runtime.kind === "ready" + ? { + url: runtime.url, + username: runtime.username ?? undefined, + password: runtime.password ?? undefined, + } + : { + url: `http://wsl-${item.config.distro}.invalid`, + } + list.push({ + displayName: `WSL: ${item.config.distro}`, + type: "sidecar", + variant: "wsl", + distro: item.config.distro, + http, + }) + } + } + return list } function handleClick(e: MouseEvent) {