From 3d26bbed825e34b0fd361b36bcb86473029ef862 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:03:41 +1000 Subject: [PATCH] clean maybe --- packages/app/src/app.tsx | 20 ++- .../app/src/components/dialog-wsl-server.tsx | 26 ++-- packages/desktop-electron/src/main/apps.ts | 17 +-- packages/desktop-electron/src/main/index.ts | 29 ++-- .../desktop-electron/src/main/wsl-servers.ts | 29 ++++ packages/desktop-electron/src/main/wsl.ts | 44 ++++++ packages/ui/src/components/dialog.tsx | 17 ++- packages/ui/src/context/dialog.tsx | 32 +++- plan.md | 138 ------------------ 9 files changed, 166 insertions(+), 186 deletions(-) delete mode 100644 plan.md diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 2ff68b9dd5..5528523ab9 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -256,9 +256,23 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: size="large" class="mt-4" onClick={() => { - void import("@/components/dialog-select-server").then((x) => { - dialog.show(() => ) - }) + void import("@/components/dialog-select-server") + .then((x) => { + dialog.show(() => ( + { + // We're above the Router here so useNavigate() isn't available. + // Update the browser URL directly; after server.setActive fires + // ServerKey remounts the Router which picks up "/" on init. + // Harmless under MemoryRouter (Electron), which restarts at "/". + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } + }} + /> + )) + }) + .catch((err) => console.error("Failed to load server dialog", err)) }} > Manage servers diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index 49ac180238..d971051eec 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -2,7 +2,7 @@ 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 { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { WslServerStep, WslServersState } from "@/context/platform" @@ -125,14 +125,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { if (!distroReady()) return "distro" return "opencode" }) + // activeStep falls back to recommendedStep when the user hasn't picked one. + // Once the user clicks a step tab we respect their choice rather than snapping + // them back when a probe result updates recommendedStep. const activeStep = createMemo(() => store.step ?? recommendedStep()) - createEffect( - on(recommendedStep, (next) => { - setStore("step", next) - }), - ) - const autoProbe = createMemo(() => { const state = current() if (!state || !wslServers() || busy()) return null @@ -157,8 +154,19 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { createEffect(() => { const probe = autoProbe() if (!probe || probe.key === lastAutoProbe) return - lastAutoProbe = probe.key - void run(probe.run) + const key = probe.key + lastAutoProbe = key + void (async () => { + try { + await probe.run() + } catch (err) { + // Allow the same probe to run again when reactive inputs next change + // (e.g. user reselects a distro). Without this the user would be stuck + // on a transient wsl.exe failure until they pick a different distro. + if (lastAutoProbe === key) lastAutoProbe = null + requestError(language, err) + } + })() }) createEffect(() => { diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index d0fd835b8e..eb0b260ea9 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process" import { existsSync, readFileSync, readdirSync } from "node:fs" import { dirname, extname, join } from "node:path" -import { wslArgs } from "./wsl" +import { resolveWslHome, runWslInDistro } from "./wsl" export function checkAppExists(appName: string): boolean { if (process.platform === "win32") return true @@ -14,20 +14,17 @@ export function resolveAppPath(appName: string): string | null { return resolveWindowsAppPath(appName) } -export function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): string { +export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise { if (process.platform !== "win32") return path const flag = mode === "windows" ? "-w" : "-u" try { - if (path.startsWith("~")) { - const suffix = path.slice(1) - const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"` - const output = execFileSync("wsl", wslArgs(["sh", "-lc", cmd], distro)) - return output.toString().trim() + const resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path + const output = await runWslInDistro(["wslpath", flag, resolved], distro) + if (output.code !== 0) { + throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`) } - - const output = execFileSync("wsl", wslArgs(["wslpath", flag, path], distro)) - return output.toString().trim() + return output.stdout.trim() } catch (error) { throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) } diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 95d688b277..73b6a644ea 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -56,23 +56,20 @@ const pendingDeepLinks: string[] = [] const serverReady = defer() void serverReady.promise.catch(() => undefined) -const wslServers = (() => { - const logger = initLogging() - return createWslServersController( - app.getVersion(), - async (distro) => { - logger.log("spawning wsl sidecar", { distro }) - return spawnWslSidecar(distro, { - onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), - }) - }, - { - log: (message, meta) => logger.log(message, meta), - error: (message, meta) => logger.error(message, meta), - }, - ) -})() const logger = initLogging() +const wslServers = createWslServersController( + app.getVersion(), + async (distro) => { + logger.log("spawning wsl sidecar", { distro }) + return spawnWslSidecar(distro, { + onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), + }) + }, + { + log: (message, meta) => logger.log(message, meta), + error: (message, meta) => logger.error(message, meta), + }, +) logger.log("app starting", { version: app.getVersion(), diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index e60157fc3f..c35e4e52bf 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -57,6 +57,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() + const startAttempts = new Map() let jobAbort: AbortController | undefined const emit = () => { @@ -116,14 +117,38 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa updateServer(id, (item) => ({ ...item, runtime })) } + const nextStartAttempt = (id: string) => { + const next = (startAttempts.get(id) ?? 0) + 1 + startAttempts.set(id, next) + return next + } + + const invalidateStartAttempt = (id: string) => { + startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1) + } + + const isCurrentStartAttempt = (id: string, attempt: number) => { + return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id) + } + const startServer = async (id: string) => { const item = state.servers.find((x) => x.config.id === id) if (!item) return + const attempt = nextStartAttempt(id) await stopServerInternal(id) + if (!isCurrentStartAttempt(id, attempt)) return setRuntime(id, { kind: "starting" }) mainLogger?.log("wsl sidecar starting", { id, distro: item.config.distro }) try { const sidecar = await spawnSidecar(item.config.distro) + if (!isCurrentStartAttempt(id, attempt)) { + try { + sidecar.listener.stop() + } catch { + // ignore stop errors for stale sidecars + } + return + } sidecars.set(id, sidecar) setRuntime(id, { kind: "ready", @@ -134,6 +159,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa mainLogger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) } catch (error) { const message = error instanceof Error ? error.message : String(error) + if (!isCurrentStartAttempt(id, attempt)) return setRuntime(id, { kind: "failed", message }) // Without this, an Ubuntu-style silent failure leaves no trace in // main.log — the controller captures the message in its state but @@ -327,6 +353,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa }, async removeServer(id: string) { + invalidateStartAttempt(id) await stopServerInternal(id) const remaining = readPersistedServers().filter((item) => item.id !== id) persistServers(remaining) @@ -336,6 +363,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa startServer, async stopServer(id: string) { + invalidateStartAttempt(id) await stopServerInternal(id) setRuntime(id, { kind: "stopped" }) }, @@ -350,6 +378,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa }, stopAll() { + for (const item of state.servers) invalidateStartAttempt(item.config.id) for (const [id] of sidecars) { const existing = sidecars.get(id) try { diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 54b2380020..07a22b8252 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -353,11 +353,55 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis } } +async function readWslDefaultUser(distro: string, opts?: RunWslOptions) { + const entry = (await readWslDistrosFromRegistry(opts)).find((item) => item.name === distro) + if (!entry || entry.defaultUid === 0) return null + + const passwd = firstLine( + ( + await runWslSh( + [ + "if command -v getent >/dev/null 2>&1; then", + ` getent passwd ${entry.defaultUid}`, + "else", + ` awk -F: '$3 == ${entry.defaultUid} { print; exit }' /etc/passwd`, + "fi", + ].join("\n"), + distro, + opts, + ) + ).stdout, + ) + if (!passwd) return null + + const parts = passwd.split(":") + const username = parts[0]?.trim() ?? "" + const home = parts[5]?.trim() ?? "" + if (!home) return null + return { username: username || null, home } +} + +export async function resolveWslHome(distro: string, opts?: RunWslOptions) { + return (await readWslDefaultUser(distro, opts))?.home ?? "/root" +} + +function opencodeCandidate(path: string) { + return `if [ -x ${shellEscape(path)} ]; then printf "%s\\n" ${shellEscape(path)}; fi` +} + export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { const command = firstLine((await runWslSh("command -v opencode 2>/dev/null || true", distro, opts)).stdout) if (command && !command.startsWith("/mnt/")) return command + const home = await resolveWslHome(distro, opts) for (const candidate of [ + ...(home !== "/root" + ? [ + opencodeCandidate(`${home}/.local/bin/opencode`), + opencodeCandidate(`${home}/bin/opencode`), + opencodeCandidate(`${home}/.opencode/bin/opencode`), + ] + : []), 'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi', 'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi', 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 88f43178ca..39003f3b68 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,6 +1,7 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" -import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" +import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js" import { useI18n } from "../context/i18n" +import { DialogContext } from "../context/dialog" import { IconButton } from "./icon-button" export interface DialogProps extends ParentProps { @@ -12,11 +13,19 @@ export interface DialogProps extends ParentProps { classList?: ComponentProps<"div">["classList"] fit?: boolean transition?: boolean + // When `false`, clicking the overlay or outside the dialog will not dismiss it. + // Default is `true`. dismissOutside?: boolean } export function Dialog(props: DialogProps) { const i18n = useI18n() + const dialogCtx = useContext(DialogContext) + createEffect(() => { + if (!dialogCtx) return + if (props.dismissOutside === undefined) return + dialogCtx.active?.setDismissOutside(props.dismissOutside) + }) return (
{ - if (props.dismissOutside === false) e.preventDefault() - }} - onPointerDownOutside={(e) => { - if (props.dismissOutside === false) e.preventDefault() - }} onOpenAutoFocus={(e) => { const target = e.currentTarget as HTMLElement | null const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index b4d866768b..a39f5a0f3f 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -23,10 +23,14 @@ type Active = { owner: Owner onClose?: () => void setClosing: (closing: boolean) => void + dismissOutside: () => boolean + setDismissOutside: (value: boolean) => void } const Context = createContext>() +export const DialogContext = Context + function init() { const [active, setActive] = createSignal() const timer = { current: undefined as ReturnType | undefined } @@ -89,12 +93,17 @@ function init() { const id = Math.random().toString(36).slice(2) let dispose: (() => void) | undefined let setClosing: ((closing: boolean) => void) | undefined + let setDismissOutsideSignal: ((value: boolean) => void) | undefined + let dismissOutsideAccessor: (() => boolean) | undefined const node = runWithOwner(owner, () => createRoot((d: () => void) => { dispose = d const [closing, setClosingSignal] = createSignal(false) setClosing = setClosingSignal + const [dismissOutside, setDismissOutside] = createSignal(true) + dismissOutsideAccessor = dismissOutside + setDismissOutsideSignal = setDismissOutside return ( - + { + if (dismissOutside()) close() + }} + /> {element()} @@ -113,9 +127,18 @@ function init() { }), ) - if (!dispose || !setClosing) return + if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return - setActive({ id, node, dispose, owner, onClose, setClosing }) + setActive({ + id, + node, + dispose, + owner, + onClose, + setClosing, + dismissOutside: dismissOutsideAccessor, + setDismissOutside: setDismissOutsideSignal, + }) } return { @@ -159,5 +182,8 @@ export function useDialog() { close() { ctx.close() }, + setDismissOutside(value: boolean) { + ctx.active?.setDismissOutside(value) + }, } } diff --git a/plan.md b/plan.md deleted file mode 100644 index 4862eed89a..0000000000 --- a/plan.md +++ /dev/null @@ -1,138 +0,0 @@ -# WSL Server Implementation Backlog - -This backlog assumes Electron only. -It is ordered chronologically. -Each task is intended to be small enough for a new engineer to pick up directly. - -## Direction - -- Local Server is **always** Windows-native local on Windows. -- Local Server has no runtime swap and no WSL mode. -- WSL servers are a **separate, additive** concept. -- A Windows user can add zero or more WSL servers; each is bound to a specific distro. -- Each WSL server runs as its own sidecar alongside the Windows Local Server. -- Adding/removing a WSL server is hot; no app restart required. -- Manage Servers UI exposes an `Add WSL` button (Windows only) that opens the wizard. - -## 01 Electron Config Split - -- [x] Remove runtime `mode` / `distro` from the persisted Local Server config. -- [x] Introduce a new persisted `wslServers` key holding an array of `WslServerConfig`. -- [x] Define `WslServerConfig` as `{ id, distro, onboarding, acknowledgements }`. -- [x] Keep onboarding metadata per WSL server, not globally. -- [x] Keep acknowledgement state per WSL server. -- [x] Migrate any legacy `localServer.mode === "wsl"` entry into a single `wslServers` entry on read. -- [x] Drop all references to `LocalServerMode` from the preload types. - -## 02 Main-Process Multi-Sidecar Startup - -- [x] Always start the Windows local sidecar on app launch. -- [x] After Windows local is spawned, iterate each `WslServerConfig` and spawn a WSL sidecar per entry. -- [x] Give each WSL sidecar its own port and password. -- [x] Allocate the Windows local port/password once per launch (unchanged). -- [x] Track all sidecars in a single map keyed by server id. -- [x] Kill all sidecars on `before-quit` / `will-quit` / signal. -- [x] Include Windows local data in the startup payload unchanged. -- [x] Include the initial set of WSL servers (with url/password/status) in the startup payload. -- [x] Emit per-WSL-server lifecycle events (`starting`, `ready`, `failed`, `stopping`, `removed`). -- [x] On startup, do not block the main window on WSL sidecar health. -- [x] If a WSL sidecar fails health, keep it in the list and mark it failed instead of hanging startup. - -## 03 WSL Server Controller - -- [x] Create a main-process controller that owns `wslServers` persistence and runtime state. -- [x] Expose typed events (`state`, per-item status changes) from the controller. -- [x] Support one in-flight job per WSL server (not a global in-flight job). -- [x] Implement `addServer(distro)` that persists, then spawns and health-checks a new sidecar. -- [x] Implement `removeServer(id)` that stops the sidecar and removes it from config. -- [x] Implement per-server `runStep`, `cancelJob`, `installWsl`, `installDistro`, `installOpencode`, `openTerminal`. -- [x] Reuse the existing WSL process helpers unchanged. -- [x] Keep transcripts per server, only for the current app launch. - -## 04 IPC / Preload Surface - -- [x] Rename `localServer.*` IPC channels to `wslServers.*`. -- [x] Add `wslServers.getState()` returning the full list plus per-server runtime info. -- [x] Add `wslServers.subscribe()` with unsubscribe support. -- [x] Add `wslServers.add(distro)` (persists config + starts sidecar). -- [x] Add `wslServers.remove(id)`. -- [x] Add `wslServers.runStep(id, step)`. -- [x] Add `wslServers.cancelJob(id)`. -- [x] Add `wslServers.installWsl(id)`. -- [x] Add `wslServers.installDistro(id, distro)`. -- [x] Add `wslServers.installOpencode(id)`. -- [x] Add `wslServers.openTerminal(id)`. -- [x] Remove obsolete `localServer.setConfig` / `localServer.*` channels. -- [x] Include url/username/password for each WSL server in the state payload (after sidecar start). - -## 05 Renderer Platform Wiring - -- [x] Expose `platform.wslServers` as a reactive accessor (list + subscribe). -- [x] Remove `platform.localServer` runtime swap APIs. -- [x] Keep `platform.wslServers` API available on Windows only. -- [x] Keep distro-aware path conversion keyed by the active WSL server. -- [x] When the active server is a WSL sidecar, default pickers to that distro's home. -- [x] When the active server is the Windows Local Server, keep native Windows picker defaults. - -## 06 Renderer Server List - -- [x] Always include the Windows Local Server in the server list. -- [x] Include each configured WSL server in the server list with `ServerConnection.Sidecar` variant `wsl`. -- [x] Keep `ServerConnection.key` returning `local:windows` for the Windows Local Server. -- [x] Keep `ServerConnection.key` returning `wsl:` for WSL servers (one per distro). -- [x] Keep distinct `projectsKey` buckets for Windows local vs each WSL server. -- [x] Do not collapse a WSL server into the `local` projects bucket. - -## 07 Manage Servers UI - -- [x] Remove `Swap to WSL` and `Swap to Windows` buttons from the Local Server row. -- [x] Show the Local Server row exactly like any other server (health, name, active check). -- [x] Add an `Add WSL` button next to `Add server`, visible only on Windows when the platform supports WSL. -- [x] `Add WSL` opens the same wizard stepper, scoped to a new WSL server draft. -- [x] Each WSL server row behaves like a sidecar entry (selectable, default-able, removable). -- [x] Add a `Remove` action to the WSL server row menu. -- [x] Add a `Retry setup` action when a WSL server is unhealthy. - -## 08 Add WSL Wizard - -- [x] Replace the "Switch" step with a `Done` step. -- [x] Step order becomes `WSL -> Distro -> OpenCode -> Done`. -- [x] On `Done`, persist the new WSL server, start the sidecar, and close the dialog. -- [x] If the user cancels, do not persist anything. -- [x] Allow resuming an incomplete WSL server wizard from Manage Servers. -- [x] Remove restart-to-apply copy, restart toasts, and "Use Windows" CTA. -- [x] Keep failure-only diagnostics panel behavior. - -## 09 Per-WSL Onboarding State - -- [x] Keep `WslServerConfig.onboarding` per server. -- [x] Keep `WslServerConfig.acknowledgements` per server. -- [x] Resume the wizard for any server where `onboarding.complete === false`. -- [x] Mark onboarding complete only after the sidecar becomes healthy. - -## 10 Connection Error Path - -- [x] If the active server is a WSL sidecar and health fails, offer `Open setup` that deep-links into the wizard for that server. -- [x] Keep behavior unchanged for the Windows Local Server. -- [x] Keep behavior unchanged for remote HTTP servers. - -## 11 Legacy Removal - -- [x] Remove `Swap to WSL` / `Swap to Windows` popover components. -- [x] Remove `restart-to-apply` banner copy and helpers from `dialog-local-server.tsx`. -- [x] Remove legacy `wslEnabled` preload shims. -- [x] Remove the old `DialogSelectServer` `initialTargetMode` prop. -- [x] Drop `localServerKey(config)` distinguishing `wsl` vs `windows` in the controller (local is always Windows). - -## 12 Verification - -- [ ] Verify the Windows Local Server starts unchanged on app launch. -- [ ] Verify `Add WSL` opens the wizard with the default installed distro preselected. -- [ ] Verify adding a WSL server spawns a new sidecar without restarting the app. -- [ ] Verify removing a WSL server stops the sidecar and removes it from the list. -- [ ] Verify multiple WSL servers can coexist, one per distro. -- [ ] Verify Windows Local Server stays active while WSL sidecars come and go. -- [ ] Verify a failed WSL sidecar does not block app startup or window creation. -- [ ] Verify the `ConnectionError` deep-link reaches the right wizard scope. -- [ ] Verify legacy `localServer.mode === "wsl"` persisted config migrates into `wslServers` on first launch. -- [ ] Verify project histories stay separate per server key.