diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3189d80257df..fd956f0afaa5 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -42,6 +42,7 @@ import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" +import { WslServersProvider } from "@/context/wsl-servers" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" @@ -74,7 +75,7 @@ declare global { __OPENCODE__?: { updaterEnabled?: boolean deepLinks?: string[] - wsl?: boolean + activeServer?: string } api?: { setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise @@ -156,11 +157,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { }} > - - - {props.children} - - + + + + {props.children} + + + @@ -283,11 +286,11 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ) } -function ServerKey(props: ParentProps) { +function ServerKey(props: { children: (key: ServerConnection.Key) => JSX.Element }) { const server = useServer() return ( - {props.children} + {(key) => props.children(key)} ) } @@ -307,22 +310,24 @@ export function AppInterface(props: { > - - - - {routerProps.children}} - > - - - - - - - - - + {() => ( + + + + {routerProps.children}} + > + + + + + + + + + + )} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 0cb5a2d60461..7ee185b28991 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -5,20 +5,26 @@ 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 { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { useNavigate } from "@solidjs/router" -import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" +import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { useWslServers } from "@/context/wsl-servers" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" const DEFAULT_USERNAME = "opencode" +interface DialogSelectServerProps { + onNavigateHome?: () => void +} + interface ServerFormProps { value: string name: string @@ -27,7 +33,6 @@ interface ServerFormProps { placeholder: string busy: boolean error: string - status: boolean | undefined onChange: (value: string) => void onNameChange: (value: string) => void onUsernameChange: (value: string) => void @@ -44,15 +49,17 @@ 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 useDefaultServer() { const language = useLanguage() const platform = usePlatform() - const [defaultKey, defaultUrlActions] = createResource( + const [defaultKey, defaultActions] = createResource( async () => { try { - const key = await platform.getDefaultServer?.() - if (!key) return null - return key + return (await platform.getDefaultServer?.()) ?? null } catch (err) { showRequestError(language, err) return null @@ -60,52 +67,18 @@ function useDefaultServer() { }, { initialValue: null }, ) - const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) const setDefault = async (key: ServerConnection.Key | null) => { try { await platform.setDefaultServer?.(key) - defaultUrlActions.mutate(key) + defaultActions.mutate(key) } catch (err) { showRequestError(language, err) } } - return { defaultKey, canDefault, setDefault } } -function useServerPreview() { - const checkServerHealth = useCheckServerHealth() - - const looksComplete = (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) return false - const host = normalized.replace(/^https?:\/\//, "").split("/")[0] - if (!host) return false - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true - return host.includes(".") || host.includes(":") - } - - const previewStatus = async ( - value: string, - username: string, - password: string, - setStatus: (value: boolean | undefined) => void, - ) => { - setStatus(undefined) - if (!looksComplete(value)) return - const normalized = normalizeServerUrl(value) - if (!normalized) return - const http: ServerConnection.HttpBase = { url: normalized } - if (username) http.username = username - if (password) http.password = password - const result = await checkServerHealth(http) - setStatus(result.healthy) - } - - return { previewStatus } -} - function ServerForm(props: ServerFormProps) { const language = useLanguage() const keyDown = (event: KeyboardEvent) => { @@ -171,15 +144,18 @@ function ServerForm(props: ServerFormProps) { ) } -export function DialogSelectServer() { - const navigate = useNavigate() +export function DialogSelectServer(props: DialogSelectServerProps = {}) { const dialog = useDialog() const server = useServer() const platform = usePlatform() const language = useLanguage() - const { defaultKey, canDefault, setDefault } = useDefaultServer() - const { previewStatus } = useServerPreview() + const wslServers = useWslServers() + const defaultServer = useDefaultServer() const checkServerHealth = useCheckServerHealth() + let disposed = false + onCleanup(() => { + disposed = true + }) const [store, setStore] = createStore({ status: {} as Record, addServer: { @@ -189,7 +165,9 @@ export function DialogSelectServer() { password: "", error: "", showForm: false, - status: undefined as boolean | undefined, + }, + addWsl: { + showWizard: false, }, editServer: { id: undefined as string | undefined, @@ -198,7 +176,6 @@ export function DialogSelectServer() { username: "", password: "", error: "", - status: undefined as boolean | undefined, }, }) @@ -210,7 +187,6 @@ export function DialogSelectServer() { password: "", error: "", showForm: false, - status: undefined, }) } const resetEdit = () => { @@ -221,7 +197,6 @@ export function DialogSelectServer() { username: "", password: "", error: "", - status: undefined, }) } @@ -294,6 +269,31 @@ export function DialogSelectServer() { }, })) + const removeWslMutation = useMutation(() => ({ + mutationFn: async (key: ServerConnection.Key) => { + await platform.wslServers?.removeServer(key) + return key + }, + onSuccess: async (key) => { + server.remove(key) + }, + onError: (err) => showRequestError(language, err), + })) + + const retryWslMutation = useMutation(() => ({ + mutationFn: async (key: ServerConnection.Key) => { + await platform.wslServers?.startServer(key) + }, + onError: (err) => showRequestError(language, err), + })) + + const updateWslMutation = useMutation(() => ({ + mutationFn: async (distro: string) => { + await platform.wslServers?.installOpencode(distro) + }, + onError: (err) => showRequestError(language, err), + })) + const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const active = server.key const newConn = server.add(next) @@ -312,6 +312,32 @@ export function DialogSelectServer() { }) const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) + const wslState = () => wslServers.data + const healthPollKey = createMemo(() => + items() + .map((conn) => + [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"), + ) + .join("\n\n"), + ) + const health = (key: ServerConnection.Key) => store.status[key] + const wslRuntime = (conn: ServerConnection.Any) => { + if (!isWslSidecar(conn)) return + return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime + } + const nonReadyWslServers = createMemo(() => + (wslState()?.servers ?? []).filter((item) => item.runtime.kind !== "ready"), + ) + const canRetryWsl = (conn: ServerConnection.Any) => { + const runtime = wslRuntime(conn) + return runtime?.kind === "failed" || runtime?.kind === "stopped" + } + const canRetryWslRuntime = (kind: string) => kind === "failed" || kind === "stopped" + const wslRuntimeLabel = (kind: string) => { + if (kind === "starting") return "Starting" + if (kind === "failed") return "Failed" + return "Stopped" + } const sortedItems = createMemo(() => { const list = items() @@ -326,7 +352,7 @@ export function DialogSelectServer() { return list.slice().sort((a, b) => { if (a === active) return -1 if (b === active) return 1 - const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) + const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b))) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) @@ -334,39 +360,60 @@ export function DialogSelectServer() { async function refreshHealth() { const results: Record = {} + const list = untrack(items) await Promise.all( - items().map(async (conn) => { + list.map(async (conn) => { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) + if (disposed) return setStore("status", reconcile(results)) } createEffect(() => { - items() + healthPollKey() void refreshHealth() const interval = setInterval(refreshHealth, 10_000) onCleanup(() => clearInterval(interval)) }) + const wslCheck = (conn: ServerConnection.Any) => { + if (!isWslSidecar(conn)) return null + return wslState()?.opencodeChecks[conn.distro] ?? null + } + async function select(conn: ServerConnection.Any, persist?: boolean) { - if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return - dialog.close() - if (persist && conn.type === "http") { - server.add(conn) - navigate("/") + if (!persist && health(ServerConnection.key(conn))?.healthy === false) return + const nextKey = ServerConnection.key(conn) + const changed = server.key !== nextKey + + const navigateHome = () => props.onNavigateHome?.() + + const apply = () => { + dialog.close() + if (persist && conn.type === "http") { + server.add(conn) + navigateHome() + return + } + + batch(() => { + navigateHome() + server.setActive(nextKey) + }) + } + + if (!changed) { + await apply() return } - navigate("/") - queueMicrotask(() => server.setActive(ServerConnection.key(conn))) + + apply() } const handleAddChange = (value: string) => { if (addMutation.isPending) return setStore("addServer", { url: value, error: "" }) - void previewStatus(value, store.addServer.username, store.addServer.password, (next) => - setStore("addServer", { status: next }), - ) } const handleAddNameChange = (value: string) => { @@ -377,25 +424,16 @@ export function DialogSelectServer() { const handleAddUsernameChange = (value: string) => { if (addMutation.isPending) return setStore("addServer", { username: value, error: "" }) - void previewStatus(store.addServer.url, value, store.addServer.password, (next) => - setStore("addServer", { status: next }), - ) } const handleAddPasswordChange = (value: string) => { if (addMutation.isPending) return setStore("addServer", { password: value, error: "" }) - void previewStatus(store.addServer.url, store.addServer.username, value, (next) => - setStore("addServer", { status: next }), - ) } const handleEditChange = (value: string) => { if (editMutation.isPending) return setStore("editServer", { value, error: "" }) - void previewStatus(value, store.editServer.username, store.editServer.password, (next) => - setStore("editServer", { status: next }), - ) } const handleEditNameChange = (value: string) => { @@ -406,20 +444,15 @@ export function DialogSelectServer() { const handleEditUsernameChange = (value: string) => { if (editMutation.isPending) return setStore("editServer", { username: value, error: "" }) - void previewStatus(store.editServer.value, value, store.editServer.password, (next) => - setStore("editServer", { status: next }), - ) } const handleEditPasswordChange = (value: string) => { if (editMutation.isPending) return setStore("editServer", { password: value, error: "" }) - void previewStatus(store.editServer.value, store.editServer.username, value, (next) => - setStore("editServer", { status: next }), - ) } - const mode = createMemo<"list" | "add" | "edit">(() => { + 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" @@ -433,9 +466,11 @@ export function DialogSelectServer() { const resetForm = () => { resetAdd() resetEdit() + setStore("addWsl", "showWizard", false) } const startAdd = () => { + setStore("addWsl", "showWizard", false) resetEdit() setStore("addServer", { showForm: true, @@ -444,11 +479,11 @@ export function DialogSelectServer() { username: DEFAULT_USERNAME, password: "", error: "", - status: undefined, }) } const startEdit = (conn: ServerConnection.Http) => { + setStore("addWsl", "showWizard", false) resetAdd() setStore("editServer", { id: conn.http.url, @@ -457,10 +492,22 @@ export function DialogSelectServer() { username: conn.http.username ?? "", password: conn.http.password ?? "", error: "", - status: store.status[ServerConnection.key(conn)]?.healthy, }) } + const startAddWsl = () => { + resetAdd() + resetEdit() + setStore("addWsl", "showWizard", true) + } + + const handleAddedWsl = async (distro: string) => { + const key = ServerConnection.Key.make(`wsl:${distro}`) + setStore("addWsl", "showWizard", false) + const conn = items().find((item) => ServerConnection.key(item) === key) + if (conn) await select(conn) + } + const submitForm = () => { if (mode() === "add") { if (addMutation.isPending) return @@ -477,14 +524,22 @@ export function DialogSelectServer() { const isFormMode = createMemo(() => mode() !== "list") const isAddMode = createMemo(() => mode() === "add") + 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") return (
- {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} + + {isAddWslMode() + ? "Add WSL server" + : isAddMode() + ? language.t("dialog.server.add.title") + : language.t("dialog.server.edit.title")} +
) }) @@ -495,37 +550,107 @@ export function DialogSelectServer() { resetEdit() }) - async function handleRemove(url: ServerConnection.Key) { - server.remove(url) - if ((await platform.getDefaultServer?.()) === url) { - void platform.setDefaultServer?.(null) - } + async function handleRemove(key: ServerConnection.Key) { + server.remove(key) + if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null) } return ( - -
+ +
+ + } + > + + } > + 0}> +
+
+ + {(item) => { + const key = ServerConnection.Key.make(item.config.id) + const retryable = () => canRetryWslRuntime(item.runtime.kind) + return ( +
+
+
+ {item.config.distro} + + WSL + + + {wslRuntimeLabel(item.runtime.kind)} + +
+ + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + + retryWslMutation.mutate(key)}> + Retry start + + + + + + removeWslMutation.mutate(key)} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + + {language.t("dialog.server.menu.delete")} + + + + + +
+ ) + }} + +
+
+ x.http.url} + key={(x) => ServerConnection.key(x)} onSelect={(x) => { if (x) void select(x) }} @@ -543,18 +668,35 @@ export function DialogSelectServer() { > {(i) => { const key = ServerConnection.key(i) + const wsl = isWslSidecar(i) + const wslDistro = wsl ? i.distro : undefined + const blocked = () => health(key)?.healthy === false + const canChangeDefault = () => defaultServer.canDefault() && i.type === "http" + const canRemove = () => i.type === "http" || wsl + const opencodeAction = () => { + const check = wslCheck(i) + if (!check) return null + if (!check.resolvedPath) return "Install OpenCode" + if (check.matchesDesktop === false) return "Update OpenCode" + return null + } + const updating = () => { + const job = wslState()?.job + return job?.kind === "install-opencode" && job.distro === wslDistro + } return (
- +
+ {language.t("dialog.server.status.default")} @@ -562,12 +704,32 @@ export function DialogSelectServer() { } showCredentials /> -
+
+ + {(label) => ( + + )} + - + - { - if (i.type !== "http") return - startEdit(i) - }} - > - {language.t("dialog.server.menu.edit")} - - - setDefault(key)}> + + { + if (i.type !== "http") return + startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + + retryWslMutation.mutate(key)}> + Retry start + + + + void defaultServer.setDefault(key)}> {language.t("dialog.server.menu.default")} - - setDefault(null)}> + + void defaultServer.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")} - + + + + + { + if (wsl) { + removeWslMutation.mutate(key) + return + } + void handleRemove(key) + }} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + + {language.t("dialog.server.menu.delete")} + + + @@ -621,17 +802,32 @@ export function DialogSelectServer() {
- {language.t("dialog.server.add.button")} - + +
+ + + + +
+
} >
}> +
+ + {(item) => ( + + )} + +
+ + + +
+
+
WSL
+ + + +
+
{wslMessage()}
+ +
+
Windows restart required.
+ +
+
+
+ +
+
+
+ + +
+
+
Choose a distro
+ + + +
+
{distroMessage()}
+ +
+ 0} + fallback={ +
+ {visibleInstalledDistros().length + ? "All installed distros are already added." + : current()?.runtime?.available + ? "No distros detected yet." + : "Checking distros..."} +
+ } + > + + {(item) => ( + + )} + +
+
+ + 0}> +
+
+
Install
+
+ + + + +
+
+
+ + {(item) => { + const selected = () => store.installTarget === item.name + return ( + + ) + }} + +
+
+
+ + +
+ +
WSL 2 is required.
+
+ + {(message) =>
{message()}
} +
+ +
This distro needs bash and curl.
+
+
+
+ +
+ + +
+ +
+ +
+
+
+ + +
+
+
OpenCode
+
+ + + + + + +
+
+
{opencodeMessage()}
+ + {(check) => ( +
+
Path: {check().resolvedPath ?? "not found"}
+
+ Version: {check().version ?? "unknown"} + + {(expected) => {` · desktop ${expected()}`}} + +
+
+ Installed version does not match the desktop app version. +
+
+ )} +
+
+
+
+ + +
+ + +
+
+
+
+
+ ) +} + +function requestError(language: ReturnType, err: unknown) { + console.error("WSL servers 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), + }) +} diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index d4f68d6306d4..432611cad741 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -17,6 +17,7 @@ import type { ServerHealth } from "@/utils/server-health" interface ServerRowProps extends ParentProps { conn: ServerConnection.Any status?: ServerHealth + version?: string class?: string nameClass?: string versionClass?: string @@ -31,6 +32,8 @@ export function ServerRow(props: ServerRowProps) { let nameRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined const name = createMemo(() => serverName(props.conn)) + const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl") + const version = createMemo(() => props.version ?? props.status?.version) const check = () => { const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false @@ -41,7 +44,7 @@ export function ServerRow(props: ServerRowProps) { createEffect(() => { name() props.conn.http.url - props.status?.version + version() queueMicrotask(check) }) @@ -54,8 +57,11 @@ export function ServerRow(props: ServerRowProps) { const tooltipValue = () => ( {serverName(props.conn, true)} - - v{props.status?.version} + + WSL + + + v{version()} ) @@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) { {name()} + + + WSL + + + - v{props.status?.version} + v{version()} } diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 952e3eac64a0..787c91a21c71 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation, useQueryClient } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { useNavigate } from "@solidjs/router" +import { useLocation, useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -156,13 +156,14 @@ const useMcpToggleMutation = () => { })) } -export function StatusPopoverBody(props: { shown: Accessor }) { +export function StatusPopoverBody(props: { shown: Accessor; close?: () => void }) { const sync = useSync() const server = useServer() const platform = usePlatform() const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() + const location = useLocation() const fail = (err: unknown) => { showToast({ @@ -251,8 +252,16 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-disabled={blocked()} onClick={() => { if (blocked()) return + props.close?.() navigate("/") - queueMicrotask(() => server.setActive(key)) + const activate = () => { + if (location.pathname !== "/") { + setTimeout(activate, 16) + return + } + setTimeout(() => server.setActive(key), 0) + } + setTimeout(activate, 0) }} > diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 6820a940b05a..152cddffc85e 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -58,7 +58,7 @@ export function StatusPopover() {
} > - + setShown(false)} /> diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 6190deb1ee4d..0a729a38de34 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -371,11 +371,7 @@ function createGlobalSync() { onCleanup(() => { queue.dispose() }) - onCleanup(() => { - for (const directory of Object.keys(children.children)) { - children.disposeDirectory(directoryKey(directory)) - } - }) + onCleanup(children.disposeAll) onMount(() => { if (typeof requestAnimationFrame === "function") { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 0138310cdccd..411102e26fb5 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -92,6 +92,22 @@ export function createChildStoreManager(input: { }) } + function disposeChild(key: DirectoryKey) { + const dispose = disposers.get(key) + if (!key || !children[key]) return false + vcsCache.delete(key) + metaCache.delete(key) + iconCache.delete(key) + lifecycle.delete(key) + disposers.delete(key) + delete children[key] + input.onDispose(key) + if (dispose) { + dispose() + } + return true + } + function disposeDirectory(directory: DirectoryKey) { const key = directory if ( @@ -106,18 +122,13 @@ export function createChildStoreManager(input: { return false } - vcsCache.delete(key) - metaCache.delete(key) - iconCache.delete(key) - lifecycle.delete(key) - const dispose = disposers.get(key) - if (dispose) { - dispose() - disposers.delete(key) + return disposeChild(key) + } + + function disposeAll() { + for (const directory of Object.keys(children)) { + disposeChild(directoryKey(directory)) } - delete children[key] - input.onDispose(key) - return true } function runEviction(skip?: string) { @@ -329,6 +340,7 @@ export function createChildStoreManager(input: { unpin, pinned, disposeDirectory, + disposeAll, runEviction, vcsCache, metaCache, diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index fd89bf51ba7d..64b664393b66 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -9,6 +9,88 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } +export type WslRuntimeCheck = { + available: boolean + version: string | null + error: string | null +} +export type WslInstalledDistro = { + name: string + version: number | null + isDefault: boolean +} +export type WslOnlineDistro = { + name: string + label: string +} +export type WslDistroProbe = { + name: string + canExecute: boolean + hasBash: boolean + hasCurl: boolean + error: string | null +} +export type WslOpencodeCheck = { + distro: string + resolvedPath: string | null + version: string | null + expectedVersion: string | null + matchesDesktop: boolean | null + error: string | null +} +export type WslServerConfig = { + id: string + distro: string +} + +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 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 +} +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 + 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 +} + export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" @@ -64,11 +146,8 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServer?(url: ServerConnection.Key | null): Promise | void - /** 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 a981d99fa1d9..30c74cf74371 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -180,6 +180,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (state.active !== input) setState("active", input) } + createEffect(() => { + if (typeof window === "undefined") return + window.__OPENCODE__ ??= {} + window.__OPENCODE__.activeServer = state.active + }) + function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) if (!url_) return @@ -230,7 +236,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( ) const isLocal = createMemo(() => { const c = current() - return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url)) + return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url)) }) return { diff --git a/packages/app/src/context/wsl-servers.tsx b/packages/app/src/context/wsl-servers.tsx new file mode 100644 index 000000000000..e38f857aa860 --- /dev/null +++ b/packages/app/src/context/wsl-servers.tsx @@ -0,0 +1,35 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query" +import { createEffect, onCleanup } from "solid-js" +import type { WslServersPlatform, WslServersState } from "./platform" +import { usePlatform } from "./platform" + +const wslServersQueryKey = ["platform", "wslServers"] as const + +export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({ + name: "WslServers", + init: () => { + const platform = usePlatform() + const queryClient = useQueryClient() + const query = useQuery(() => { + const api = platform.wslServers + return queryOptions({ + queryKey: wslServersQueryKey, + queryFn: api ? () => api.getState() : skipToken, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }) + }) + + createEffect(() => { + const api = platform.wslServers + if (!api) return + const off = api.subscribe((event) => { + queryClient.setQueryData(wslServersQueryKey, event.state) + }) + onCleanup(off) + }) + + return query + }, +}) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index d80e9fffb0af..e85ea84a5ba4 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -2,6 +2,19 @@ export { AppBaseProviders, AppInterface } from "./app" 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 Platform, PlatformProvider } from "./context/platform" +export { useWslServers } from "./context/wsl-servers" +export { + type DisplayBackend, + type Platform, + PlatformProvider, + type WslInstalledDistro, + type WslOnlineDistro, + type WslOpencodeCheck, + type WslServerConfig, + type WslServerItem, + type WslServersEvent, + type WslServersPlatform, + type WslServersState, +} from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 2df69ee92251..3b2c18997dbe 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -36,6 +36,7 @@ export default function Home() { if (healthy === false) return "bg-icon-critical-base" return "bg-border-weak-base" }) + const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl") function openProject(directory: string) { layout.projects.open(directory) @@ -54,7 +55,7 @@ export default function Home() { } } - if (platform.openDirectoryPickerDialog && server.isLocal()) { + if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) { const result = await platform.openDirectoryPickerDialog?.({ title: language.t("command.project.open"), multiple: true, @@ -75,7 +76,7 @@ export default function Home() { size="large" variant="ghost" class="mt-4 mx-auto text-14-regular text-text-weak" - onClick={() => dialog.show(() => )} + onClick={() => dialog.show(() => navigate("/")} />)} >
language.t(colorSchemeKey[scheme]) const currentDir = createMemo(() => route().dir) + const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl") const [state, setState] = createStore({ autoselect: !initialDirectory, @@ -1207,7 +1208,7 @@ export default function Layout(props: ParentProps) { const run = ++dialogRun void import("@/components/dialog-select-server").then((x) => { if (dialogDead || dialogRun !== run) return - dialog.show(() => ) + dialog.show(() => navigate("/")} />) }) } @@ -1459,7 +1460,7 @@ export default function Layout(props: ParentProps) { } } - if (platform.openDirectoryPickerDialog && server.isLocal()) { + if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) { const result = await platform.openDirectoryPickerDialog?.({ title: language.t("command.project.open"), multiple: true, diff --git a/packages/app/src/utils/solid-dnd.tsx b/packages/app/src/utils/solid-dnd.tsx index 8e30a033aecd..363db50814a9 100644 --- a/packages/app/src/utils/solid-dnd.tsx +++ b/packages/app/src/utils/solid-dnd.tsx @@ -1,6 +1,6 @@ import { useDragDropContext } from "@thisbeyond/solid-dnd" import type { Transformer } from "@thisbeyond/solid-dnd" -import { createRoot, onCleanup, type JSXElement } from "solid-js" +import type { JSXElement } from "solid-js" type DragEvent = { draggable?: { id?: unknown } } @@ -27,20 +27,16 @@ const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSX if (!context) return null const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context const transformer = createTransformer(transformerId, axis) - const dispose = createRoot((dispose) => { - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return dispose + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) }) - onCleanup(dispose) return null } diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index 174da94a5d9b..8586c271c4ce 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process" import { existsSync, readFileSync, readdirSync } from "node:fs" import { dirname, extname, join } from "node:path" +import { resolveWslHome, runWslInDistro } from "./wsl" export function checkAppExists(appName: string): boolean { if (process.platform === "win32") return true @@ -13,20 +14,44 @@ export function resolveAppPath(appName: string): string | null { return resolveWindowsAppPath(appName) } -export function wslPath(path: string, mode: "windows" | "linux" | null): string { +// Parses `\\wsl$\\...` and `\\wsl.localhost\\...` UNC paths that +// point *into* a WSL distro's rootfs. `wslpath -u` cannot handle these reliably: +// backslashes get shell-collapsed when passed through `wsl.exe`, turning +// `\\wsl.localhost\Debian\home\luke` into `/mnt/c/wsl.localhostDebianhomeluke`, +// which is a valid-looking path that wedges opencode on DrvFs stat calls. +function parseWslUncPath(value: string): { distro: string; subpath: string } | null { + // Normalise separators; both `\\` and `//` prefixes mean UNC. + const normalised = value.replace(/\\/g, "/").replace(/^\/+/, "//") + const match = /^\/\/(wsl\$|wsl\.localhost)\/([^/]+)(?:\/(.*))?$/i.exec(normalised) + if (!match) return null + const distro = match[2] + const subpath = match[3] ?? "" + return { distro, subpath } +} + +export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise { if (process.platform !== "win32") return path + // `\\wsl$\\...` / `\\wsl.localhost\\...` -> `/` in + // the target distro. Do the conversion in-process rather than shelling out + // to `wslpath -u`, which mangles backslashes via wsl.exe's command-line + // joiner. If the requested distro differs from the UNC distro, we still + // translate literally — callers are responsible for only picking paths + // inside the active distro. + if (mode === "linux") { + const unc = parseWslUncPath(path) + if (unc) return `/${unc.subpath}` + } + 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", ["-e", "sh", "-lc", cmd]) - return output.toString().trim() + const resolved = path.startsWith("~") ? `${await resolveWslHome(distro)}${path.slice(1)}` : path + const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved + const output = await runWslInDistro(["wslpath", flag, input], distro) + if (output.code !== 0) { + throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`) } - - const output = execFileSync("wsl", ["-e", "wslpath", flag, path]) - 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/constants.ts b/packages/desktop-electron/src/main/constants.ts index 1e21661c1ad8..bf8f692e7f13 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -6,5 +6,5 @@ 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 WSL_ENABLED_KEY = "wslEnabled" +export const WSL_SERVERS_KEY = "wslServers" 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 af7fd42583e8..e0e1a37e0f51 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -1,12 +1,13 @@ 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" import { app, BrowserWindow, dialog } from "electron" import pkg from "electron-updater" +import { drizzle } from "drizzle-orm/node-sqlite/driver" +import type { Server } from "virtual:opencode-server" import contextMenu from "electron-context-menu" contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) @@ -34,14 +35,15 @@ app.setAppUserModelId(appId) app.setPath("userData", join(app.getPath("appData"), appId)) const { autoUpdater } = pkg -import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" +import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server" +import { createWslServersController } from "./wsl-servers" import { createLoadingWindow, createMainWindow, @@ -49,8 +51,6 @@ import { setBackgroundColor, setDockIcon, } from "./windows" -import { drizzle } from "drizzle-orm/node-sqlite/driver" -import type { Server } from "virtual:opencode-server" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } @@ -63,6 +63,19 @@ const pendingDeepLinks: string[] = [] const serverReady = defer() 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(), @@ -98,15 +111,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) }) } @@ -140,10 +156,9 @@ function setInitStep(step: InitStep) { async function initialize() { const needsMigration = !sqliteFileExists() - 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() @@ -155,24 +170,17 @@ async function initialize() { setInitStep({ phase: "sqlite_waiting" }) if (overlay) sendSqliteMigrationProgress(overlay, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - if (progress.type === "Done") sqliteDone?.resolve() }) if (needsMigration) { const { Database, JsonMigration } = await import("virtual:opencode-server") await JsonMigration.run(drizzle({ client: Database.Client().$client }), { progress: (event: { current: number; total: number }) => { - const percent = Math.round(event.current / event.total) * 100 + const percent = Math.round((event.current / event.total) * 100) initEmitter.emit("sqlite", { type: "InProgress", value: percent }) }, }) initEmitter.emit("sqlite", { type: "Done" }) - - sqliteDone?.resolve() - } - - if (needsMigration) { - await sqliteDone?.promise } logger.log("spawning sidecar", { url }) @@ -184,6 +192,9 @@ async function initialize() { password, }) + // Initialize WSL sidecars in parallel; failures do not block app startup. + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) + await Promise.race([ health.wait, delay(30_000).then(() => { @@ -225,16 +236,13 @@ function wireMenu() { void checkForUpdates(true) }, reload: () => mainWindow?.reload(), - relaunch: () => { - killSidecar() - app.relaunch() - app.exit(0) - }, + relaunch: () => relaunchApp(), }) } registerIpcHandlers({ killSidecar: () => killSidecar(), + relaunch: () => relaunchApp(), awaitInitialization: async (sendStep) => { sendStep(initStep) const listener = (step: InitStep) => sendStep(step) @@ -248,17 +256,28 @@ registerIpcHandlers({ initEmitter.off("step", 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), getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), - getWslConfig: () => Promise.resolve(getWslConfig()), - setWslConfig: (config: WslConfig) => setWslConfig(config), getDisplayBackend: async () => null, setDisplayBackend: async () => undefined, parseMarkdown: async (markdown) => parseMarkdown(markdown), checkAppExists: async (appName) => checkAppExists(appName), - wslPath: async (path, mode) => wslPath(path, mode), + wslPath: async (path, mode, distro) => wslPath(path, mode, distro), resolveAppPath: async (appName) => resolveAppPath(appName), loadingWindowComplete: () => loadingComplete.resolve(), runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), @@ -273,6 +292,15 @@ function killSidecar() { server = null } +function relaunchApp() { + // app.exit() skips before-quit / will-quit, so relaunch callers must + // explicitly stop sidecars here rather than relying on process hooks. + killSidecar() + wslServers.stopAll() + app.relaunch() + app.exit(0) +} + function ensureLoopbackNoProxy() { const loopback = ["127.0.0.1", "localhost", "::1"] const upsert = (key: string) => { @@ -293,29 +321,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") @@ -392,6 +397,7 @@ async function installUpdate() { version: downloadedUpdateVersion, }) killSidecar() + wslServers.stopAll() autoUpdater.quitAndInstall() } diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 2413613730a0..c864994c0a4f 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -8,7 +8,9 @@ import type { SqliteMigrationProgress, TitlebarTheme, WindowConfig, - WslConfig, + WslServerConfig, + WslServersEvent, + WslServersState, } from "../preload/types" import { getStore } from "./store" import { setTitlebar, updateTitlebar } from "./windows" @@ -20,18 +22,30 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void + relaunch: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise + 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 getWindowConfig: () => Promise | WindowConfig consumeInitialDeepLinks: () => Promise | string[] getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void - getWslConfig: () => Promise - setWslConfig: (config: WslConfig) => Promise | void getDisplayBackend: () => Promise setDisplayBackend: (backend: string | null) => Promise | void parseMarkdown: (markdown: string) => Promise | string checkAppExists: (appName: string) => Promise | boolean - wslPath: (path: string, mode: "windows" | "linux" | null) => Promise + wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise resolveAppPath: (appName: string) => Promise loadingWindowComplete: () => void runUpdater: (alertOnFail: boolean) => Promise | void @@ -41,27 +55,89 @@ type Deps = { } export function registerIpcHandlers(deps: Deps) { + const requireString = (name: string, value: unknown) => { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) + } + + 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() + }) + 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("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)) + }) + ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id)) + 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(requireString("distro", name)), + ) + ipcMain.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(requireString("distro", name)), + ) + ipcMain.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(requireString("distro", name)), + ) + ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => + deps.wslServersAddServer(requireString("distro", distro)), + ) + ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => + deps.wslServersRemoveServer(requireString("server id", id)), + ) + ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => + deps.wslServersStartServer(requireString("server id", id)), + ) 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) => deps.setDefaultServerUrl(url), ) - ipcMain.handle("get-wsl-config", () => deps.getWslConfig()) - ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config)) ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) ipcMain.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("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) => - deps.wslPath(path, mode), + ipcMain.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()) @@ -178,8 +254,7 @@ export function registerIpcHandlers(deps: Deps) { }) ipcMain.on("relaunch", () => { - app.relaunch() - app.exit(0) + deps.relaunch() }) ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 83d50f7cb614..47d2daba3913 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,9 +1,11 @@ +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, WSL_ENABLED_KEY } from "./constants" +import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" - -export type WslConfig = { enabled: boolean } +import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } @@ -21,13 +23,26 @@ export function setDefaultServerUrl(url: string | null) { getStore().delete(DEFAULT_SERVER_URL_KEY) } -export function getWslConfig(): WslConfig { - const value = getStore().get(WSL_ENABLED_KEY) - return { enabled: typeof value === "boolean" ? value : false } -} - -export function setWslConfig(config: WslConfig) { - getStore().set(WSL_ENABLED_KEY, config.enabled) +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) { @@ -58,6 +73,133 @@ export async function spawnLocalServer(hostname: string, port: number, password: return { listener, health: { wait } } } +export type WslSidecar = { + listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } + url: string + username: string | null + password: string +} + +export async function spawnWslSidecar( + distro: string, + opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, +): Promise { + // Do not pass --user here: the sidecar should inherit the distro's + // default user so config, auth, git, ssh, and file ownership match the + // user's normal WSL environment. If that default user is root, WSL will + // choose root itself. + 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 logLevel = app.isPackaged ? "WARN" : "INFO" + + const script = [ + "set -euo pipefail", + // wsl.exe inherits the Windows-side cwd (e.g. C:\Users\Lukem) and maps it + // to the distro as /mnt/c/Users/Lukem — a DrvFs/9p path. opencode's + // instance middleware falls back to `process.cwd()` when a request + // arrives without a `directory=` query or `x-opencode-directory` header + // (see opencode server.ts InstanceMiddleware), and then calls + // `realpathSync(process.cwd())` synchronously on the main thread. A + // statx against a 9p path can wedge the whole event loop in kernel + // uninterruptible sleep, freezing the accept loop. Move cwd to the + // user's native Linux home so the fallback can't land on DrvFs. + 'cd "$HOME" || cd /', + // wsl.exe by default splices the Windows %PATH% into the distro's $PATH + // via the interop layer (every `/mnt/c/Program Files/...` entry). Anything + // the sidecar spawns — PTY login shells, plugin helpers, etc. — then + // inherits it, which means `which pwsh.exe` resolves to the Windows + // PowerShell binary and bash-l profiles that end with + // eval "$(oh-my-posh init bash)" (or similar) + // silently run Windows pwsh for prompt rendering, whose banner + // ("Loading personal and system profiles took Xms.") then shows up in + // opencode's terminal pane. We want a clean, Linux-only environment in + // the sidecar, so filter every /mnt/* segment out of PATH and clear + // WSLENV so no further Windows vars leak in. Users who really need + // Windows binaries in the sidecar can invoke them by absolute path. + 'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")', + "export PATH", + "export WSLENV=", + // WSL sidecars often target /mnt/* worktrees. Keep the desktop-only + // watcher/discovery features off there because DrvFs/9p stalls can wedge + // the server process after it starts listening. + "export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true", + "export OPENCODE_CLIENT=desktop", + `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 ${logLevel} serve --hostname 0.0.0.0 --port ${port}`, + ].join("\n") + + const child = spawn("wsl", wslArgs(["bash", "-se"], distro), { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }) + child.stdin.end(script) + + let settled = false + const recentOutput: string[] = [] + const emit = (line: WslCommandLine) => { + if (settled || !line.text.trim()) return + recentOutput.push(`[${line.stream}] ${line.text}`) + if (recentOutput.length > 12) recentOutput.shift() + opts.onLine?.(line) + } + + forwardLines(child.stdout, "stdout", emit) + forwardLines(child.stderr, "stderr", emit) + + const exit = new Promise((_, reject) => { + child.once("error", reject) + child.once("exit", (code, signal) => { + reject(new Error(startupFailure(code, signal, recentOutput))) + }) + }) + + 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() + }, + onExit(cb) { + child.once("exit", cb) + }, + }, + url, + username, + password, + } +} + function prepareServerEnv(password: string) { const shell = process.platform === "win32" ? null : getUserShell() const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} @@ -74,6 +216,29 @@ function prepareServerEnv(password: string) { Object.assign(process.env, env) } +function forwardLines( + stream: NodeJS.ReadableStream, + source: WslCommandLine["stream"], + onLine: (line: WslCommandLine) => void, +) { + let pending = "" + stream.setEncoding("utf8") + stream.on("data", (chunk: string) => { + pending += chunk + const lines = pending.split(/\r?\n/g) + pending = lines.pop() ?? "" + for (const line of lines) onLine({ stream: source, text: line }) + }) + stream.on("end", () => { + if (pending) onLine({ stream: source, text: pending }) + }) +} + +function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) { + const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : "" + return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}` +} + export async function checkHealth(url: string, password?: string | null): Promise { let healthUrl: URL try { 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 000000000000..34cf62f9d779 --- /dev/null +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -0,0 +1,447 @@ +import type { + WslDistroProbe, + WslInstalledDistro, + WslJob, + WslOnlineDistro, + WslOpencodeCheck, + WslRuntimeCheck, + WslServerConfig, + WslServerItem, + WslServerRuntime, + WslServersEvent, + WslServersState, +} from "../preload/types" +import { WSL_SERVERS_KEY } from "./constants" +import { getStore } from "./store" +import { + installWslDistro, + installWslOpencode, + installWslRuntimeElevated, + listInstalledWslDistros, + listOnlineWslDistros, + openWslTerminal, + probeWslDistro, + probeWslRuntime, + readWslCommandVersion, + resolveWslOpencode, + summarize, + upgradeWslOpencode, + wslNeedsRestart, +} from "./wsl" + +type RunningSidecar = { + listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } + url: string + username: string | null + password: string +} + +type SpawnSidecar = (distro: string) => Promise + +type ControllerLogger = { + log: (message: string, meta?: unknown) => void + error: (message: string, meta?: unknown) => void +} + +export type WslServersController = ReturnType + +export function wslServerIdForDistro(distro: string) { + return `wsl:${distro}` +} + +export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) { + 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 = () => { + for (const listener of listeners) listener({ type: "state", state }) + } + + const setState = (next: Partial) => { + state = { ...state, ...next } + emit() + } + + const persistServers = (servers: WslServerConfig[]) => { + getStore().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): AbortController => { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + setState({ job }) + return abort + } + + const endJob = (abort: AbortController) => { + if (jobAbort !== abort) return + jobAbort = undefined + setState({ job: null }) + } + + 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 setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => { + setState({ + opencodeChecks: { + ...state.opencodeChecks, + [distro]: check, + }, + }) + } + + const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => { + const resolved = await resolveWslOpencode(distro, opts) + const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null + setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion)) + } + + const refreshDistroLists = async (opts: { signal?: AbortSignal }) => { + const [installed, online] = await Promise.all([ + listInstalledWslDistros(opts), + listOnlineWslDistros(opts), + ]) + return { installed, online } + } + + 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" }) + logger?.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", + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, + }) + sidecar.listener.onExit((code, signal) => { + if (sidecars.get(id) !== sidecar) return + sidecars.delete(id) + const message = startupFailure(code, signal) + setRuntime(id, { kind: "failed", message }) + 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) + logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message }) + }) + 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 + 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. + logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) + } + } + + const stopServerInternal = async (id: string) => { + const existing = sidecars.get(id) + if (!existing) return + sidecars.delete(id) + try { + existing.listener.stop() + } catch { + // ignore stop errors + } + } + + 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) + 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) => { + const runtime = await probeWslRuntime({ signal: abort.signal }) + setState({ + runtime, + pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false, + }) + }) + }, + + async refreshDistros() { + await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { + setState(await refreshDistroLists({ signal: abort.signal })) + }) + }, + + async installWsl() { + await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { + const result = await installWslRuntimeElevated({ signal: abort.signal }) + 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 }) + setState({ runtime }) + } + }) + }, + + async installDistro(name: string) { + await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { + const result = await installWslDistro(name, { signal: abort.signal }) + if (result.code !== 0) { + const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}` + throw new Error(message) + } + const distros = await refreshDistroLists({ signal: abort.signal }) + const probe = await probeWslDistro(name, { signal: abort.signal }) + setState({ + ...distros, + distroProbes: { ...state.distroProbes, [name]: probe }, + }) + }) + }, + + async probeDistro(name: string) { + await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => { + const probe = await probeWslDistro(name, { signal: abort.signal }) + setState({ distroProbes: { ...state.distroProbes, [name]: probe } }) + }) + }, + + async probeOpencode(name: string) { + await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => { + await refreshOpencodeCheck(name, { signal: abort.signal }) + }) + }, + + async installOpencode(name: string) { + await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { + const resolved = await resolveWslOpencode(name, { signal: abort.signal }) + const existingVersion = resolved + ? await readWslCommandVersion(resolved, name, { signal: abort.signal }) + : null + const result = + resolved && existingVersion + ? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal }) + : await installWslOpencode(appVersion, name, { signal: abort.signal }) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed") + } + await refreshOpencodeCheck(name, { signal: abort.signal }) + }) + }, + + async openTerminal(name: string) { + await openWslTerminal(name) + }, + + 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, + } + persistServers([...readPersistedServers(), config]) + setState({ + servers: [...state.servers, { config, runtime: { kind: "starting" } }], + }) + void startServer(id) + return config + }, + + async removeServer(id: string) { + invalidateStartAttempt(id) + await stopServerInternal(id) + const remaining = readPersistedServers().filter((item) => item.id !== id) + persistServers(remaining) + setState({ servers: state.servers.filter((item) => item.config.id !== id) }) + }, + + startServer, + + stopAll() { + for (const item of state.servers) invalidateStartAttempt(item.config.id) + for (const existing of sidecars.values()) { + try { + existing.listener.stop() + } catch { + // ignore + } + } + sidecars.clear() + }, + } +} + +function initialState(): WslServersState { + return { + runtime: null, + installed: [], + online: [], + distroProbes: {}, + opencodeChecks: {}, + pendingRestart: false, + servers: [], + job: null, + } +} + +function readPersistedServers(): WslServerConfig[] { + const store = getStore() + 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) + } + return [] +} + +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, + }, + ] +} + +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 startupFailure(code: number | null, signal: NodeJS.Signals | null) { + return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})` +} + +// 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 new file mode 100644 index 000000000000..e0ee64d8beed --- /dev/null +++ b/packages/desktop-electron/src/main/wsl.ts @@ -0,0 +1,422 @@ +import { spawn } from "node:child_process" +import { existsSync } from "node:fs" +import { join } from "node:path" +/** @ts-expect-error */ +import * as pty from "@lydell/node-pty" +import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types" + +export type WslCommandLine = { + stream: "stdout" | "stderr" + text: string +} + +export type WslCommandResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +export type RunWslOptions = { + signal?: AbortSignal + /** + * Ceiling on how long we wait for the child process to exit. When the + * LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a + * pending first-run prompt), `wsl.exe` never returns and any command + * that doesn't specify a timeout hangs the entire startup flow. Default + * is 20s — enough for slow cold-starts, short enough to fail fast on + * a wedge. Callers can override for longer-running jobs. + */ + timeoutMs?: number +} + +const DEFAULT_WSL_TIMEOUT_MS = 20_000 +const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000 + +export function wslArgs(args: string[], distro?: string | null, user?: string | null) { + return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args] +} + +export function runWsl(args: string[], opts: RunWslOptions = {}) { + return runCommand("wsl", args, opts) +} + +function runPowerShell(command: string, opts: RunWslOptions = {}) { + return runCommand( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command], + opts, + ) +} + +function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + signal: opts.signal, + }) + + // Guard every wsl.exe invocation with a timeout. When the distro or + // the LXSS service is wedged (Ubuntu first-run state, Windows update + // pending, etc.) wsl.exe produces no output and never exits; without + // this the whole sidecar spawn flow stalls the app forever. + const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS + const timeoutId = setTimeout(() => { + try { + child.kill() + } catch { + /* ignore */ + } + reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + let stdout = "" + let stderr = "" + const stdoutDecoder = createOutputDecoder() + const stderrDecoder = createOutputDecoder() + + const append = (stream: WslCommandLine["stream"], chunk: string) => { + if (!chunk) return + if (stream === "stdout") { + stdout += chunk + return + } + stderr += chunk + } + + child.stdout.on("data", (chunk: Buffer) => { + append("stdout", stdoutDecoder.decode(chunk)) + }) + child.stdout.on("end", () => { + append("stdout", stdoutDecoder.flush()) + }) + + child.stderr.on("data", (chunk: Buffer) => { + append("stderr", stderrDecoder.decode(chunk)) + }) + child.stderr.on("end", () => { + append("stderr", stderrDecoder.flush()) + }) + + child.once("error", (error) => { + clearTimeout(timeoutId) + reject(error) + }) + child.once("close", (code, signal) => { + clearTimeout(timeoutId) + resolve({ code, signal, stdout, stderr }) + }) + }) +} + +function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) { + return new Promise((resolve, reject) => { + const child = pty.spawn(command, args, { + name: "xterm-color", + cols: 80, + rows: 24, + cwd: process.cwd(), + env: process.env, + useConpty: true, + }) + + let settled = false + let stdout = "" + + const cleanup = () => { + clearTimeout(timeoutId) + abortCleanup?.() + } + + const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs + const timeoutId = setTimeout(() => { + try { + child.kill() + } catch { + /* ignore */ + } + if (settled) return + settled = true + cleanup() + reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + const abortHandler = () => { + try { + child.kill() + } catch { + /* ignore */ + } + if (settled) return + settled = true + cleanup() + reject(new DOMException("Aborted", "AbortError")) + } + const abortCleanup = opts.signal + ? (() => { + opts.signal?.addEventListener("abort", abortHandler, { once: true }) + return () => opts.signal?.removeEventListener("abort", abortHandler) + })() + : undefined + + child.onData((data: string) => { + stdout += data + }) + child.onExit((event: { exitCode: number }) => { + if (settled) return + settled = true + cleanup() + resolve({ code: event.exitCode, signal: null, stdout, stderr: "" }) + }) + }) +} + +function createOutputDecoder() { + let decoder: TextDecoder | undefined + return { + decode(chunk: Buffer) { + decoder ??= new TextDecoder(detectOutputEncoding(chunk)) + return decoder.decode(chunk, { stream: true }) + }, + flush() { + return decoder?.decode() ?? "" + }, + } +} + +function detectOutputEncoding(chunk: Uint8Array) { + if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le" + const pairs = Math.floor(chunk.length / 2) + if (pairs < 2) return "utf-8" + const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length + const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length + return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8" +} + +export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) { + return runWsl(wslArgs(args, distro), opts) +} + +export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { + return runWslInDistro(["sh", "-lc", script], distro, opts) +} + +export async function probeWslRuntime(opts?: RunWslOptions): Promise { + const version = await runWsl(["--version"], opts).catch((error) => ({ + code: 1, + signal: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) + + if (version.code !== 0) { + return { + available: false, + version: null, + error: summarize(version.stderr || version.stdout) || "WSL is unavailable", + } + } + + return { + available: true, + version: firstLine(version.stdout), + error: null, + } +} + +export async function listInstalledWslDistros(opts?: RunWslOptions) { + const result = await runWsl(["--list", "--verbose"], opts) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros") + } + return parseInstalledDistros(result.stdout) +} + +export async function listOnlineWslDistros(opts?: RunWslOptions) { + const result = await runWsl(["--list", "--online"], opts) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros") + } + return parseOnlineDistros(result.stdout) +} + +export async function installWslRuntimeElevated(opts?: RunWslOptions) { + const script = [ + "$ErrorActionPreference = 'Stop'", + "$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru", + "if ($null -ne $process.ExitCode) { exit $process.ExitCode }", + ].join("; ") + return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) +} + +export async function installWslDistro(name: string, opts?: RunWslOptions) { + return runInteractiveCommand( + resolveSystem32Command("wsl.exe"), + ["--install", "-d", name, "--web-download", "--no-launch"], + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + DEFAULT_WSL_INSTALL_TIMEOUT_MS, + ) +} + +export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) { + return runInteractiveCommand( + resolveSystem32Command("wsl.exe"), + wslArgs(["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`], distro), + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + DEFAULT_WSL_INSTALL_TIMEOUT_MS, + ) +} + +export function wslNeedsRestart(result: WslCommandResult) { + return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) +} + +export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { + const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({ + code: 1, + signal: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) + if (executable.code !== 0) { + return { + name, + canExecute: false, + hasBash: false, + hasCurl: false, + error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro", + } + } + + const [bash, curl] = await Promise.all([ + runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts), + runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts), + ]) + + return { + name, + canExecute: true, + hasBash: bash.code === 0 && summarize(bash.stdout) === "yes", + hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes", + error: null, + } +} + +export async function resolveWslHome(distro?: string | null, opts?: RunWslOptions) { + return firstLine((await runWslSh('printf "%s\\n" "$HOME"', distro, opts)).stdout) ?? "/" +} + +export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { + const command = firstLine((await runWslSh("command -v opencode 2>/dev/null | grep -v '^/mnt/' | head -n 1 || true", distro, opts)).stdout) + if (command) return command + + for (const candidate of [ + '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', + 'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi', + ]) { + const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout) + if (resolved) return resolved + } + + return null +} + +export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) { + const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts) + return firstLine(result.stdout) +} + +export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) { + return runInteractiveCommand( + resolveSystem32Command("wsl.exe"), + wslArgs(["bash", "-lc", `${shellEscape(command)} upgrade ${shellEscape(target)}`], distro, "root"), + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + DEFAULT_WSL_INSTALL_TIMEOUT_MS, + ) +} + +export function openWslTerminal(distro?: string | null) { + if (distro && !/^[a-zA-Z0-9_.-]+$/.test(distro)) { + return Promise.reject(new Error("Invalid distro name")) + } + return new Promise((resolve, reject) => { + const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], { + detached: true, + stdio: "ignore", + windowsHide: true, + }) + child.once("error", reject) + child.once("spawn", () => { + child.unref() + resolve() + }) + }) +} + +function parseInstalledDistros(output: string) { + return output.split(/\r?\n/g).flatMap((line) => { + const trimmed = line.trim() + if (!trimmed) return [] + const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/) + if (!match) return [] + const [, marker, name, version] = match + if (!name || /^name$/i.test(name)) return [] + return [ + { + name: name.trim(), + version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10), + isDefault: marker === "*", + } satisfies WslInstalledDistro, + ] + }) +} + +function parseOnlineDistros(output: string) { + return output.split(/\r?\n/g).flatMap((line) => { + const trimmed = line.trim() + if (!trimmed) return [] + const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/) + if (!match) return [] + const [, name, label] = match + if (/^name$/i.test(name)) return [] + return [{ name, label: label.trim() } satisfies WslOnlineDistro] + }) +} + +function firstLine(value: string) { + return ( + value + .split(/\r?\n/g) + .map((line) => line.trim()) + .find(Boolean) ?? null + ) +} + +export function summarize(value: string) { + return value + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n") +} + +export function shellEscape(value: string) { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + +function resolveSystem32Command(command: string) { + const root = process.env.SystemRoot ?? process.env.windir + if (!root) return command + const resolved = join(root, "System32", command) + return existsSync(resolved) ? resolved : command +} + +function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions { + return { + ...opts, + timeoutMs: opts?.timeoutMs ?? timeoutMs, + } +} diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 6261419ca55d..bdda548d4782 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, SqliteMigrationProgress } from "./types" +import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types" const api: ElectronAPI = { killSidecar: () => ipcRenderer.invoke("kill-sidecar"), @@ -11,17 +11,38 @@ const api: ElectronAPI = { ipcRenderer.removeListener("init-step", handler) }) }, + wslServers: { + getState: () => ipcRenderer.invoke("wsl-servers-get-state"), + subscribe: (cb) => { + const handler = (_: unknown, event: WslServersEvent) => cb(event) + ipcRenderer.on("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"), + 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), + }, getWindowConfig: () => ipcRenderer.invoke("get-window-config"), consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"), getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), - getWslConfig: () => ipcRenderer.invoke("get-wsl-config"), - setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config), getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"), setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend), parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown), checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName), - wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode), + wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro), resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName), storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key), storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 6e22954d18fa..34fd5be6aa49 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -8,7 +8,87 @@ export type ServerReadyData = { export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } -export type WslConfig = { enabled: boolean } +export type WslRuntimeCheck = { + available: boolean + version: string | null + error: string | null +} +export type WslInstalledDistro = { + name: string + version: number | null + isDefault: boolean +} +export type WslOnlineDistro = { + name: string + label: string +} +export type WslDistroProbe = { + name: string + canExecute: boolean + hasBash: boolean + hasCurl: boolean + error: string | null +} +export type WslOpencodeCheck = { + distro: string + resolvedPath: string | null + version: string | null + expectedVersion: string | null + matchesDesktop: boolean | null + error: string | null +} +export type WslServerConfig = { + id: string + distro: string +} + +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 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 +} +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 + 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 +} export type LinuxDisplayBackend = "wayland" | "auto" export type TitlebarTheme = { @@ -23,17 +103,16 @@ export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: (onStep: (step: InitStep) => void) => Promise + wslServers: WslServersAPI getWindowConfig: () => Promise consumeInitialDeepLinks: () => Promise getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise - getWslConfig: () => Promise - setWslConfig: (config: WslConfig) => Promise getDisplayBackend: () => Promise setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise parseMarkdownCommand: (markdown: string) => Promise checkAppExists: (appName: string) => Promise - wslPath: (path: string, mode: "windows" | "linux" | null) => Promise + wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise resolveAppPath: (appName: string) => Promise storeGet: (name: string, key: string) => Promise storeSet: (name: string, key: string, value: string) => Promise diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts index 6dff3baf1c49..48cfc7786120 100644 --- a/packages/desktop-electron/src/renderer/env.d.ts +++ b/packages/desktop-electron/src/renderer/env.d.ts @@ -5,6 +5,7 @@ declare global { api: ElectronAPI __OPENCODE__?: { deepLinks?: string[] + activeServer?: string } } } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 52dd6bef8cc2..d1905970041a 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -13,16 +13,18 @@ import { PlatformProvider, ServerConnection, useCommand, + useWslServers, } from "@opencode-ai/app" import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { MemoryRouter } from "@solidjs/router" -import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createResource, onCleanup, onMount } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { webviewZoom } from "./webview-zoom" import "./styles.css" +import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme" const root = document.getElementById("root") @@ -75,25 +77,26 @@ const createPlatform = (): Platform => { return undefined })() - const isWslEnabled = async () => { - if (os !== "windows") return false - return window.api - .getWslConfig() - .then((config) => config.enabled) - .catch(() => false) + const activeWslDistro = () => { + const key = window.__OPENCODE__?.activeServer + if (!key || !key.startsWith("wsl:")) return undefined + return key.slice("wsl:".length) } const wslHome = async () => { - if (!(await isWslEnabled())) return undefined - return window.api.wslPath("~", "windows").catch(() => undefined) + const distro = activeWslDistro() + if (!distro) return undefined + return window.api.wslPath("~", "windows", distro) } - const handleWslPicker = async (result: T | null): Promise => { - if (!result || !(await isWslEnabled())) return result + const handleWslPicker = async (result: T): Promise => { + const distro = activeWslDistro() + if (!result || !distro) return result + const convert = (path: string) => window.api.wslPath(path, "linux", distro) if (Array.isArray(result)) { - return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any + return (await Promise.all(result.map(convert))) as T } - return window.api.wslPath(result, "linux").catch(() => result) as any + return (await convert(result)) as T } const storage = (() => { @@ -123,6 +126,8 @@ const createPlatform = (): Platform => { } })() + const wslServersApi = os === "windows" ? window.api.wslServers : undefined + return { platform: "desktop", os, @@ -163,10 +168,8 @@ const createPlatform = (): Platform => { if (os === "windows") { const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null const resolvedPath = await (async () => { - if (await isWslEnabled()) { - const converted = await window.api.wslPath(path, "windows").catch(() => null) - if (converted) return converted - } + const distro = activeWslDistro() + if (distro) return window.api.wslPath(path, "windows", distro) return path })() return window.api.openPath(resolvedPath, resolvedApp ?? undefined) @@ -217,16 +220,7 @@ const createPlatform = (): Platform => { } }, - fetch: (input, init) => { - if (input instanceof Request) return fetch(input) - return fetch(input, init) - }, - - getWslEnabled: () => isWslEnabled(), - - setWslEnabled: async (enabled) => { - await window.api.setWslConfig({ enabled }) - }, + fetch, getDefaultServer: async () => { const url = await window.api.getDefaultServerUrl().catch(() => null) @@ -238,6 +232,8 @@ const createPlatform = (): Platform => { await window.api.setDefaultServerUrl(url) }, + wslServers: wslServersApi, + getDisplayBackend: async () => { return window.api.getDisplayBackend().catch(() => null) }, @@ -273,7 +269,6 @@ listenForDeepLinks() render(() => { const platform = createPlatform() - const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))) const loadLocale = async () => { const current = await platform.storage?.("opencode.global.dat").getItem("language") const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") @@ -288,32 +283,11 @@ render(() => { const [windowCount] = createResource(() => window.api.getWindowCount()) - // Fetch sidecar credentials (available immediately, before health check) const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) - const [defaultServer] = createResource(() => - platform.getDefaultServer?.().then((url) => { - if (url) return ServerConnection.key({ type: "http", http: { url } }) - }), - ) + const [defaultServer] = createResource(() => platform.getDefaultServer?.()) const [locale] = createResource(loadLocale) - const servers = () => { - const data = sidecar() - if (!data) return [] - const server: ServerConnection.Sidecar = { - displayName: "Local Server", - type: "sidecar", - variant: "base", - http: { - url: data.url, - username: data.username ?? undefined, - password: data.password ?? undefined, - }, - } - return [server] as ServerConnection.Any[] - } - function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null if (link?.href) { @@ -340,6 +314,66 @@ render(() => { return null } + function App() { + const wslServers = useWslServers() + const splash = ( +
+ +
+ ) + + const ready = createMemo( + () => + !defaultServer.loading && + !sidecar.loading && + !windowCount.loading && + !locale.loading, + ) + const servers = createMemo(() => { + const data = sidecar() + const list: ServerConnection.Any[] = [] + if (data) { + list.push({ + displayName: "Local Server", + type: "sidecar", + variant: "base", + http: { + url: data.url, + username: data.username ?? undefined, + password: data.password ?? undefined, + }, + }) + } + for (const item of wslServers.data?.servers ?? []) { + const runtime = item.runtime + if (runtime.kind !== "ready") continue + list.push({ + displayName: item.config.distro, + type: "sidecar", + variant: "wsl", + distro: item.config.distro, + http: { + url: runtime.url, + username: runtime.username ?? undefined, + password: runtime.password ?? undefined, + }, + }) + } + return list + }) + if (!ready()) return splash + + return ( + + + + ) + } + onMount(() => { document.addEventListener("click", handleClick) onCleanup(() => { @@ -350,27 +384,7 @@ render(() => { return ( - - {(_) => { - return ( - - - - ) - }} - + ) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 1a0da014dd43..021b9b467118 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -71,16 +71,11 @@ const createPlatform = (): Platform => { })() const wslHome = async () => { - if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined - return commands.wslPath("~", "windows").catch(() => undefined) + return undefined } const handleWslPicker = async (result: T | null): Promise => { - if (!result || !window.__OPENCODE__?.wsl) return result - if (Array.isArray(result)) { - return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any - } - return commands.wslPath(result, "linux").catch(() => result) as any + return result } return { @@ -349,16 +344,6 @@ const createPlatform = (): Platform => { } }, - getWslEnabled: async () => { - const next = await commands.getWslConfig().catch(() => null) - if (next) return next.enabled - return window.__OPENCODE__!.wsl ?? false - }, - - setWslEnabled: async (enabled) => { - await commands.setWslConfig({ enabled }) - }, - getDefaultServer: async () => { const url = await commands.getDefaultServerUrl().catch(() => null) if (!url) return null