Apply PR #23407: feat: desktop WSL onboarding + happy experience

This commit is contained in:
opencode-agent[bot] 2026-05-23 03:20:58 +00:00
commit 72b4541a52
26 changed files with 2600 additions and 341 deletions

View file

@ -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"
@ -69,7 +70,7 @@ declare global {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
activeServer?: string
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@ -152,11 +153,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
}}
>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
@ -279,11 +282,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 (
<Show when={server.key} keyed>
{props.children}
{(key) => props.children(key)}
</Show>
)
}
@ -303,22 +306,24 @@ export function AppInterface(props: {
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</QueryProvider>
{() => (
<QueryProvider>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</QueryProvider>
)}
</ServerKey>
</ConnectionGate>
</ServerProvider>

View file

@ -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<typeof useLanguage>, 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<ServerConnection.Key, ServerHealth | undefined>,
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,32 @@ export function DialogSelectServer() {
},
}))
const removeWslMutation = useMutation(() => ({
mutationFn: async (key: ServerConnection.Key) => {
await platform.wslServers?.removeServer(key)
return key
},
onSuccess: async (key) => {
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
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 +313,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 +353,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 +361,60 @@ export function DialogSelectServer() {
async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
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 +425,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 +445,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 +467,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 +480,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 +493,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 +525,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 (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
<span>
{isAddWslMode()
? "Add WSL server"
: isAddMode()
? language.t("dialog.server.add.title")
: language.t("dialog.server.edit.title")}
</span>
</div>
)
})
@ -495,37 +551,126 @@ 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 (
<Dialog title={formTitle()}>
<div class="flex flex-1 min-h-0 flex-col gap-2">
<Dialog
title={formTitle()}
fit={isAddWslMode()}
class={isAddWslMode() ? "[&_[data-slot=dialog-body]]:flex-none [&_[data-slot=dialog-body]]:overflow-visible" : undefined}
>
<div class={isAddWslMode() ? "flex flex-col gap-2" : "flex flex-1 min-h-0 flex-col gap-2"}>
<Show
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
<Show
when={isAddWslMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<DialogWslServer onAdded={handleAddedWsl} />
</Show>
}
>
<Show when={nonReadyWslServers().length > 0}>
<div class="px-5">
<div class="bg-surface-base rounded-md overflow-hidden">
<For each={nonReadyWslServers()}>
{(item) => {
const key = ServerConnection.Key.make(item.config.id)
const retryable = () => canRetryWslRuntime(item.runtime.kind)
return (
<div class="min-h-14 p-3 flex items-center gap-3 border-b border-border-weak-base last:border-b-0">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-critical-base": item.runtime.kind === "failed",
"bg-border-weak-base": item.runtime.kind !== "failed",
}}
/>
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="text-14-medium text-text-base truncate">{item.config.distro}</span>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
<Show when={defaultServer.defaultKey() === key}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs shrink-0">
{language.t("dialog.server.status.default")}
</span>
</Show>
<span class="text-12-regular text-text-weak truncate">
{wslRuntimeLabel(item.runtime.kind)}
</span>
</div>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<Show when={retryable()}>
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={retryable() || defaultServer.canDefault()}>
<DropdownMenu.Separator />
</Show>
<DropdownMenu.Item
onSelect={() => removeWslMutation.mutate(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
}}
</For>
</div>
</div>
</Show>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
@ -534,7 +679,7 @@ export function DialogSelectServer() {
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
key={(x) => ServerConnection.key(x)}
onSelect={(x) => {
if (x) void select(x)
}}
@ -543,18 +688,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" || wsl)
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 (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
<ServerHealthIndicator health={health(key)} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
dimmed={blocked()}
status={health(key)}
version={wslCheck(i)?.version ?? undefined}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<Show when={defaultServer.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@ -562,12 +724,32 @@ export function DialogSelectServer() {
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<div class="flex items-center justify-center gap-3 pl-4">
<Show when={wsl && opencodeAction()}>
{(label) => (
<Button
variant="secondary"
size="small"
disabled={!!wslState()?.job}
class="shrink-0"
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => {
e.stopPropagation()
if (wslDistro) updateWslMutation.mutate(wslDistro)
}}
>
<Show when={updating()}>
<Spinner class="size-3.5 shrink-0" />
</Show>
{label()}
</Button>
)}
</Show>
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<Show when={i.type === "http" || i.type === "sidecar"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
@ -579,35 +761,54 @@ export function DialogSelectServer() {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<Show when={i.type === "http"}>
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={wsl && canRetryWsl(i)}>
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && defaultServer.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<Show when={canChangeDefault() && defaultServer.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))}>
<DropdownMenu.Separator />
</Show>
<Show when={canRemove()}>
<DropdownMenu.Item
onSelect={() => {
if (wsl) {
removeWslMutation.mutate(key)
return
}
void handleRemove(key)
}}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@ -621,17 +822,32 @@ export function DialogSelectServer() {
<div class="shrink-0 px-5 pb-5">
<Show
when={isFormMode()}
when={!isAddWslMode() && isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<Show when={!isAddWslMode()}>
<div class="flex items-center gap-2">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<Show when={canAddWsl()}>
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAddWsl}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
Add WSL
</Button>
</Show>
</div>
</Show>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">

View file

@ -0,0 +1,582 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useWslServers } from "@/context/wsl-servers"
type WslServerStep = "wsl" | "distro" | "opencode"
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
interface DialogWslServerProps {
onAdded?: (distro: string) => void | Promise<void>
}
export function DialogWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const wslServers = useWslServers()
const api = platform.wslServers!
const [store, setStore] = createStore({
step: undefined as WslServerStep | undefined,
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
adding: false,
})
const current = () => wslServers.data
let disposed = false
onCleanup(() => {
disposed = true
})
const busy = createMemo(() => !!current()?.job || store.adding)
const selectedProbe = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const visibleInstalledDistros = createMemo(() =>
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
)
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
const opencodeCheck = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady()) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = store.selectedDistro
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return `${distro} is not installed yet.`
return `Open ${distro} once to finish setup.`
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
})
const installableDistros = createMemo(() => {
const online = visibleOnlineDistros()
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const installingOpencode = createMemo(() => {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === store.selectedDistro
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !store.selectedDistro) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
if (!job) return store.adding
return store.adding || job.kind !== "probe-opencode"
})
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
return "opencode"
})
// activeStep falls back to recommendedStep when the user hasn't picked one.
// Once the user clicks a step tab we respect their choice rather than snapping
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const autoProbe = createMemo(() => {
const state = current()
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = store.selectedDistro
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
if (lastAutoProbe === key) lastAutoProbe = null
requestError(language, err)
}
})()
})
createEffect(() => {
const state = current()
const distro = defaultInstalledDistro()
if (!state || !distro || busy()) return
if (store.selectedDistro) return
if (existingServerDistros().has(distro.name)) return
setStore("selectedDistro", distro.name)
})
createEffect(() => {
const distros = installableDistros()
if (!distros.length) {
if (store.installTarget) setStore("installTarget", undefined)
return
}
if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
setStore("installTarget", distros[0]!.name)
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.kind === "runtime") return "Checking WSL..."
if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
return state.runtime?.error ?? "WSL is required to continue."
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return "Checking distros..."
const distro = store.selectedDistro
if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
if (state.job?.kind === "distros") return "Listing distros..."
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
if (distro) return `Finishing setup for ${distro}.`
return "Pick a distro or install one below."
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return "Checking OpenCode..."
const distro = store.selectedDistro
if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
}
if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
})
const run = async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
requestError(language, err)
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = store.selectedDistro
if (!distro) return
void run(() => action(distro))
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
}
const finish = async () => {
const distro = store.selectedDistro
if (!distro) return
setStore("adding", true)
try {
await api.addServer(distro)
if (props.onAdded) {
await props.onAdded(distro)
} else {
dialog.close()
}
} catch (err) {
requestError(language, err)
} finally {
setStore("adding", false)
}
}
const steps = createMemo(() => {
const active = activeStep()
const activeIndex = STEPS.indexOf(active)
const recommendedIndex = STEPS.indexOf(recommendedStep())
return STEPS.map((step) => {
const index = STEPS.indexOf(step)
return {
step,
title: step === "wsl" ? "WSL" : step === "distro" ? "Choose distro" : "OpenCode",
state:
active === step
? "current"
: step === "wsl"
? wslReady()
? "done"
: "warning"
: step === "distro"
? distroReady()
? "done"
: index > activeIndex
? "locked"
: "warning"
: opencodeCheck()?.matchesDesktop === false
? "warning"
: opencodeReady()
? "done"
: index > activeIndex
? "locked"
: "warning",
locked: index > recommendedIndex,
}
})
})
const loadError = createMemo(() => {
const error = wslServers.error
if (!error) return "Failed to load WSL state."
return error instanceof Error ? error.message : String(error)
})
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Switch>
<Match when={activeStep() === "wsl"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">WSL</div>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => api.installWsl())}
>
Install WSL
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
Relaunch OpenCode
</Button>
</div>
</Show>
<div class="flex items-center justify-end">
<Button variant="secondary" size="large" disabled={busy() || !wslReady()} onClick={() => setStore("step", "distro")}>
Next
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "distro"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">Choose a distro</div>
<Show when={store.selectedDistro}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
<Show
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{visibleInstalledDistros().length
? "All installed distros are already added."
: current()?.runtime?.available
? "No distros detected yet."
: "Checking distros..."}
</div>
}
>
<For each={addableInstalledDistros()}>
{(item) => (
<button
type="button"
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
classList={{ "bg-surface-raised-base": store.selectedDistro === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<Show when={item.isDefault}>
<div class="text-12-regular text-text-weak">Default</div>
</Show>
</button>
)}
</For>
</Show>
</div>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">Install</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro() ? "Installing..." : "Install"}
</Button>
</div>
</div>
<div
role="radiogroup"
aria-label="Install distro"
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => store.installTarget === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show
when={
selectedInstalled()?.version === 1 ||
distroUnavailableMessage() ||
distroMissingTools()
}
>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">This distro needs bash and curl.</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
Open terminal
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !store.selectedDistro}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
</div>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !store.selectedDistro || !distroReady()}
onClick={() => setStore("step", "opencode")}
>
Next
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "opencode"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">OpenCode</div>
<div class="flex items-center gap-2">
<Show when={store.selectedDistro}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
Refresh
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
<Show when={installingOpencode()}>
<Spinner class="size-4 shrink-0" />
</Show>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">Path: {check().resolvedPath ?? "not found"}</div>
<div class="text-12-regular text-text-weak">
Version: {check().version ?? "unknown"}
<Show when={check().expectedVersion}>
{(expected) => <span>{` · desktop ${expected()}`}</span>}
</Show>
</div>
<div class="text-12-regular text-text-warning-base">
Installed version does not match the desktop app version.
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<Show when={activeStep() === "opencode" && allReady() && store.selectedDistro}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
{store.adding ? "Adding..." : "Add WSL server"}
</Button>
</div>
</Show>
</Show>
</Show>
</div>
)
}
function requestError(language: ReturnType<typeof useLanguage>, 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),
})
}

View file

@ -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 = () => (
<span class="flex items-center gap-2">
<span>{serverName(props.conn, true)}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-weak">v{props.status?.version}</span>
<Show when={isWsl()}>
<span class="text-text-invert-weak">WSL</span>
</Show>
<Show when={version()}>
<span class="text-text-invert-weak">v{version()}</span>
</Show>
</span>
)
@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) {
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
{name()}
</span>
<Show when={isWsl()}>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
</Show>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<Show when={version()}>
<span
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
v{props.status?.version}
v{version()}
</span>
</Show>
}

View file

@ -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"
@ -166,13 +166,14 @@ const useMcpToggleMutation = () => {
}))
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean>; 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({
@ -261,8 +262,16 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
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)
}}
>
<ServerHealthIndicator health={health[key]} />

View file

@ -62,7 +62,7 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown} />
<Body shown={shown} close={() => setShown(false)} />
</Suspense>
</Show>
</Popover>

View file

@ -381,11 +381,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") {

View file

@ -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) {
@ -331,6 +342,7 @@ export function createChildStoreManager(input: {
unpin,
pinned,
disposeDirectory,
disposeAll,
runEviction,
vcsCache,
metaCache,

View file

@ -20,6 +20,88 @@ export type FatalRendererErrorLog = {
os?: DesktopOS
}
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<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersPlatform = {
getState(): Promise<WslServersState>
subscribe(cb: (event: WslServersEvent) => void): () => void
probeRuntime(): Promise<void>
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
probeDistro(name: string): Promise<void>
probeOpencode(name: string): Promise<void>
installOpencode(name: string): Promise<void>
openTerminal(name: string): Promise<void>
addServer(distro: string): Promise<WslServerConfig>
removeServer(id: string): Promise<void>
startServer(id: string): Promise<void>
}
export type Platform = {
/** Platform discriminator */
platform: PlatformName
@ -75,11 +157,8 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null

View file

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

View file

@ -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<WslServersState>({
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
},
})

View file

@ -2,6 +2,24 @@ 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 FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { useWslServers } from "./context/wsl-servers"
export {
type DisplayBackend,
type FatalRendererErrorLog,
type Platform,
PlatformProvider,
type WslDistroProbe,
type WslInstalledDistro,
type WslJob,
type WslOnlineDistro,
type WslOpencodeCheck,
type WslRuntimeCheck,
type WslServerConfig,
type WslServerItem,
type WslServerRuntime,
type WslServersEvent,
type WslServersPlatform,
type WslServersState,
} from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View file

@ -480,6 +480,7 @@ function LegacyHome() {
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)
@ -498,7 +499,7 @@ function LegacyHome() {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
@ -519,7 +520,7 @@ function LegacyHome() {
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
onClick={() => dialog.show(() => <DialogSelectServer onNavigateHome={() => navigate("/")} />)}
>
<div
classList={{

View file

@ -152,6 +152,7 @@ export default function Layout(props: ParentProps) {
}
const colorSchemeLabel = (scheme: ColorScheme) => 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 && !USE_NEW_DESIGN,
@ -1222,7 +1223,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(() => <x.DialogSelectServer />)
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
})
}
@ -1475,7 +1476,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,

View file

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

View file

@ -1,7 +1,8 @@
import { execFile, execFileSync } from "node:child_process"
import { execFile } from "node:child_process"
import { access, readFile, readdir } from "node:fs/promises"
import { dirname, extname, join } from "node:path"
import util from "node:util"
import { resolveWslHome, runWslInDistro } from "./wsl"
const execFilePromise = util.promisify(execFile)
@ -21,20 +22,44 @@ export function resolveAppPath(appName: string) {
return resolveWindowsAppPath(appName)
}
export function wslPath(path: string, mode: "windows" | "linux" | null): string {
// Parses `\\wsl$\<distro>\...` and `\\wsl.localhost\<distro>\...` 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<string> {
if (process.platform !== "win32") return path
// `\\wsl$\<distro>\...` / `\\wsl.localhost\<distro>\...` -> `/<subpath>` 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 })
}

View file

@ -6,6 +6,7 @@ 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_SERVERS_KEY = "wslServers"
export const WSL_ENABLED_KEY = "wslEnabled"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

View file

@ -9,9 +9,10 @@ import { getCACertificates, setDefaultCACertificates } from "node:tls"
import type { Event } from "electron"
import { app, BrowserWindow } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import contextMenu from "electron-context-menu"
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"
@ -20,13 +21,13 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
getDefaultServerUrl,
getWslConfig,
preferAppEnv,
setDefaultServerUrl,
setWslConfig,
spawnLocalServer,
spawnWslSidecar,
type SidecarListener,
} from "./server"
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
import {
createLoadingWindow,
createMainWindow,
@ -35,9 +36,8 @@ import {
setBackgroundColor,
setDockIcon,
} from "./windows"
import { createWslServersController } from "./wsl-servers"
import { migrate } from "./migrate"
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
import { Deferred, Effect, Fiber } from "effect"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@ -145,6 +145,30 @@ const main = Effect.gen(function* () {
logger = initLogging()
initCrashReporter()
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),
},
)
const stopSidecars = async () => {
await killSidecar()
wslServers.stopAll()
}
const relaunch = () => {
void stopSidecars().finally(() => {
app.relaunch()
app.exit(0)
})
}
try {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
} catch (error) {
@ -190,11 +214,11 @@ const main = Effect.gen(function* () {
})
app.on("before-quit", () => {
void killSidecar()
void stopSidecars()
})
app.on("will-quit", () => {
void killSidecar()
void stopSidecars()
})
app.on("child-process-gone", (_event, details) => {
@ -214,7 +238,7 @@ const main = Effect.gen(function* () {
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void killSidecar().finally(() => app.exit(0))
void stopSidecars().finally(() => app.exit(0))
})
}
@ -223,6 +247,7 @@ const main = Effect.gen(function* () {
registerIpcHandlers({
killSidecar: () => killSidecar(),
relaunch,
awaitInitialization: Effect.fnUntraced(
function* (sendStep) {
sendStep(initStep)
@ -239,22 +264,33 @@ const main = Effect.gen(function* () {
},
(e) => Effect.runPromise(e),
),
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: (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: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(killSidecar),
installUpdate: async () => installUpdate(stopSidecars),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
@ -341,6 +377,10 @@ const main = Effect.gen(function* () {
password,
})
void wslServers
.initialize({ defaultServer: getDefaultServerUrl() })
.catch((error) => logger.error("wsl server initialization failed", error))
yield* Effect.promise(() => health.wait).pipe(
Effect.timeout("30 seconds"),
Effect.catch((e) =>
@ -379,7 +419,7 @@ const main = Effect.gen(function* () {
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => {
void checkForUpdates(true, killSidecar)
void checkForUpdates(true, stopSidecars)
},
relaunch: () => {
void killSidecar().finally(() => {

View file

@ -10,7 +10,9 @@ import type {
SqliteMigrationProgress,
TitlebarTheme,
WindowConfig,
WslConfig,
WslServerConfig,
WslServersEvent,
WslServersState,
} from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { getStore } from "./store"
@ -23,18 +25,30 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => Promise<void> | void
relaunch: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getWslServersState: () => Promise<WslServersState> | WslServersState
onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void
wslServersProbeRuntime: () => Promise<void> | void
wslServersRefreshDistros: () => Promise<void> | void
wslServersInstallWsl: () => Promise<void> | void
wslServersInstallDistro: (name: string) => Promise<void> | void
wslServersProbeDistro: (name: string) => Promise<void> | void
wslServersProbeOpencode: (name: string) => Promise<void> | void
wslServersInstallOpencode: (name: string) => Promise<void> | void
wslServersOpenTerminal: (name: string) => Promise<void> | void
wslServersAddServer: (distro: string) => Promise<WslServerConfig> | WslServerConfig
wslServersRemoveServer: (id: string) => Promise<void> | void
wslServersStartServer: (id: string) => Promise<void> | void
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
getWslConfig: () => Promise<WslConfig>
setWslConfig: (config: WslConfig) => Promise<void> | void
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> | void
@ -46,27 +60,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<number, () => 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())
@ -191,8 +267,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())

View file

@ -1,13 +1,15 @@
import { spawn } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import type { SqliteMigrationProgress } from "../preload/types"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import type { SqliteMigrationProgress } from "../preload/types"
export type WslConfig = { enabled: boolean }
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl"
export type HealthCheck = { wait: Promise<void> }
@ -46,15 +48,6 @@ 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 function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
Object.assign(process.env, {
@ -201,6 +194,133 @@ export async function spawnLocalServer(
}
}
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<WslSidecar> {
// 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<never>((_, 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<never>((_, 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,
}
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrl: URL
try {
@ -227,6 +347,46 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
}
function allocatePort() {
return new Promise<number>((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 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}`
}
function createSidecarEnv(): Record<string, string> {
const env = Object.fromEntries(
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),

View file

@ -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<RunningSidecar>
type ControllerLogger = {
log: (message: string, meta?: unknown) => void
error: (message: string, meta?: unknown) => void
}
export type WslServersController = ReturnType<typeof createWslServersController>
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<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
}
const setState = (next: Partial<WslServersState>) => {
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 <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
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(opts?: { defaultServer?: string | null }) {
refreshFromStore()
if (opts?.defaultServer?.startsWith("wsl:")) void startServer(opts.defaultServer)
},
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<WslServerConfig> {
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<string, unknown>
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,
}

View file

@ -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<WslCommandResult>((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<WslCommandResult>((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<WslRuntimeCheck> {
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<WslDistroProbe> {
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<void>((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,
}
}

View file

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

View file

@ -10,7 +10,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<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersAPI = {
getState: () => Promise<WslServersState>
subscribe: (cb: (event: WslServersEvent) => void) => () => void
probeRuntime: () => Promise<void>
refreshDistros: () => Promise<void>
installWsl: () => Promise<void>
installDistro: (name: string) => Promise<void>
probeDistro: (name: string) => Promise<void>
probeOpencode: (name: string) => Promise<void>
installOpencode: (name: string) => Promise<void>
openTerminal: (name: string) => Promise<void>
addServer: (distro: string) => Promise<WslServerConfig>
removeServer: (id: string) => Promise<void>
startServer: (id: string) => Promise<void>
}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
@ -32,17 +112,16 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
wslServers: WslServersAPI
getWindowConfig: () => Promise<WindowConfig>
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
getWslConfig: () => Promise<WslConfig>
setWslConfig: (config: WslConfig) => Promise<void>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>
checkAppExists: (appName: string) => Promise<boolean>
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
storeGet: (name: string, key: string) => Promise<string | null>
storeSet: (name: string, key: string, value: string) => Promise<void>

View file

@ -5,6 +5,7 @@ declare global {
api: ElectronAPI
__OPENCODE__?: {
deepLinks?: string[]
activeServer?: string
}
}
}

View file

@ -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, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } 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")
@ -79,25 +81,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 <T extends string | string[]>(result: T | null): Promise<T | null> => {
if (!result || !(await isWslEnabled())) return result
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
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 runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
@ -143,6 +146,8 @@ const createPlatform = (): Platform => {
}
})()
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
return {
platform: "desktop",
os,
@ -183,10 +188,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)
@ -241,16 +244,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)
@ -262,6 +256,8 @@ const createPlatform = (): Platform => {
await window.api.setDefaultServerUrl(url)
},
wslServers: wslServersApi,
getDisplayBackend: async () => {
return window.api.getDisplayBackend().catch(() => null)
},
@ -303,7 +299,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")
@ -318,32 +313,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) {
@ -370,6 +344,73 @@ render(() => {
return null
}
function App() {
const wslServers = useWslServers()
const splash = (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
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
})
const effectiveDefaultServer = createMemo(() => {
const key = defaultServer.latest ?? ServerConnection.Key.make("sidecar")
if (!key.startsWith("wsl:")) return key
const item = wslServers.data?.servers.find((item) => item.config.id === key)
if (item?.runtime.kind === "ready") return key
return ServerConnection.Key.make("sidecar")
})
if (!ready()) return splash
return (
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={MemoryRouter}>
<Inner />
</AppInterface>
)}
</Show>
)
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
@ -380,27 +421,7 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>
<Show
when={
!defaultServer.loading &&
!sidecar.loading &&
!windowConfig.loading &&
!windowCount.loading &&
!locale.loading
}
>
{(_) => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
>
<Inner />
</AppInterface>
)
}}
</Show>
<App />
</AppBaseProviders>
</PlatformProvider>
)