From ed4a41f1e0b8d784bfc91a0fb62843e5a32a9b8a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:57:25 +1000 Subject: [PATCH] . --- .../src/components/dialog-select-server.tsx | 36 ++-- .../app/src/components/dialog-wsl-server.tsx | 106 +++------ packages/desktop-electron/src/main/index.ts | 62 +++++- packages/desktop-electron/src/main/ipc.ts | 202 ++++++++++++------ packages/desktop-electron/src/main/server.ts | 6 +- .../desktop-electron/src/main/wsl-servers.ts | 51 ++--- packages/desktop-electron/src/main/wsl.ts | 28 +-- .../desktop-electron/src/preload/index.ts | 6 +- .../desktop-electron/src/preload/types.ts | 2 - .../desktop-electron/src/renderer/index.tsx | 7 +- packages/desktop/src-tauri/src/server.rs | 20 +- packages/desktop/src/index.tsx | 7 +- packages/ui/src/components/dialog.tsx | 12 +- packages/ui/src/context/dialog.tsx | 23 +- 14 files changed, 295 insertions(+), 273 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 4b3aa4b410..8708fb8c84 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -47,7 +47,7 @@ function versionOlderThan(current: string | null | undefined, expected: string | } interface DialogSelectServerProps { - initialView?: "list" | "add-wsl" + initialView?: "add-wsl" onNavigateHome?: () => void } @@ -76,6 +76,10 @@ function showRequestError(language: ReturnType, err: unknown }) } +function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } { + return conn.type === "sidecar" && conn.variant === "wsl" +} + function useServerPreview() { const checkServerHealth = useCheckServerHealth() @@ -182,6 +186,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const wslServers = useWslServers() const { previewStatus } = useServerPreview() const checkServerHealth = useCheckServerHealth() + let disposed = false + onCleanup(() => { + disposed = true + }) const [store, setStore] = createStore({ status: {} as Record, addServer: { @@ -355,7 +363,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key) const isSelectable = (conn: ServerConnection.Any) => !isPlaceholderServerUrl(conn.http.url) const wslRuntime = (conn: ServerConnection.Any) => { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return + if (!isWslSidecar(conn)) return return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime } const canRetryWsl = (conn: ServerConnection.Any) => { @@ -390,6 +398,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) + if (disposed) return for (const [key, value] of Object.entries(results)) { cachedServerStatus.set(ServerConnection.Key.make(key), value) } @@ -404,13 +413,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) const wslCheck = (conn: ServerConnection.Any) => { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return null + if (!isWslSidecar(conn)) return null return wslState()?.opencodeChecks[conn.distro] ?? null } const displayVersion = (conn: ServerConnection.Any) => { - if (conn.type === "sidecar" && conn.variant === "wsl") return wslCheck(conn)?.version ?? undefined - return undefined + return wslCheck(conn)?.version ?? undefined } async function select(conn: ServerConnection.Any, persist?: boolean) { @@ -630,17 +638,17 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } function handleRemoveWsl(conn: ServerConnection.Any) { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return + if (!isWslSidecar(conn)) return removeWslMutation.mutate(ServerConnection.key(conn)) } function handleRetryWsl(conn: ServerConnection.Any) { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return + if (!isWslSidecar(conn)) return retryWslMutation.mutate(ServerConnection.key(conn)) } function handleUpdateWsl(conn: ServerConnection.Any) { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return + if (!isWslSidecar(conn)) return updateWslMutation.mutate(conn.distro) } @@ -697,11 +705,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { > {(i) => { const key = ServerConnection.key(i) - const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" - const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined + const wsl = isWslSidecar(i) + const wslDistro = wsl ? i.distro : undefined const blocked = () => !isSelectable(i) || health(key)?.healthy === false const canChangeDefault = () => canDefault() && i.type !== "ssh" - const canRemove = () => i.type === "http" || isWslSidecar + const canRemove = () => i.type === "http" || wsl const hasMenuActionsBeforeDelete = () => canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i)) const outdated = () => { const check = wslCheck(i) @@ -739,7 +747,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { showCredentials />
- + {(label) => ( @@ -560,11 +536,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="large" disabled={busy() || !selectedInstalled()} - onClick={() => { - const distro = selectedDistro() - if (!distro) return - void run(() => openTerminalMutation.mutateAsync(distro)) - }} + onClick={() => runSelectedDistro((distro) => openTerminalMutation.mutateAsync(distro))} > Open terminal @@ -572,11 +544,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="ghost" size="large" disabled={busy() || !selectedDistro()} - onClick={() => { - const distro = selectedDistro() - if (!distro) return - void run(() => probeDistroMutation.mutateAsync(distro)) - }} + onClick={() => runSelectedDistro((distro) => probeDistroMutation.mutateAsync(distro))} > Refresh @@ -605,11 +573,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="ghost" size="large" disabled={busy()} - onClick={() => { - const distro = selectedDistro() - if (!distro) return - void run(() => probeOpencodeMutation.mutateAsync(distro)) - }} + onClick={() => runSelectedDistro((distro) => probeOpencodeMutation.mutateAsync(distro))} > Refresh @@ -619,11 +583,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="large" disabled={busy()} - onClick={() => { - const distro = selectedDistro() - if (!distro) return - void run(() => installOpencodeMutation.mutateAsync(distro)) - }} + onClick={() => runSelectedDistro((distro) => installOpencodeMutation.mutateAsync(distro))} > {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"} diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 9c25501e56..e1780db5f0 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -352,7 +352,7 @@ function wireMenu() { } registerIpcHandlers({ - httpFetch: (input) => bridgedHttpFetch(input), + httpFetch: (input) => bridgedHttpFetch(input, readyWslUrls()), killSidecar: () => killSidecar(), relaunch: () => relaunchApp(), awaitInitialization: async (sendStep) => { @@ -401,6 +401,12 @@ registerIpcHandlers({ setBackgroundColor: (color) => setBackgroundColor(color), }) +function readyWslUrls() { + return wslServers + .getState() + .servers.flatMap((item) => (item.runtime.kind === "ready" ? [item.runtime.url] : [])) +} + function killSidecar() { if (!server) return server.stop() @@ -421,13 +427,19 @@ function relaunchApp() { // silently drops idle loopback sockets, so reusing one hangs until timeout. // `agent: false` + `Connection: close` forces a fresh TCP connection per // request, which is the only reliable way to hit a WSL-forwarded port. -function bridgedHttpFetch(input: { - url: string - method: string - headers: Record - body?: string - timeoutMs?: number -}): Promise<{ +const BRIDGED_HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) +const MAX_BRIDGED_HTTP_BODY_BYTES = 25 * 1024 * 1024 + +function bridgedHttpFetch( + input: { + url: string + method: string + headers: Record + body?: string + timeoutMs?: number + }, + allowedUrls: string[], +): Promise<{ status: number statusText: string headers: Record @@ -445,12 +457,25 @@ function bridgedHttpFetch(input: { reject(new Error(`httpFetch: only http: is supported (got ${parsed.protocol})`)) return } + if (!allowedUrls.some((url) => sameOrigin(parsed, url))) { + reject(new Error("httpFetch: url is not an active WSL sidecar")) + return + } + const method = input.method.toUpperCase() + if (!BRIDGED_HTTP_METHODS.has(method)) { + reject(new Error(`httpFetch: unsupported method ${input.method}`)) + return + } + if (input.body && Buffer.byteLength(input.body) > MAX_BRIDGED_HTTP_BODY_BYTES) { + reject(new Error(`httpFetch: request body exceeded ${MAX_BRIDGED_HTTP_BODY_BYTES} bytes`)) + return + } const req = nodeHttp.request({ host: parsed.hostname, port: parsed.port ? Number(parsed.port) : 80, path: `${parsed.pathname}${parsed.search}`, - method: input.method, + method, headers: { ...input.headers, connection: "close" }, agent: false, }) @@ -468,7 +493,15 @@ function bridgedHttpFetch(input: { req.once("response", (res) => { const chunks: Buffer[] = [] - res.on("data", (chunk: Buffer) => chunks.push(chunk)) + let bytes = 0 + res.on("data", (chunk: Buffer) => { + bytes += chunk.length + if (bytes <= MAX_BRIDGED_HTTP_BODY_BYTES) { + chunks.push(chunk) + return + } + res.destroy(new Error(`httpFetch: response exceeded ${MAX_BRIDGED_HTTP_BODY_BYTES} bytes`)) + }) res.once("end", () => { const headers: Record = {} for (const [key, value] of Object.entries(res.headers)) { @@ -492,6 +525,15 @@ function bridgedHttpFetch(input: { }) } +function sameOrigin(input: URL, allowed: string) { + try { + const url = new URL(allowed) + return input.protocol === url.protocol && input.hostname === url.hostname && input.port === url.port + } catch { + return false + } +} + function ensureLoopbackNoProxy() { const loopback = ["127.0.0.1", "localhost", "::1"] const upsert = (key: string) => { diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 023da7eae6..6baeefb1f3 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -78,79 +78,147 @@ export function registerIpcHandlers(deps: Deps) { console.log(`[store ${op}] ${JSON.stringify({ name, key, ...meta })}`) } - const offWslServers = deps.onWslServersEvent((payload) => { - for (const win of BrowserWindow.getAllWindows()) { - if (win.isDestroyed()) continue - win.webContents.send("wsl-servers-event", payload) - } - }) - app.once("will-quit", offWslServers) + const requireString = (name: string, value: unknown) => { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) + } - ipcMain.handle( + const trustedSender = (event: IpcMainEvent | IpcMainInvokeEvent) => { + const raw = event.senderFrame?.url ?? event.sender.getURL() + try { + const url = new URL(raw) + if (url.protocol === "oc:" && url.hostname === "renderer") return true + if (!app.isPackaged && (url.hostname === "127.0.0.1" || url.hostname === "localhost")) return true + } catch { + return false + } + return false + } + + const requireTrustedSender = (event: IpcMainEvent | IpcMainInvokeEvent) => { + if (trustedSender(event)) return + throw new Error("Untrusted IPC sender") + } + + const handle = ( + channel: string, + listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown, + ) => { + ipcMain.handle(channel, (event, ...args) => { + requireTrustedSender(event) + return listener(event, ...(args as Args)) + }) + } + + const on = (channel: string, listener: (event: IpcMainEvent, ...args: Args) => void) => { + ipcMain.on(channel, (event, ...args) => { + if (!trustedSender(event)) return + listener(event, ...(args as Args)) + }) + } + + const wslSubscriptions = new Map void>() + const unsubscribeWsl = (id: number) => { + const off = wslSubscriptions.get(id) + if (!off) return + off() + wslSubscriptions.delete(id) + } + + app.once("will-quit", () => { + for (const off of wslSubscriptions.values()) off() + wslSubscriptions.clear() + }) + + handle( "http-fetch", ( _event: IpcMainInvokeEvent, input: { url: string; method: string; headers: Record; body?: string; timeoutMs?: number }, ) => deps.httpFetch(input), ) - ipcMain.handle("kill-sidecar", () => deps.killSidecar()) - ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { + handle("kill-sidecar", () => deps.killSidecar()) + handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) }) - 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), + handle("wsl-servers-subscribe", (event) => { + const id = event.sender.id + if (wslSubscriptions.has(id)) return + wslSubscriptions.set( + id, + deps.onWslServersEvent((payload) => { + if (event.sender.isDestroyed()) { + unsubscribeWsl(id) + return + } + event.sender.send("wsl-servers-event", payload) + }), + ) + event.sender.once("destroyed", () => unsubscribeWsl(id)) + }) + handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id)) + handle("wsl-servers-get-state", () => deps.getWslServersState()) + handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) + handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) + handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) + handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersInstallDistro(requireString("distro", name)), ) - ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersProbeDistro(name), + handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersProbeDistro(requireString("distro", name)), ) - ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersProbeOpencode(name), + handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersProbeOpencode(requireString("distro", name)), ) - ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersInstallOpencode(name), + handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersInstallOpencode(requireString("distro", name)), ) - ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersOpenTerminal(name), + handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersOpenTerminal(requireString("distro", 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( + handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => + deps.wslServersAddServer(requireString("distro", distro)), + ) + handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => + deps.wslServersRemoveServer(requireString("server id", id)), + ) + handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => + deps.wslServersStartServer(requireString("server id", id)), + ) + handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => + deps.wslServersStopServer(requireString("server id", id)), + ) + handle("wsl-servers-cancel", () => deps.wslServersCancelJob()) + handle( "wsl-servers-update-acknowledgements", (_event: IpcMainInvokeEvent, id: string, acks: Partial) => - deps.wslServersUpdateAcknowledgements(id, acks), + deps.wslServersUpdateAcknowledgements(requireString("server id", id), acks), ) - ipcMain.handle("get-window-config", () => deps.getWindowConfig()) - ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) - ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) - ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => + handle("get-window-config", () => deps.getWindowConfig()) + handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) + handle("get-default-server-url", () => deps.getDefaultServerUrl()) + handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), ) - ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) - ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => + handle("get-display-backend", () => deps.getDisplayBackend()) + handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => deps.setDisplayBackend(backend), ) - ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown)) - ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) - ipcMain.handle( + handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown)) + handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) + handle( "wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) => deps.wslPath(path, mode, distro), ) - ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) - ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete()) - ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail)) - ipcMain.handle("check-update", () => deps.checkUpdate()) - ipcMain.handle("install-update", () => deps.installUpdate()) - ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) - ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { + handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) + on("loading-window-complete", () => deps.loadingWindowComplete()) + handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail)) + handle("check-update", () => deps.checkUpdate()) + handle("install-update", () => deps.installUpdate()) + handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) + handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { const store = getStore(name) const value = store.get(key) debugStore("get", name, key, { @@ -165,27 +233,27 @@ export function registerIpcHandlers(deps: Deps) { if (value === undefined || value === null) return null return typeof value === "string" ? value : JSON.stringify(value) }) - ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { + handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { debugStore("set", name, key, { length: value.length }) getStore(name).set(key, value) }) - ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { + handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { debugStore("delete", name, key) getStore(name).delete(key) }) - ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { + handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { getStore(name).clear() }) - ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { + handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) return Object.keys(store.store) }) - ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { + handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) return Object.keys(store.store).length }) - ipcMain.handle( + handle( "open-directory-picker", async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { const result = await dialog.showOpenDialog({ @@ -198,7 +266,7 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.handle( + handle( "open-file-picker", async ( _event: IpcMainInvokeEvent, @@ -215,7 +283,7 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.handle( + handle( "save-file-picker", async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => { const result = await dialog.showSaveDialog({ @@ -227,11 +295,11 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => { + on("open-link", (_event: IpcMainEvent, url: string) => { void shell.openExternal(url) }) - ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { + handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { if (!app) return shell.openPath(path) await new Promise((resolve, reject) => { const [cmd, args] = @@ -240,7 +308,7 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("read-clipboard-image", () => { + handle("read-clipboard-image", () => { const image = clipboard.readImage() if (image.isEmpty()) return null const buffer = image.toPNG().buffer @@ -248,34 +316,34 @@ export function registerIpcHandlers(deps: Deps) { return { buffer, width: size.width, height: size.height } }) - ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { + on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { new Notification({ title, body }).show() }) - ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length) + handle("get-window-count", () => BrowserWindow.getAllWindows().length) - ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => { + handle("get-window-focused", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) return win?.isFocused() ?? false }) - ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { + handle("set-window-focus", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.focus() }) - ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => { + handle("show-window", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.show() }) - ipcMain.on("relaunch", () => { + on("relaunch", () => { deps.relaunch() }) - ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) - ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) - ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { + handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) + handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) + handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return setTitlebar(win, theme) diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index e8362070d0..fa476ea0ec 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -5,7 +5,7 @@ import { app } from "electron" import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" -import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" +import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } @@ -228,10 +228,6 @@ function prepareServerEnv(password: string) { Object.assign(process.env, env) } -function shellEscape(value: string) { - return `'${value.replace(/'/g, `'"'"'`)}'` -} - function forwardLines( stream: NodeJS.ReadableStream, source: WslCommandLine["stream"], diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index 9c9dc79e89..4a95a28fb4 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -28,6 +28,7 @@ import { probeWslRuntime, readWslCommandVersion, resolveWslOpencode, + summarize, upgradeWslOpencode, wslNeedsRestart, } from "./wsl" @@ -53,7 +54,6 @@ export function wslServerIdForDistro(distro: string) { } export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) { - const mainLogger: ControllerLogger | undefined = logger let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() @@ -151,6 +151,17 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion)) } + const refreshDistroLists = async (opts: { signal?: AbortSignal; onLine?: (line: WslCommandLine) => void }) => { + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros(opts), + listOnlineWslDistros(opts), + ]) + return { + installed: installedResult.status === "fulfilled" ? installedResult.value : [], + online: onlineResult.status === "fulfilled" ? onlineResult.value : [], + } + } + const nextStartAttempt = (id: string) => { const next = (startAttempts.get(id) ?? 0) + 1 startAttempts.set(id, next) @@ -172,7 +183,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa await stopServerInternal(id) if (!isCurrentStartAttempt(id, attempt)) return setRuntime(id, { kind: "starting" }) - mainLogger?.log("wsl sidecar starting", { id, distro: item.config.distro }) + logger?.log("wsl sidecar starting", { id, distro: item.config.distro }) try { const sidecar = await spawnSidecar(item.config.distro) if (!isCurrentStartAttempt(id, attempt)) { @@ -195,26 +206,26 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa sidecars.delete(id) const message = startupFailure(code, signal) setRuntime(id, { kind: "failed", message }) - mainLogger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) + logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) }) void refreshOpencodeCheck(item.config.distro).catch((error) => { const message = error instanceof Error ? error.message : String(error) - mainLogger?.error("wsl opencode check failed", { id, distro: item.config.distro, message }) + logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message }) }) - mainLogger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) + logger?.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 if (isMissingDistroError(message)) { removeMissingServer(id) - mainLogger?.error("wsl server removed after missing distro", { id, distro: item.config.distro, message }) + logger?.error("wsl server removed after missing distro", { id, distro: item.config.distro, message }) 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 // nothing surfaces unless the user opens the WSL servers dialog. - mainLogger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) + logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) } } @@ -274,13 +285,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa 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 }) + setState(await refreshDistroLists({ signal: abort.signal, onLine })) }) }, @@ -309,16 +314,10 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa 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 distros = await refreshDistroLists({ signal: abort.signal, onLine }) const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) setState({ - installed, - online, + ...distros, distroProbes: { ...state.distroProbes, [name]: probe }, }) }) @@ -534,14 +533,6 @@ function opencodeCheck( } } -function summarize(value: string) { - return value - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter(Boolean) - .join("\n") -} - function isMissingDistroError(message: string) { return /WSL_E_DISTRO_NOT_FOUND|There is no distribution with the supplied name/i.test(message) } diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index abfc8a9ca6..b10084e632 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -186,12 +186,6 @@ export type WslRegistryDistro = { version: number } -// Distros that are designed to run as root and don't have a user-level -// first-run setup. Ubuntu/Debian/Kali/etc. all run a first-boot hook that -// prompts for a UNIX username on first invocation; if that never runs, -// wsl.exe -d hangs silently forever. -const ALWAYS_ROOT_DISTROS = new Set(["docker-desktop", "docker-desktop-data"]) - // Read LXSS metadata from the Windows registry. This never invokes // wsl.exe, so it is safe to call when wsl.exe itself is wedged. // DefaultUid === 0 on a user-oriented distro means the first-run @@ -243,20 +237,6 @@ export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise< return out } -export type WslFirstRunCheck = - | { status: "ok" } - | { status: "needs-first-run"; defaultUid: number } - | { status: "not-installed" } - -export async function checkWslDistroFirstRun(distro: string, opts?: RunWslOptions): Promise { - const distros = await readWslDistrosFromRegistry(opts) - const entry = distros.find((d) => d.name === distro) - if (!entry) return { status: "not-installed" } - if (ALWAYS_ROOT_DISTROS.has(entry.name)) return { status: "ok" } - if (entry.defaultUid === 0) return { status: "needs-first-run", defaultUid: entry.defaultUid } - return { status: "ok" } -} - export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { return runWslInDistro(["sh", "-lc", script], distro, opts) } @@ -307,10 +287,6 @@ export async function listOnlineWslDistros(opts?: RunWslOptions) { return parseOnlineDistros(result.stdout) } -export async function installWslRuntime(opts?: RunWslOptions) { - return runWsl(["--install", "--no-distribution"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) -} - export async function installWslRuntimeElevated(opts?: RunWslOptions) { const script = [ "$ErrorActionPreference = 'Stop'", @@ -510,7 +486,7 @@ function firstLine(value: string) { ) } -function summarize(value: string) { +export function summarize(value: string) { return value .split(/\r?\n/g) .map((line) => line.trim()) @@ -518,7 +494,7 @@ function summarize(value: string) { .join("\n") } -function shellEscape(value: string) { +export function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'` } diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 507c4d6837..6aa2d148be 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -17,7 +17,11 @@ const api: ElectronAPI = { subscribe: (cb) => { const handler = (_: unknown, event: WslServersEvent) => cb(event) ipcRenderer.on("wsl-servers-event", handler) - return () => ipcRenderer.removeListener("wsl-servers-event", handler) + void ipcRenderer.invoke("wsl-servers-subscribe") + return () => { + ipcRenderer.removeListener("wsl-servers-event", handler) + void ipcRenderer.invoke("wsl-servers-unsubscribe") + } }, probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"), refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index ea4c0a811b..e8d2b5a1da 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -14,8 +14,6 @@ export type ServerReadyData = { export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } -export type WslServerStep = "wsl" | "distro" | "opencode" - export type WslRuntimeCheck = { available: boolean version: string | null diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 2088dbcd10..9265b858fb 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -180,13 +180,14 @@ const createPlatform = (): Platform => { }) } - const handleWslPicker = async (result: T | null): Promise => { + const handleWslPicker = async (result: T): Promise => { const distro = activeWslDistro() if (!result || !distro) return result + const convert = (path: string) => window.api.wslPath(path, "linux", distro).catch(() => path) if (Array.isArray(result)) { - return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any + return (await Promise.all(result.map(convert))) as T } - return window.api.wslPath(result, "linux", distro).catch(() => result) as any + return (await convert(result)) as T } const storage = (() => { diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index d6ed086445..523b12e72f 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -67,18 +67,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option) -> Resu #[tauri::command] #[specta::specta] -pub fn get_wsl_config(_app: AppHandle) -> Result { - // let store = app - // .store(SETTINGS_STORE) - // .map_err(|e| format!("Failed to open settings store: {}", e))?; +pub fn get_wsl_config(app: AppHandle) -> Result { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; - // let enabled = store - // .get(WSL_ENABLED_KEY) - // .as_ref() - // .and_then(|v| v.as_bool()) - // .unwrap_or(false); + let enabled = store + .get(WSL_ENABLED_KEY) + .as_ref() + .and_then(|v| v.as_bool()) + .unwrap_or(false); - Ok(WslConfig { enabled: false }) + Ok(WslConfig { enabled }) } #[tauri::command] diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 289953b093..25d6762869 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -76,14 +76,15 @@ const createPlatform = (): Platform => { return commands.wslPath("~", "windows").catch(() => undefined) } - const handleWslPicker = async (result: T | null): Promise => { + const handleWslPicker = async (result: T): Promise => { if (!result) return result const wsl = await commands.getWslConfig().catch(() => null) if (!wsl?.enabled) return result + const convert = (path: string) => commands.wslPath(path, "linux").catch(() => path) if (Array.isArray(result)) { - return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any + return (await Promise.all(result.map(convert))) as T } - return commands.wslPath(result, "linux").catch(() => result) as any + return (await convert(result)) as T } return { diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 39003f3b68..a5ca4a5aaa 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,7 +1,6 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" -import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js" +import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" import { useI18n } from "../context/i18n" -import { DialogContext } from "../context/dialog" import { IconButton } from "./icon-button" export interface DialogProps extends ParentProps { @@ -20,12 +19,6 @@ export interface DialogProps extends ParentProps { 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() + }} >
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index a39f5a0f3f..03045ee402 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -23,14 +23,10 @@ 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 } @@ -93,17 +89,12 @@ 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()} @@ -127,7 +113,7 @@ function init() { }), ) - if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return + if (!dispose || !setClosing) return setActive({ id, @@ -136,8 +122,6 @@ function init() { owner, onClose, setClosing, - dismissOutside: dismissOutsideAccessor, - setDismissOutside: setDismissOutsideSignal, }) } @@ -182,8 +166,5 @@ export function useDialog() { close() { ctx.close() }, - setDismissOutside(value: boolean) { - ctx.active?.setDismissOutside(value) - }, } }