refactor: treat WSL servers as additive alongside windows local

Local Server is always Windows-native now; WSL lives as a separate list
of one-or-more distro-bound sidecars spawned alongside it. Manage Servers
shows an Add WSL button on Windows, each WSL server appears as its own
row with remove + retry, and the wizard runs scoped to a new distro.
This commit is contained in:
LukeParkerDev
2026-04-17 10:56:28 +10:00
parent 66dfdb933d
commit 12fa782137
17 changed files with 1165 additions and 1441 deletions

View File

@@ -76,7 +76,7 @@ declare global {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
activeServer?: string
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@@ -234,7 +234,7 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const canOpenLocalServer = createMemo(() => !!platform.localServer && server.current?.type === "sidecar")
const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl")
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
@@ -249,18 +249,18 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
<Show when={canOpenLocalServer()}>
<Show when={canManage() && !!platform.wslServers}>
<Button
variant="secondary"
size="large"
class="mt-4"
onClick={() => {
void import("@/components/dialog-select-server").then((x) => {
dialog.show(() => <x.DialogSelectServer initialView="local" />)
dialog.show(() => <x.DialogSelectServer />)
})
}}
>
Open Local Server
Manage servers
</Button>
</Show>
</div>

View File

@@ -5,24 +5,22 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { DialogLocalServer } from "@/components/dialog-local-server"
import { DialogWslServer } from "@/components/dialog-wsl-server"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { type LocalServerConfig, usePlatform } from "@/context/platform"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
interface DialogSelectServerProps {
initialView?: "list" | "local"
initialTargetMode?: "windows" | "wsl"
initialView?: "list" | "add-wsl"
onNavigateHome?: () => void
}
@@ -197,10 +195,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
showForm: false,
status: undefined as boolean | undefined,
},
localServer: {
showPage: props.initialView === "local",
targetMode: props.initialTargetMode as "windows" | "wsl" | undefined,
confirmSwapKey: undefined as ServerConnection.Key | undefined,
addWsl: {
showWizard: props.initialView === "add-wsl",
},
editServer: {
id: undefined as string | undefined,
@@ -430,8 +426,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
)
}
const mode = createMemo<"list" | "local" | "add" | "edit">(() => {
if (store.localServer.showPage) return "local"
const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => {
if (store.addWsl.showWizard) return "add-wsl"
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
@@ -445,12 +441,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const resetForm = () => {
resetAdd()
resetEdit()
setStore("localServer", "showPage", false)
setStore("localServer", "targetMode", undefined)
setStore("addWsl", "showWizard", false)
}
const startAdd = () => {
setStore("localServer", "showPage", false)
setStore("addWsl", "showWizard", false)
resetEdit()
setStore("addServer", {
showForm: true,
@@ -464,7 +459,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
}
const startEdit = (conn: ServerConnection.Http) => {
setStore("localServer", "showPage", false)
setStore("addWsl", "showWizard", false)
resetAdd()
setStore("editServer", {
id: conn.http.url,
@@ -477,65 +472,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
})
}
const startLocal = (targetMode?: "windows" | "wsl") => {
const startAddWsl = () => {
resetAdd()
resetEdit()
setStore("localServer", "showPage", true)
setStore("localServer", "targetMode", targetMode)
setStore("localServer", "confirmSwapKey", undefined)
}
const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(config)
const swapLocalToWindows = async () => {
const localServer = platform.localServer
if (!localServer) return
try {
const state = await localServer.getState()
const config = plainConfig(state.config)
await localServer.setConfig({
...config,
mode: "windows",
distro: null,
onboarding: {
...config.onboarding,
complete: true,
pendingRestart: false,
step: null,
},
})
setStore("localServer", "confirmSwapKey", undefined)
showToast({
variant: "success",
title: "Local Server set to Windows",
description: "Restart OpenCode to finish switching back to Windows.",
persistent: true,
actions: [
{
label: "Restart",
onClick: () => void platform.restart(),
},
],
})
} catch (err) {
console.error("Local Server request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
}
const localSwapLabel = (conn: ServerConnection.Any) => {
if (conn.type !== "sidecar") return ""
return conn.variant === "wsl" ? "Swap to Windows" : "Swap to WSL"
}
const localSwapTarget = (conn: ServerConnection.Any) => {
if (conn.type !== "sidecar") return undefined
return conn.variant === "wsl" ? "windows" : "wsl"
setStore("addWsl", "showWizard", true)
}
const submitForm = () => {
@@ -554,8 +494,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const isLocalMode = createMemo(() => mode() === "local")
const isAddWslMode = createMemo(() => mode() === "add-wsl")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows")
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
@@ -563,8 +504,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>
{isLocalMode()
? "Local Server"
{isAddWslMode()
? "Add WSL server"
: isAddMode()
? language.t("dialog.server.add.title")
: language.t("dialog.server.edit.title")}
@@ -579,21 +520,44 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
resetEdit()
})
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
async function handleRemove(key: ServerConnection.Key) {
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
void platform.setDefaultServer?.(null)
}
}
async function handleRemoveWsl(conn: ServerConnection.Any) {
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
const key = ServerConnection.key(conn)
try {
await platform.wslServers?.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
void platform.setDefaultServer?.(null)
}
} catch (err) {
showRequestError(language, err)
}
}
async function handleRetryWsl(conn: ServerConnection.Any) {
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
try {
await platform.wslServers?.startServer(ServerConnection.key(conn))
} catch (err) {
showRequestError(language, err)
}
}
return (
<Dialog title={formTitle()} dismissOutside={!isLocalMode()}>
<Dialog title={formTitle()} dismissOutside={!isAddWslMode()}>
<div class="flex flex-col gap-2">
<Show
when={!isFormMode()}
fallback={
<Show
when={isLocalMode()}
when={isAddWslMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
@@ -613,7 +577,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
/>
}
>
<DialogLocalServer targetMode={store.localServer.targetMode} />
<DialogWslServer />
</Show>
}
>
@@ -625,7 +589,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
key={(x) => ServerConnection.key(x)}
onSelect={(x) => {
if (x) void select(x)
}}
@@ -634,6 +598,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
>
{(i) => {
const key = ServerConnection.key(i)
const isWslSidecar = i.type === "sidecar" && i.variant === "wsl"
return (
<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">
@@ -654,85 +619,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
showCredentials
/>
<div class="flex items-center justify-center gap-3 pl-4">
<Show when={platform.localServer && i.type === "sidecar"}>
{(() => {
const sidecar = i as ServerConnection.Sidecar
if (sidecar.variant !== "wsl") {
return (
<Button
variant="secondary"
size="small"
class="shrink-0"
onClick={(event: MouseEvent) => {
event.stopPropagation()
startLocal(localSwapTarget(sidecar))
}}
>
{localSwapLabel(sidecar)}
</Button>
)
}
return (
<Popover
open={store.localServer.confirmSwapKey === ServerConnection.key(sidecar)}
onOpenChange={(open) =>
setStore(
"localServer",
"confirmSwapKey",
open ? ServerConnection.key(sidecar) : undefined,
)
}
triggerAs={Button}
triggerProps={{
variant: "secondary",
size: "small",
class: "shrink-0",
onClick: (event: MouseEvent) => event.stopPropagation(),
onPointerDown: (event: PointerEvent) => event.stopPropagation(),
}}
trigger={localSwapLabel(sidecar)}
placement="bottom-end"
portal={false}
>
<div class="flex flex-col gap-3">
<div class="text-13-medium text-text-strong">Use Windows instead?</div>
<div class="text-12-regular text-text-weak">
Restart OpenCode after switching the Local Server back to Windows.
</div>
<div class="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="small"
onClick={(event: MouseEvent) => {
event.stopPropagation()
setStore("localServer", "confirmSwapKey", undefined)
}}
>
Cancel
</Button>
<Button
variant="secondary"
size="small"
onClick={(event: MouseEvent) => {
event.stopPropagation()
void swapLocalToWindows()
}}
>
Use Windows
</Button>
</div>
</div>
</Popover>
)
})()}
</Show>
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<Show when={i.type === "http" || isWslSidecar}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
@@ -744,35 +635,46 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
/>
<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}>
<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={isWslSidecar && store.status[key]?.healthy === false}>
<DropdownMenu.Item onSelect={() => void handleRetryWsl(i)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={i.type === "http" && canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<Show when={i.type === "http" && canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => 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={i.type === "http" || isWslSidecar}>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => (isWslSidecar ? void handleRemoveWsl(i) : 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>
@@ -786,18 +688,31 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<div class="px-5 pb-5">
<Show
when={!isLocalMode() && isFormMode()}
when={!isAddWslMode() && isFormMode()}
fallback={
<Show when={!isLocalMode()}>
<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>
}
>

View File

@@ -1,29 +1,37 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, reconcile, unwrap } from "solid-js/store"
import { createStore, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { LocalServerConfig, LocalServerMode, LocalServerState, LocalServerStep } from "@/context/platform"
import type { WslServerStep, WslServersState } from "@/context/platform"
import { usePlatform } from "@/context/platform"
const WSL_STEPS: LocalServerStep[] = ["wsl", "distro", "opencode", "switch"]
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
interface DialogWslServerProps {
onAdded?: () => void
}
export function DialogWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [store, setStore] = createStore({
state: undefined as LocalServerState | undefined,
state: undefined as WslServersState | undefined,
loading: true,
step: undefined as LocalServerStep | undefined,
step: undefined as WslServerStep | undefined,
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
adding: false,
})
createEffect(() => {
const localServer = platform.localServer
if (!localServer) return
const wslServers = platform.wslServers
if (!wslServers) return
let mounted = true
void localServer
void wslServers
.getState()
.then((state) => {
if (!mounted) return
@@ -34,7 +42,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
requestError(language, err)
setStore("loading", false)
})
const off = localServer.subscribe((event) => {
const off = wslServers.subscribe((event) => {
setStore("state", reconcile(event.state))
setStore("loading", false)
})
@@ -45,25 +53,24 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
})
const current = () => store.state
const localServer = () => platform.localServer
const targetMode = createMemo<"windows" | "wsl">(
() => props.targetMode ?? (current()?.config.mode === "wsl" ? "wsl" : "wsl"),
)
const configuredDistro = createMemo(() => current()?.config.distro ?? null)
const busy = createMemo(() => !!current()?.job)
const wslServers = () => platform.wslServers
const busy = createMemo(() => !!current()?.job || store.adding)
const selectedDistro = () => store.selectedDistro
const selectedProbe = createMemo(() => {
const probe = current()?.checks.distro?.selected
return probe?.name === configuredDistro() ? probe : null
const distro = selectedDistro()
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() =>
(current()?.checks.distro?.installed ?? []).find((item) => item.name === current()?.config.distro),
)
const defaultInstalledDistro = createMemo(
() => (current()?.checks.distro?.installed ?? []).find((item) => item.isDefault) ?? null,
)
const selectedInstalled = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const defaultInstalledDistro = createMemo(() => (current()?.installed ?? []).find((item) => item.isDefault) ?? null)
const opencodeCheck = createMemo(() => {
const check = current()?.checks.opencode
return check?.distro === configuredDistro() ? check : null
const distro = selectedDistro()
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
@@ -73,7 +80,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = configuredDistro()
const distro = selectedDistro()
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return `${distro} is not installed yet.`
return `Open ${distro} once to finish setup.`
@@ -88,36 +95,23 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
const check = opencodeCheck()
return check?.matchesDesktop === false ? check : null
})
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return (current()?.installed ?? []).filter((item) => !existingServerDistros().has(item.name))
})
const installableDistros = createMemo(() => {
const online = current()?.checks.distro?.online ?? []
const installed = new Set((current()?.checks.distro?.installed ?? []).map((item) => item.name))
const online = current()?.online ?? []
const installed = new Set((current()?.installed ?? []).map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
const configuredRuntime = createMemo(() => {
const state = current()
if (!state) return { mode: "windows" as const, distro: null as string | null }
if (state.config.mode === "wsl" && state.config.distro) {
return { mode: "wsl" as const, distro: state.config.distro }
}
return { mode: "windows" as const, distro: null as string | null }
})
const configuredRuntimeLabel = createMemo(() => runtimeLabel(configuredRuntime().mode, configuredRuntime().distro))
const currentRuntimeLabel = createMemo(() =>
runtimeLabel(current()?.runtime.mode ?? "windows", current()?.runtime.distro ?? null),
)
const needsRestart = createMemo(() => {
const state = current()
if (!state) return false
return state.runtime.mode !== configuredRuntime().mode || state.runtime.distro !== configuredRuntime().distro
})
const wslReady = createMemo(() => !!current()?.checks.wsl?.available && !current()?.config.onboarding.pendingRestart)
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !current()?.config.distro) return false
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
@@ -125,15 +119,13 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const recommendedStep = createMemo<LocalServerStep>(() => {
if (targetMode() === "windows") return "switch"
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
if (!opencodeReady()) return "opencode"
return "switch"
return "opencode"
})
const activeStep = createMemo(() => current()?.job?.step ?? store.step ?? recommendedStep())
const activeStep = createMemo(() => store.step ?? recommendedStep())
createEffect(
on(recommendedStep, (next) => {
@@ -143,17 +135,20 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
const autoProbe = createMemo(() => {
const state = current()
if (!state || !localServer() || busy() || targetMode() === "windows") return null
if (state.config.onboarding.pendingRestart) return null
if (!state.checks.wsl) return { key: "wsl", step: "wsl" as const }
if (!state || !wslServers() || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => wslServers()!.probeRuntime() }
if (!wslReady()) return null
if (!state.checks.distro) return { key: "distro:list", step: "distro" as const }
if (state.config.distro && !selectedProbe()) {
return { key: `distro:${state.config.distro}`, step: "distro" as const }
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => wslServers()!.refreshDistros() }
}
if (!state.config.distro || !distroReady()) return null
if (!opencodeCheck()) {
return { key: `opencode:${state.config.distro}`, step: "opencode" as const }
const distro = selectedDistro()
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => wslServers()!.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => wslServers()!.probeOpencode(distro) }
}
return null
})
@@ -163,15 +158,16 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
lastAutoProbe = probe.key
void run(() => localServer()!.runStep(probe.step))
void run(probe.run)
})
createEffect(() => {
const state = current()
const distro = defaultInstalledDistro()
if (!state || !distro || !localServer() || busy() || targetMode() !== "wsl") return
if (state.config.distro) return
void selectDistro(distro.name)
if (!state || !distro || busy()) return
if (selectedDistro()) return
if (existingServerDistros().has(distro.name)) return
setStore("selectedDistro", distro.name)
})
createEffect(() => {
@@ -186,43 +182,43 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.step === "wsl") return "Checking WSL..."
if (state.config.onboarding.pendingRestart) return "Windows needs a restart to finish installing WSL."
if (state.checks.wsl?.available) return state.checks.wsl.version ?? "WSL is ready."
return state.checks.wsl?.error ?? "WSL is required to continue."
if (!state || state.job?.kind === "runtime") return "Checking WSL..."
if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
return state.runtime?.error ?? "WSL is required to continue."
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return "Checking distros..."
if (state.job?.step === "distro") {
if (state.config.distro && !selectedInstalled()) return `Installing ${state.config.distro}...`
return state.config.distro ? `Checking ${state.config.distro}...` : "Checking distros..."
}
const distro = selectedDistro()
if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
if (state.job?.kind === "distros") return "Listing distros..."
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (state.checks.distro?.error && !selectedProbe()) return state.checks.distro.error
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
if (state.config.distro) return `Finishing setup for ${state.config.distro}.`
if (distro) return `Finishing setup for ${distro}.`
return "Pick a distro or install one below."
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return "Checking OpenCode..."
if (state.job?.step === "opencode") {
return state.config.distro ? `Checking OpenCode in ${state.config.distro}...` : "Checking OpenCode..."
const distro = selectedDistro()
if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return state.config.distro ? `Update OpenCode in ${state.config.distro}.` : "Update OpenCode."
return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
}
if (opencodeReady())
return state.config.distro ? `OpenCode is ready in ${state.config.distro}.` : "OpenCode is ready."
return state.config.distro ? `Install OpenCode in ${state.config.distro}.` : "Choose a distro first."
if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
})
const installProgress = createMemo(() => {
const state = current()
if (!state?.job || state.status.kind !== "running") return null
if (!state?.job) return null
const transcript = state.transcript.filter((line) => line.text.trim())
const title = transcript[0]?.text
if (!title?.startsWith("Installing ")) return null
@@ -240,48 +236,30 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
}
}
const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(unwrap(config))
const selectDistro = async (name: string) => {
const state = current()
if (!state || !localServer()) return
const config = plainConfig(state.config)
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", "distro")
await run(() =>
localServer()!.setConfig({
...config,
mode: "wsl",
distro: name,
onboarding: {
...config.onboarding,
complete: false,
step: "distro",
},
}),
)
}
const swapToWindows = async () => {
const state = current()
if (!state || !localServer()) return
const config = plainConfig(state.config)
await run(() =>
localServer()!.setConfig({
...config,
mode: "windows",
distro: null,
onboarding: {
...config.onboarding,
complete: true,
pendingRestart: false,
step: null,
},
}),
)
const finish = async () => {
const distro = selectedDistro()
if (!distro) return
const api = wslServers()
if (!api) return
setStore("adding", true)
try {
await api.addServer(distro)
props.onAdded?.()
dialog.close()
} catch (err) {
requestError(language, err)
} finally {
setStore("adding", false)
}
}
const steps = createMemo(() =>
WSL_STEPS.filter((step) => targetMode() === "wsl" || step === "switch").map((step) => ({
STEPS.map((step) => ({
step,
title: stepTitle(step),
state: stepState(step, {
@@ -290,8 +268,6 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
distroReady: distroReady(),
opencodeReady: opencodeReady(),
opencodeMismatch: opencodeCheck()?.matchesDesktop === false,
switchReady: switchReady(),
needsRestart: needsRestart(),
}),
locked: stepIndex(step) > stepIndex(recommendedStep()),
})),
@@ -299,51 +275,46 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show
when={!store.loading}
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading local server...</div>}
>
<Show when={targetMode() === "wsl"}>
<div class="flex gap-2 overflow-x-auto pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="min-w-[132px] 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>
</Show>
<Show when={!store.loading} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
<div class="flex gap-2 overflow-x-auto pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="min-w-[132px] 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()?.checks.wsl && !wslReady() && !current()?.config.onboarding.pendingRestart}>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installWsl())}
onClick={() => void run(() => wslServers()!.installWsl())}
>
Install WSL
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.config.onboarding.pendingRestart}>
<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()}>
@@ -361,20 +332,24 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
<div class="flex flex-col gap-2">
<Show
when={(current()?.checks.distro?.installed.length ?? 0) > 0}
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{current()?.checks.distro ? "No distros detected yet." : "Checking distros..."}
{current()?.installed.length
? "All installed distros are already added."
: current()?.runtime?.available
? "No distros detected yet."
: "Checking distros..."}
</div>
}
>
<For each={current()?.checks.distro?.installed ?? []}>
<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": current()?.config.distro === item.name }}
onClick={() => void selectDistro(item.name)}
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<div class="text-12-regular text-text-weak">
@@ -396,7 +371,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => localServer()!.installDistro(installTarget()!.name))}
onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))}
>
Install
</Button>
@@ -472,7 +447,11 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => void run(() => localServer()!.openTerminal())}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => wslServers()!.openTerminal(distro))
}}
>
Open terminal
</Button>
@@ -488,7 +467,11 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installOpencode())}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => wslServers()!.installOpencode(distro))
}}
>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
@@ -513,51 +496,6 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
</Show>
</div>
</Match>
<Match when={activeStep() === "switch"}>
<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">Switch</div>
<div class="flex gap-2 shrink-0">
<Show when={targetMode() === "windows" && configuredRuntime().mode !== "windows"}>
<Button variant="secondary" size="large" onClick={() => void swapToWindows()}>
Use Windows
</Button>
</Show>
<Button
variant="secondary"
size="large"
disabled={(targetMode() === "wsl" && !switchReady()) || !needsRestart()}
onClick={() => void platform.restart()}
>
Restart OpenCode
</Button>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">
{targetMode() === "windows"
? needsRestart()
? "Restart OpenCode to switch back to Windows."
: "Windows Local Server is active."
: needsRestart()
? `Restart OpenCode to start using ${configuredRuntimeLabel()}.`
: `${configuredRuntimeLabel()} is active.`}
</div>
<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">
After restart: <span class="text-text-strong">{configuredRuntimeLabel()}</span>
</div>
<div class="text-12-regular text-text-weak">
Using now: <span class="text-text-strong">{currentRuntimeLabel()}</span>
</div>
<Show when={targetMode() === "wsl" && !switchReady()}>
<div class="text-12-regular text-text-warning-base">Complete the earlier steps first.</div>
</Show>
</div>
</div>
</Match>
</Switch>
<Show when={installProgress()}>
@@ -595,7 +533,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
)}
</Show>
<Show when={current()?.status.kind === "failed" && (current()?.transcript.length ?? 0) > 0}>
<Show when={current()?.lastError && (current()?.transcript.length ?? 0) > 0}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
<div class="text-14-medium text-text-strong">Diagnostics</div>
<div class="max-h-56 overflow-y-auto rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular text-text-weak whitespace-pre-wrap break-words">
@@ -603,13 +541,27 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
</div>
</div>
</Show>
<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={!allReady() || !selectedDistro() || store.adding || busy()}
onClick={() => void finish()}
>
{store.adding ? "Adding..." : "Add WSL server"}
</Button>
</div>
</Show>
</div>
)
}
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
console.error("Local Server request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
@@ -617,47 +569,35 @@ function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
})
}
function stepIndex(step: LocalServerStep) {
return WSL_STEPS.indexOf(step)
function stepIndex(step: WslServerStep) {
return STEPS.indexOf(step)
}
function stepTitle(step: LocalServerStep) {
function stepTitle(step: WslServerStep) {
if (step === "wsl") return "WSL"
if (step === "distro") return "Choose distro"
if (step === "opencode") return "OpenCode"
return "Switch"
}
function runtimeLabel(mode: LocalServerMode, distro: string | null) {
if (mode === "windows") return "Windows"
return distro ? `WSL on ${distro}` : "WSL"
return "OpenCode"
}
function stepState(
step: LocalServerStep,
step: WslServerStep,
state: {
active: LocalServerStep
active: WslServerStep
wslReady: boolean
distroReady: boolean
opencodeReady: boolean
opencodeMismatch: boolean
switchReady: boolean
needsRestart: boolean
},
) {
if (state.active === step) return "current"
if (step === "wsl") return state.wslReady ? "done" : "warning"
if (step === "distro")
return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning"
if (step === "opencode")
return state.opencodeMismatch
? "warning"
: state.opencodeReady
? "done"
: stepIndex(step) > stepIndex(state.active)
? "locked"
: "warning"
if (state.switchReady && !state.needsRestart) return "done"
if (stepIndex(step) > stepIndex(state.active)) return "locked"
return "warning"
return state.opencodeMismatch
? "warning"
: state.opencodeReady
? "done"
: stepIndex(step) > stepIndex(state.active)
? "locked"
: "warning"
}

View File

@@ -9,29 +9,25 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
export type LocalServerMode = "windows" | "wsl"
export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch"
export type LocalServerMismatchAcknowledgement = {
path: string
version: string
}
export type LocalServerWslCheck = {
export type WslServerStep = "wsl" | "distro" | "opencode"
export type WslRuntimeCheck = {
available: boolean
version: string | null
status: string | null
error: string | null
}
export type LocalServerInstalledDistro = {
export type WslInstalledDistro = {
name: string
state: string | null
version: number | null
isDefault: boolean
}
export type LocalServerOnlineDistro = {
export type WslOnlineDistro = {
name: string
label: string
}
export type LocalServerDistroProbe = {
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
@@ -40,73 +36,82 @@ export type LocalServerDistroProbe = {
isRoot: boolean | null
error: string | null
}
export type LocalServerDistroCheck = {
installed: LocalServerInstalledDistro[]
online: LocalServerOnlineDistro[]
selected: LocalServerDistroProbe | null
error: string | null
}
export type LocalServerOpencodeCheck = {
distro: string | null
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type LocalServerTranscriptLine = {
export type WslTranscriptLine = {
stream: "stdout" | "stderr" | "system"
text: string
at: number
}
export type LocalServerConfig = {
mode: LocalServerMode
distro: string | null
onboarding: {
step: LocalServerStep | null
complete: boolean
pendingRestart: boolean
}
acknowledgements: {
root: string[]
mismatch: LocalServerMismatchAcknowledgement[]
}
export type WslServerAcknowledgements = {
root: boolean
mismatch: { path: string; version: string } | null
}
export type LocalServerStatus =
| { kind: "idle" }
| { kind: "ready" }
| { kind: "running"; step: LocalServerStep | null }
| { kind: "failed"; step: LocalServerStep | null; message: string }
export type LocalServerState = {
config: LocalServerConfig
runtime: {
key: string
mode: LocalServerMode
distro: string | null
}
status: LocalServerStatus
job: { step: LocalServerStep | null; startedAt: number } | null
checks: {
wsl: LocalServerWslCheck | null
distro: LocalServerDistroCheck | null
opencode: LocalServerOpencodeCheck | null
}
transcript: LocalServerTranscriptLine[]
export type WslServerConfig = {
id: string
distro: string
acknowledgements: WslServerAcknowledgements
}
export type LocalServerEvent = {
type: "state"
state: LocalServerState
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type LocalServerPlatform = {
getState(): Promise<LocalServerState>
setConfig(config: LocalServerConfig): Promise<void>
runStep(step: LocalServerStep): Promise<void>
cancelJob(): Promise<void>
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
transcript: WslTranscriptLine[]
lastError: string | 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>
installOpencode(): Promise<void>
openTerminal(): Promise<void>
subscribe(cb: (event: LocalServerEvent) => void): () => 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>
stopServer(id: string): Promise<void>
cancelJob(): Promise<void>
updateAcknowledgements(id: string, acks: Partial<WslServerAcknowledgements>): Promise<void>
}
export type Platform = {
@@ -164,14 +169,8 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Manage the desktop Local Server lifecycle (desktop only) */
localServer?: LocalServerPlatform
/** 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

@@ -214,6 +214,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
onCleanup(startHealthPolling(current_))
})
createEffect(() => {
const key = state.active
if (typeof window === "undefined") return
window.__OPENCODE__ ??= {}
window.__OPENCODE__.activeServer = key
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo(

View File

@@ -1,19 +1,21 @@
export { AppBaseProviders, AppInterface } from "./app"
export { DialogLocalServer } from "./components/dialog-local-server"
export { DialogWslServer } from "./components/dialog-wsl-server"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export {
type DisplayBackend,
type LocalServerConfig,
type LocalServerEvent,
type LocalServerMode,
type LocalServerOpencodeCheck,
type LocalServerPlatform,
type LocalServerState,
type LocalServerStep,
type Platform,
PlatformProvider,
type WslInstalledDistro,
type WslOnlineDistro,
type WslOpencodeCheck,
type WslServerConfig,
type WslServerItem,
type WslServersEvent,
type WslServersPlatform,
type WslServersState,
type WslServerStep,
} from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -6,5 +6,6 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const LOCAL_SERVER_KEY = "localServer"
export const WSL_SERVERS_KEY = "wslServers"
export const LEGACY_LOCAL_SERVER_KEY = "localServer"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

View File

@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
import { existsSync } from "node:fs"
import { createServer } from "node:net"
import { homedir } from "node:os"
import { join } from "node:path"
import type { Event } from "electron"
@@ -36,14 +35,14 @@ const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, LOCAL_SERVER_KEY, UPDATER_ENABLED } from "./constants"
import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { createLocalServerController } from "./local-server"
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslLocalServer } from "./server"
import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server"
import { store } from "./store"
import { createWslServersController } from "./wsl-servers"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
const initEmitter = new EventEmitter()
@@ -57,7 +56,13 @@ const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
void serverReady.promise.catch(() => undefined)
const localServer = createLocalServerController(app.getVersion())
const wslServers = createWslServersController(app.getVersion(), async (distro) => {
const logger = initLogging()
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
})
const logger = initLogging()
logger.log("app starting", {
@@ -67,8 +72,8 @@ logger.log("app starting", {
logger.log("config paths", {
userData: app.getPath("userData"),
settingsStore: store.path,
localServerKey: LOCAL_SERVER_KEY,
localServer: store.get(LOCAL_SERVER_KEY) ?? null,
wslServersKey: WSL_SERVERS_KEY,
wslServers: store.get(WSL_SERVERS_KEY) ?? null,
})
setupApp()
@@ -107,15 +112,18 @@ function setupApp() {
app.on("before-quit", () => {
killSidecar()
wslServers.stopAll()
})
app.on("will-quit", () => {
killSidecar()
wslServers.stopAll()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
wslServers.stopAll()
app.exit(0)
})
}
@@ -151,57 +159,39 @@ async function initialize() {
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
const port = await allocatePort()
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
const config = localServer.getState().config
const runtime =
config.mode === "wsl" && config.distro
? {
key: `local:wsl:${config.distro}`,
mode: "wsl" as const,
distro: config.distro,
}
: {
key: "local:windows",
mode: "windows" as const,
distro: null,
}
const key = "local:windows"
logger.log("spawning sidecar", { url })
localServer.setRuntime(runtime)
localServer.setStatus({ kind: "running", step: null })
const startupData = {
logger.log("spawning windows sidecar", { url })
const startupData: ServerReadyData = {
url,
username: "opencode",
password,
local: runtime,
local: {
key,
url,
username: "opencode",
password,
},
}
let startupError: Error | null = null
const startup = await (async () => {
try {
if (runtime.mode === "wsl") {
if (!runtime.distro) throw new Error("No WSL distro selected")
return await spawnWslLocalServer(runtime.distro, port, password, {
onLine: (line) =>
logger.log("wsl sidecar startup", { distro: runtime.distro, stream: line.stream, text: line.text }),
})
}
return await spawnLocalServer(hostname, port, password)
} catch (error) {
startupError = asError(error)
localServer.setStatus({
kind: "failed",
step: null,
message: startupError.message,
})
logger.error("local server startup failed", startupError)
logger.error("windows sidecar startup failed", startupError)
return undefined
}
})()
server = startup?.listener ?? null
// Initialize WSL sidecars in parallel; failures do not block app startup.
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error)))
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
@@ -224,16 +214,10 @@ async function initialize() {
}),
])
.then(() => {
localServer.setStatus({ kind: "ready" })
serverReady.resolve(startupData)
})
.catch((error) => {
startupError = asError(error)
localServer.setStatus({
kind: "failed",
step: null,
message: startupError.message,
})
logger.error("sidecar health check failed", startupError)
serverReady.reject(startupError)
})
@@ -321,6 +305,7 @@ function wireMenu() {
reload: () => mainWindow?.reload(),
relaunch: () => {
killSidecar()
wslServers.stopAll()
app.relaunch()
app.exit(0)
},
@@ -342,15 +327,22 @@ registerIpcHandlers({
initEmitter.off("step", listener)
}
},
getLocalServerState: () => localServer.getState(),
setLocalServerConfig: (config) => localServer.setConfig(config),
runLocalServerStep: (step) => localServer.runStep(step),
cancelLocalServerJob: () => localServer.cancelJob(),
installLocalServerWsl: () => localServer.installWsl(),
installLocalServerDistro: (name) => localServer.installDistro(name),
installLocalServerOpencode: () => localServer.installOpencode(),
openLocalServerTerminal: () => localServer.openTerminal(),
onLocalServerEvent: (listener) => localServer.subscribe(listener),
getWslServersState: () => wslServers.getState(),
onWslServersEvent: (listener) => wslServers.subscribe(listener),
wslServersProbeRuntime: () => wslServers.probeRuntime(),
wslServersRefreshDistros: () => wslServers.refreshDistros(),
wslServersInstallWsl: () => wslServers.installWsl(),
wslServersInstallDistro: (name) => wslServers.installDistro(name),
wslServersProbeDistro: (name) => wslServers.probeDistro(name),
wslServersProbeOpencode: (name) => wslServers.probeOpencode(name),
wslServersInstallOpencode: (name) => wslServers.installOpencode(name),
wslServersOpenTerminal: (name) => wslServers.openTerminal(name),
wslServersAddServer: (distro) => wslServers.addServer(distro),
wslServersRemoveServer: (id) => wslServers.removeServer(id),
wslServersStartServer: (id) => wslServers.startServer(id),
wslServersStopServer: (id) => wslServers.stopServer(id),
wslServersCancelJob: () => wslServers.cancelJob(),
wslServersUpdateAcknowledgements: (id, acks) => wslServers.updateAcknowledgements(id, acks),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
getDisplayBackend: async () => null,
@@ -392,29 +384,6 @@ function ensureLoopbackNoProxy() {
upsert("no_proxy")
}
async function getSidecarPort() {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
return await new Promise<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 sqliteFileExists() {
const xdg = process.env.XDG_DATA_HOME
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
@@ -478,6 +447,7 @@ async function checkUpdate() {
async function installUpdate() {
if (!updateReady) return
killSidecar()
wslServers.stopAll()
autoUpdater.quitAndInstall()
}

View File

@@ -4,13 +4,13 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type {
InitStep,
LocalServerConfig,
LocalServerEvent,
LocalServerState,
LocalServerStep,
ServerReadyData,
SqliteMigrationProgress,
TitlebarTheme,
WslServerAcknowledgements,
WslServerConfig,
WslServersEvent,
WslServersState,
} from "../preload/types"
import { getStore } from "./store"
import { setTitlebar } from "./windows"
@@ -23,15 +23,22 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getLocalServerState: () => Promise<LocalServerState> | LocalServerState
setLocalServerConfig: (config: LocalServerConfig) => Promise<void> | void
runLocalServerStep: (step: LocalServerStep) => Promise<void> | void
cancelLocalServerJob: () => Promise<void> | void
installLocalServerWsl: () => Promise<void> | void
installLocalServerDistro: (name: string) => Promise<void> | void
installLocalServerOpencode: () => Promise<void> | void
openLocalServerTerminal: () => Promise<void> | void
onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void
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
wslServersStopServer: (id: string) => Promise<void> | void
wslServersCancelJob: () => Promise<void> | void
wslServersUpdateAcknowledgements: (id: string, acks: Partial<WslServerAcknowledgements>) => Promise<void> | void
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
getDisplayBackend: () => Promise<string | null>
@@ -48,33 +55,48 @@ type Deps = {
}
export function registerIpcHandlers(deps: Deps) {
const offLocalServer = deps.onLocalServerEvent((payload) => {
const offWslServers = deps.onWslServersEvent((payload) => {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed()) continue
win.webContents.send("local-server-event", payload)
win.webContents.send("wsl-servers-event", payload)
}
})
app.once("will-quit", offLocalServer)
app.once("will-quit", offWslServers)
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
})
ipcMain.handle("local-server-get-state", () => deps.getLocalServerState())
ipcMain.handle("local-server-set-config", (_event: IpcMainInvokeEvent, config: LocalServerConfig) =>
deps.setLocalServerConfig(config),
ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState())
ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime())
ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros())
ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl())
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallDistro(name),
)
ipcMain.handle("local-server-run-step", (_event: IpcMainInvokeEvent, step: LocalServerStep) =>
deps.runLocalServerStep(step),
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeDistro(name),
)
ipcMain.handle("local-server-cancel-job", () => deps.cancelLocalServerJob())
ipcMain.handle("local-server-install-wsl", () => deps.installLocalServerWsl())
ipcMain.handle("local-server-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.installLocalServerDistro(name),
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeOpencode(name),
)
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallOpencode(name),
)
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersOpenTerminal(name),
)
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(distro))
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(id))
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(id))
ipcMain.handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStopServer(id))
ipcMain.handle("wsl-servers-cancel", () => deps.wslServersCancelJob())
ipcMain.handle(
"wsl-servers-update-acknowledgements",
(_event: IpcMainInvokeEvent, id: string, acks: Partial<WslServerAcknowledgements>) =>
deps.wslServersUpdateAcknowledgements(id, acks),
)
ipcMain.handle("local-server-install-opencode", () => deps.installLocalServerOpencode())
ipcMain.handle("local-server-open-terminal", () => deps.openLocalServerTerminal())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),

View File

@@ -1,678 +0,0 @@
import type {
LocalServerConfig,
LocalServerDistroCheck,
LocalServerEvent,
LocalServerOpencodeCheck,
LocalServerState,
LocalServerStep,
LocalServerTranscriptLine,
} from "../preload/types"
import { LOCAL_SERVER_KEY } from "./constants"
import { store } from "./store"
import {
installWslDistro,
installWslOpencode,
installWslRuntimeElevated,
listInstalledWslDistros,
listOnlineWslDistros,
openWslTerminal,
probeWslDistro,
probeWslRuntime,
readWslCommandVersion,
resolveWslOpencode,
upgradeWslOpencode,
wslNeedsRestart,
} from "./wsl"
export function defaultLocalServerConfig(): LocalServerConfig {
return {
mode: "windows",
distro: null,
onboarding: {
step: null,
complete: true,
pendingRestart: false,
},
acknowledgements: {
root: [],
mismatch: [],
},
}
}
export function createLocalServerController(appVersion: string) {
let state = toState(readLocalServerConfig())
const listeners = new Set<(event: LocalServerEvent) => void>()
let jobAbort: AbortController | undefined
const emit = (event: LocalServerEvent) => {
for (const listener of listeners) listener(event)
}
const update = (next: LocalServerState) => {
state = next
emit({ type: "state", state })
}
const appendTranscript = (line: Omit<LocalServerTranscriptLine, "at">) => {
update({
...state,
transcript: [...state.transcript, { ...line, at: Date.now() }],
})
}
const clearTranscript = () => {
update({
...state,
transcript: [],
})
}
const persistConfig = (config: LocalServerConfig) => {
const next = normalizeLocalServerConfig(config)
store.set(LOCAL_SERVER_KEY, next)
update({
...state,
config: next,
})
return next
}
return {
getState() {
return state
},
setConfig(config: LocalServerConfig) {
persistConfig(config)
},
subscribe(listener: (event: LocalServerEvent) => void) {
listeners.add(listener)
return () => listeners.delete(listener)
},
async runStep(step: LocalServerStep) {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
clearTranscript()
appendTranscript({ stream: "system", text: `Running local server step: ${step}` })
update({
...state,
job: { step, startedAt: Date.now() },
status: { kind: "running", step },
})
try {
if (step === "wsl") {
const wsl = await probeWslRuntime({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
update({
...state,
job: null,
status: wsl.available
? { kind: "ready" }
: { kind: "failed", step, message: wsl.error ?? "WSL is unavailable" },
checks: {
...state.checks,
wsl,
},
})
return
}
if (step === "distro") {
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
}),
listOnlineWslDistros({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
}),
])
if (jobAbort !== abort) return
const installed = installedResult.status === "fulfilled" ? installedResult.value : []
const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
const selected = state.config.distro
? await probeWslDistro(state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
: null
if (jobAbort !== abort) return
const error = distroError(state.config.distro, installed, selected, installedResult, onlineResult)
const distro: LocalServerDistroCheck = {
installed,
online,
selected,
error,
}
update({
...state,
job: null,
status: error ? { kind: "failed", step, message: error } : { kind: "ready" },
checks: {
...state.checks,
distro,
},
})
return
}
if (step === "opencode") {
if (!state.config.distro) {
update({
...state,
job: null,
status: { kind: "failed", step, message: "No WSL distro selected" },
})
return
}
const resolvedPath = await resolveWslOpencode(state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
const version = resolvedPath
? await readWslCommandVersion(resolvedPath, state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
: null
if (jobAbort !== abort) return
const opencode = opencodeCheck(resolvedPath, version, appVersion, state.config.distro)
update({
...state,
job: null,
status: opencode.error ? { kind: "failed", step, message: opencode.error } : { kind: "ready" },
checks: {
...state.checks,
opencode,
},
})
return
}
update({
...state,
job: null,
status: { kind: "idle" },
})
} catch (error) {
if (jobAbort !== abort) return
if (error instanceof Error && error.name === "AbortError") {
update({
...state,
job: null,
status: { kind: "idle" },
})
return
}
update({
...state,
job: null,
status: { kind: "failed", step, message: error instanceof Error ? error.message : String(error) },
})
} finally {
if (jobAbort === abort) jobAbort = undefined
}
},
cancelJob() {
jobAbort?.abort()
jobAbort = undefined
appendTranscript({ stream: "system", text: "Canceled local server job" })
update({
...state,
job: null,
status: { kind: "idle" },
})
},
async installWsl() {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
clearTranscript()
appendTranscript({ stream: "system", text: "Installing WSL runtime" })
persistConfig({
...state.config,
mode: "wsl",
onboarding: {
...state.config.onboarding,
step: "wsl",
complete: false,
pendingRestart: false,
},
})
update({
...state,
job: { step: "wsl", startedAt: Date.now() },
status: { kind: "running", step: "wsl" },
})
try {
const result = await installWslRuntimeElevated({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
if (result.code !== 0) throw new Error(commandFailure(result, "WSL installation failed"))
const pendingRestart = wslNeedsRestart(result)
const nextConfig = persistConfig({
...state.config,
mode: "wsl",
onboarding: {
...state.config.onboarding,
step: pendingRestart ? "wsl" : "distro",
complete: false,
pendingRestart,
},
})
if (pendingRestart) {
const message = "Windows restart required to finish WSL installation"
update({
...state,
config: nextConfig,
job: null,
status: { kind: "failed", step: "wsl", message },
checks: {
...state.checks,
wsl: {
available: false,
version: null,
status: null,
error: message,
},
},
})
return
}
const wsl = await probeWslRuntime({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
update({
...state,
config: nextConfig,
job: null,
status: wsl.available
? { kind: "ready" }
: { kind: "failed", step: "wsl", message: wsl.error ?? "WSL is unavailable" },
checks: {
...state.checks,
wsl,
},
})
} catch (error) {
if (jobAbort !== abort) return
if (error instanceof Error && error.name === "AbortError") {
update({
...state,
job: null,
status: { kind: "idle" },
})
return
}
update({
...state,
job: null,
status: { kind: "failed", step: "wsl", message: error instanceof Error ? error.message : String(error) },
})
} finally {
if (jobAbort === abort) jobAbort = undefined
}
},
async installDistro(name: string) {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
clearTranscript()
appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` })
persistConfig({
...state.config,
mode: "wsl",
distro: name,
onboarding: {
...state.config.onboarding,
step: "distro",
complete: false,
pendingRestart: false,
},
})
update({
...state,
job: { step: "distro", startedAt: Date.now() },
status: { kind: "running", step: "distro" },
})
try {
const result = await installWslDistro(name, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
if (result.code !== 0) throw new Error(commandFailure(result, `Failed to install distro: ${name}`))
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
}),
listOnlineWslDistros({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
}),
])
if (jobAbort !== abort) return
const installed = installedResult.status === "fulfilled" ? installedResult.value : []
const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
const selected = await probeWslDistro(name, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
const error = distroError(name, installed, selected, installedResult, onlineResult)
const nextConfig = persistConfig({
...state.config,
mode: "wsl",
distro: name,
onboarding: {
...state.config.onboarding,
step: error ? "distro" : "opencode",
complete: false,
pendingRestart: false,
},
})
update({
...state,
config: nextConfig,
job: null,
status: error ? { kind: "failed", step: "distro", message: error } : { kind: "ready" },
checks: {
...state.checks,
distro: {
installed,
online,
selected,
error,
},
},
})
} catch (error) {
if (jobAbort !== abort) return
if (error instanceof Error && error.name === "AbortError") {
update({
...state,
job: null,
status: { kind: "idle" },
})
return
}
update({
...state,
job: null,
status: { kind: "failed", step: "distro", message: error instanceof Error ? error.message : String(error) },
})
} finally {
if (jobAbort === abort) jobAbort = undefined
}
},
async installOpencode() {
if (!state.config.distro) throw new Error("No WSL distro selected")
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
clearTranscript()
appendTranscript({ stream: "system", text: `Installing OpenCode in ${state.config.distro}` })
update({
...state,
job: { step: "opencode", startedAt: Date.now() },
status: { kind: "running", step: "opencode" },
})
try {
const resolvedPath = await resolveWslOpencode(state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
const currentVersion = resolvedPath
? await readWslCommandVersion(resolvedPath, state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
: null
if (jobAbort !== abort) return
const result =
resolvedPath && currentVersion
? await upgradeWslOpencode(appVersion, resolvedPath, state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
: await installWslOpencode(appVersion, state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
if (result.code !== 0) throw new Error(commandFailure(result, "OpenCode installation failed"))
const nextPath = await resolveWslOpencode(state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
const nextVersion = nextPath
? await readWslCommandVersion(nextPath, state.config.distro, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
: null
if (jobAbort !== abort) return
const opencode = opencodeCheck(nextPath, nextVersion, appVersion, state.config.distro)
update({
...state,
job: null,
status: opencode.error ? { kind: "failed", step: "opencode", message: opencode.error } : { kind: "ready" },
checks: {
...state.checks,
opencode,
},
})
} catch (error) {
if (jobAbort !== abort) return
if (error instanceof Error && error.name === "AbortError") {
update({
...state,
job: null,
status: { kind: "idle" },
})
return
}
update({
...state,
job: null,
status: { kind: "failed", step: "opencode", message: error instanceof Error ? error.message : String(error) },
})
} finally {
if (jobAbort === abort) jobAbort = undefined
}
},
async openTerminal() {
if (!state.config.distro) throw new Error("No WSL distro selected")
await openWslTerminal(state.config.distro)
},
setRuntime(runtime: LocalServerState["runtime"]) {
update({
...state,
runtime,
})
},
setStatus(status: LocalServerState["status"]) {
update({
...state,
status,
})
},
}
}
function readLocalServerConfig() {
return normalizeLocalServerConfig(store.get(LOCAL_SERVER_KEY))
}
function toState(config: LocalServerConfig, current?: LocalServerState): LocalServerState {
return {
config,
runtime: current?.runtime ?? windowsRuntime(),
status: current?.status ?? { kind: "idle" },
job: current?.job ?? null,
checks: current?.checks ?? { wsl: null, distro: null, opencode: null },
transcript: current?.transcript ?? [],
}
}
function normalizeLocalServerConfig(value: unknown): LocalServerConfig {
const fallback = defaultLocalServerConfig()
if (!value || typeof value !== "object") return fallback
const record = value as Record<string, unknown>
const mode = record.mode === "wsl" ? "wsl" : "windows"
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
return {
mode,
distro,
onboarding: normalizeOnboarding(record.onboarding, mode),
acknowledgements: normalizeAcknowledgements(record.acknowledgements),
}
}
function normalizeOnboarding(value: unknown, mode: LocalServerConfig["mode"]): LocalServerConfig["onboarding"] {
const record = value && typeof value === "object" ? (value as Record<string, unknown>) : {}
return {
step: normalizeStep(record.step),
complete: typeof record.complete === "boolean" ? record.complete : mode === "windows",
pendingRestart: typeof record.pendingRestart === "boolean" ? record.pendingRestart : false,
}
}
function normalizeAcknowledgements(value: unknown): LocalServerConfig["acknowledgements"] {
const record = value && typeof value === "object" ? (value as Record<string, unknown>) : {}
return {
root: Array.isArray(record.root)
? record.root.filter((item): item is string => typeof item === "string" && item.length > 0)
: [],
mismatch: Array.isArray(record.mismatch)
? record.mismatch.flatMap((item) => {
if (!item || typeof item !== "object") return []
const path = typeof item.path === "string" ? item.path : ""
const version = typeof item.version === "string" ? item.version : ""
if (!path || !version) return []
return [{ path, version }]
})
: [],
}
}
function normalizeStep(value: unknown): LocalServerStep | null {
if (value === "wsl") return value
if (value === "distro") return value
if (value === "opencode") return value
if (value === "switch") return value
return null
}
function localServerKey(config: LocalServerConfig) {
if (config.mode === "windows") return "local:windows"
if (!config.distro) return "local:wsl"
return `local:wsl:${config.distro}`
}
function windowsRuntime(): LocalServerState["runtime"] {
return {
key: localServerKey({
...defaultLocalServerConfig(),
mode: "windows",
}),
mode: "windows",
distro: null,
}
}
function distroError(
configured: string | null,
installed: LocalServerDistroCheck["installed"],
selected: LocalServerDistroCheck["selected"],
installedResult: PromiseSettledResult<LocalServerDistroCheck["installed"]>,
onlineResult: PromiseSettledResult<LocalServerDistroCheck["online"]>,
) {
if (installedResult.status === "rejected") {
return installedResult.reason instanceof Error ? installedResult.reason.message : String(installedResult.reason)
}
if (onlineResult.status === "rejected") {
return onlineResult.reason instanceof Error ? onlineResult.reason.message : String(onlineResult.reason)
}
if (configured && !installed.find((item) => item.name === configured)) {
return `Selected distro is not installed: ${configured}`
}
if (selected?.error) return selected.error
return null
}
function commandFailure(result: { stdout: string; stderr: string }, fallback: string) {
const output = `${result.stderr}\n${result.stdout}`
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
.join("\n")
return output || fallback
}
function opencodeCheck(
resolvedPath: string | null,
version: string | null,
expectedVersion: string,
distro: string | null,
): LocalServerOpencodeCheck {
if (!resolvedPath) {
return {
distro,
resolvedPath: null,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is not installed in the selected distro",
}
}
if (!version) {
return {
distro,
resolvedPath,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is installed but could not run in the selected distro",
}
}
return {
distro,
resolvedPath,
version,
expectedVersion,
matchesDesktop: version ? version === expectedVersion : null,
error: null,
}
}

View File

@@ -1,4 +1,6 @@
import { spawn } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
@@ -21,6 +23,28 @@ export function setDefaultServerUrl(url: string | null) {
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export async function allocatePort() {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
return new Promise<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))
})
})
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
prepareServerEnv(password)
const { Log, Server } = await import("virtual:opencode-server")
@@ -48,21 +72,30 @@ export async function spawnLocalServer(hostname: string, port: number, password:
return { listener, health: { wait } }
}
export async function spawnWslLocalServer(
export type WslSidecar = {
listener: { stop: () => void }
url: string
username: string | null
password: string
}
export async function spawnWslSidecar(
distro: string,
port: number,
password: string,
opts: { onLine?: (line: WslCommandLine) => void } = {},
) {
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
const port = await allocatePort()
const password = randomUUID()
const username = "opencode"
const script = [
"set -euo pipefail",
"export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true",
"export OPENCODE_EXPERIMENTAL_FILEWATCHER=true",
"export OPENCODE_CLIENT=desktop",
`export OPENCODE_SERVER_USERNAME=${shellEscape("opencode")}`,
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
'export XDG_STATE_HOME="$HOME/.local/state"',
`exec ${shellEscape(opencode)} --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`,
@@ -93,26 +126,41 @@ export async function spawnWslLocalServer(
})
})
const wait = Promise.race([
(async () => {
const url = `http://127.0.0.1:${port}`
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) return
}
})(),
exit,
]).finally(() => {
settled = true
const url = `http://127.0.0.1:${port}`
const healthPromise = (async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) return
}
})()
const timeoutMs = opts.healthTimeoutMs ?? 30_000
const timeout = new Promise<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()
},
},
health: { wait },
url,
username,
password,
}
}
@@ -156,7 +204,7 @@ function forwardLines(
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
return `WSL local server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {

View File

@@ -0,0 +1,483 @@
import type {
WslDistroProbe,
WslInstalledDistro,
WslJob,
WslOnlineDistro,
WslOpencodeCheck,
WslRuntimeCheck,
WslServerAcknowledgements,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
WslTranscriptLine,
} from "../preload/types"
import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants"
import { spawnWslSidecar } from "./server"
import { store } from "./store"
import type { WslCommandLine } from "./wsl"
import {
installWslDistro,
installWslOpencode,
installWslRuntimeElevated,
listInstalledWslDistros,
listOnlineWslDistros,
openWslTerminal,
probeWslDistro,
probeWslRuntime,
readWslCommandVersion,
resolveWslOpencode,
upgradeWslOpencode,
wslNeedsRestart,
} from "./wsl"
type RunningSidecar = {
listener: { stop: () => void }
url: string
username: string | null
password: string
}
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
export type WslServersController = ReturnType<typeof createWslServersController>
export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar) {
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
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 appendTranscript = (line: Omit<WslTranscriptLine, "at">) => {
setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] })
}
const clearTranscript = () => setState({ transcript: [] })
const persistServers = (servers: WslServerConfig[]) => {
store.set(WSL_SERVERS_KEY, { servers })
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
setState({ servers: next })
}
const beginJob = (job: WslJob, opts: { keepTranscript?: boolean } = {}): AbortController => {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
if (!opts.keepTranscript) clearTranscript()
setState({ job, lastError: null })
return abort
}
const endJob = (abort: AbortController, error?: Error | null) => {
if (jobAbort !== abort) return
jobAbort = undefined
setState({ job: null, lastError: error?.message ?? null })
}
const onLine = (line: WslCommandLine) => appendTranscript(line)
const refreshFromStore = () => {
const persisted = readPersistedServers()
const items: WslServerItem[] = persisted.map((config) => {
const existing = state.servers.find((item) => item.config.id === config.id)
return {
config,
runtime: existing?.runtime ?? { kind: "stopped" },
}
})
setState({ servers: items })
}
const setRuntime = (id: string, runtime: WslServerRuntime) => {
updateServer(id, (item) => ({ ...item, runtime }))
}
const startServer = async (id: string) => {
const item = state.servers.find((x) => x.config.id === id)
if (!item) return
await stopServerInternal(id)
setRuntime(id, { kind: "starting" })
try {
const sidecar = await spawnSidecar(item.config.distro)
sidecars.set(id, sidecar)
setRuntime(id, {
kind: "ready",
url: sidecar.url,
username: sidecar.username,
password: sidecar.password,
})
} catch (error) {
setRuntime(id, {
kind: "failed",
message: error instanceof Error ? error.message : String(error),
})
}
}
const stopServerInternal = async (id: string) => {
const existing = sidecars.get(id)
if (!existing) return
try {
existing.listener.stop()
} catch {
// ignore stop errors
}
sidecars.delete(id)
}
const runJob = async <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, err)
throw err
}
}
return {
getState() {
return state
},
subscribe(listener: (event: WslServersEvent) => void) {
listeners.add(listener)
return () => listeners.delete(listener)
},
async initialize() {
refreshFromStore()
await Promise.all(state.servers.map((item) => startServer(item.config.id)))
},
async probeRuntime() {
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: "Checking WSL runtime" })
const runtime = await probeWslRuntime({ signal: abort.signal, onLine })
setState({
runtime,
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
})
})
},
async refreshDistros() {
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: "Listing WSL distros" })
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros({ signal: abort.signal, onLine }),
listOnlineWslDistros({ signal: abort.signal, onLine }),
])
const installed = installedResult.status === "fulfilled" ? installedResult.value : []
const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
setState({ installed, online })
})
},
async installWsl() {
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: "Installing WSL runtime" })
const result = await installWslRuntimeElevated({ signal: abort.signal, onLine })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
throw new Error(message)
}
const pendingRestart = wslNeedsRestart(result)
setState({ pendingRestart })
if (!pendingRestart) {
const runtime = await probeWslRuntime({ signal: abort.signal, onLine })
setState({ runtime })
}
})
},
async installDistro(name: string) {
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` })
const result = await installWslDistro(name, { signal: abort.signal, onLine })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
throw new Error(message)
}
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros({ signal: abort.signal, onLine }),
listOnlineWslDistros({ signal: abort.signal, onLine }),
])
const installed = installedResult.status === "fulfilled" ? installedResult.value : []
const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
const probe = await probeWslDistro(name, { signal: abort.signal, onLine })
setState({
installed,
online,
distroProbes: { ...state.distroProbes, [name]: probe },
})
})
},
async probeDistro(name: string) {
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Checking ${name}` })
const probe = await probeWslDistro(name, { signal: abort.signal, onLine })
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
})
},
async probeOpencode(name: string) {
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Checking OpenCode in ${name}` })
const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine })
const version = resolved ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) : null
setState({
opencodeChecks: {
...state.opencodeChecks,
[name]: opencodeCheck(name, resolved, version, appVersion),
},
})
})
},
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Installing OpenCode in ${name}` })
const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine })
const existingVersion = resolved
? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine })
: null
const result =
resolved && existingVersion
? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal, onLine })
: await installWslOpencode(appVersion, name, { signal: abort.signal, onLine })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
}
const nextPath = await resolveWslOpencode(name, { signal: abort.signal, onLine })
const nextVersion = nextPath
? await readWslCommandVersion(nextPath, name, { signal: abort.signal, onLine })
: null
setState({
opencodeChecks: {
...state.opencodeChecks,
[name]: opencodeCheck(name, nextPath, nextVersion, appVersion),
},
})
})
},
async openTerminal(name: string) {
await openWslTerminal(name)
},
async cancelJob() {
jobAbort?.abort()
jobAbort = undefined
appendTranscript({ stream: "system", text: "Canceled" })
setState({ job: null })
},
async addServer(distro: string): Promise<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,
acknowledgements: { root: false, mismatch: null },
}
persistServers([...readPersistedServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
void startServer(id)
return config
},
async removeServer(id: string) {
await stopServerInternal(id)
const remaining = readPersistedServers().filter((item) => item.id !== id)
persistServers(remaining)
setState({ servers: state.servers.filter((item) => item.config.id !== id) })
},
startServer,
async stopServer(id: string) {
await stopServerInternal(id)
setRuntime(id, { kind: "stopped" })
},
async updateAcknowledgements(id: string, acks: Partial<WslServerAcknowledgements>) {
const persisted = readPersistedServers()
const next = persisted.map((config) =>
config.id === id ? { ...config, acknowledgements: { ...config.acknowledgements, ...acks } } : config,
)
persistServers(next)
refreshFromStore()
},
stopAll() {
for (const [id] of sidecars) {
const existing = sidecars.get(id)
try {
existing?.listener.stop()
} catch {
// ignore
}
}
sidecars.clear()
},
}
}
function initialState(): WslServersState {
return {
runtime: null,
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
transcript: [],
lastError: null,
}
}
function readPersistedServers(): WslServerConfig[] {
const existing = store.get(WSL_SERVERS_KEY)
if (existing && typeof existing === "object") {
const record = existing as { servers?: unknown }
const list = Array.isArray(record.servers) ? record.servers : []
return list.flatMap(normalizePersistedServer)
}
const migrated = migrateLegacyLocalServer()
if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated })
return migrated
}
function migrateLegacyLocalServer(): WslServerConfig[] {
const legacy = store.get(LEGACY_LOCAL_SERVER_KEY)
if (!legacy || typeof legacy !== "object") return []
const record = legacy as Record<string, unknown>
if (record.mode !== "wsl") return []
const distro = typeof record.distro === "string" ? record.distro : null
if (!distro) return []
return [
{
id: wslServerIdForDistro(distro),
distro,
acknowledgements: { root: false, mismatch: null },
},
]
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {
if (!value || typeof value !== "object") return []
const record = value as Record<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,
acknowledgements: normalizeAcks(record.acknowledgements),
},
]
}
function normalizeAcks(value: unknown): WslServerAcknowledgements {
const record = value && typeof value === "object" ? (value as Record<string, unknown>) : {}
const mismatch =
record.mismatch && typeof record.mismatch === "object" ? (record.mismatch as Record<string, unknown>) : null
return {
root: record.root === true,
mismatch:
mismatch && typeof mismatch.path === "string" && typeof mismatch.version === "string"
? { path: mismatch.path, version: mismatch.version }
: null,
}
}
function opencodeCheck(
distro: string,
resolvedPath: string | null,
version: string | null,
expectedVersion: string,
): WslOpencodeCheck {
if (!resolvedPath) {
return {
distro,
resolvedPath: null,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is not installed in this distro",
}
}
if (!version) {
return {
distro,
resolvedPath,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is installed but could not run",
}
}
return {
distro,
resolvedPath,
version,
expectedVersion,
matchesDesktop: version === expectedVersion,
error: null,
}
}
function summarize(value: string) {
return value
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
.join("\n")
}
// Re-export types used by callers
export type {
WslInstalledDistro,
WslOnlineDistro,
WslRuntimeCheck,
WslDistroProbe,
WslOpencodeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
}

View File

@@ -1,10 +1,5 @@
import { spawn } from "node:child_process"
import type {
LocalServerDistroProbe,
LocalServerInstalledDistro,
LocalServerOnlineDistro,
LocalServerWslCheck,
} from "../preload/types"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
export type WslCommandLine = {
stream: "stdout" | "stderr"
@@ -135,7 +130,7 @@ export function runWslBash(script: string, distro?: string | null, opts?: RunWsl
return runWslInDistro(["bash", "-lc", script], distro, opts)
}
export async function probeWslRuntime(opts?: RunWslOptions): Promise<LocalServerWslCheck> {
export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
const version = await runWsl(["--version"], opts).catch((error) => ({
code: 1,
signal: null,
@@ -206,7 +201,7 @@ export function wslNeedsRestart(result: WslCommandResult) {
return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`)
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<LocalServerDistroProbe> {
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
code: 1,
signal: null,
@@ -298,7 +293,7 @@ function parseInstalledDistros(output: string) {
state: state || null,
version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
isDefault: marker === "*",
} satisfies LocalServerInstalledDistro,
} satisfies WslInstalledDistro,
]
})
}
@@ -311,7 +306,7 @@ function parseOnlineDistros(output: string) {
if (!match) return []
const [, name, label] = match
if (/^name$/i.test(name)) return []
return [{ name, label: label.trim() } satisfies LocalServerOnlineDistro]
return [{ name, label: label.trim() } satisfies WslOnlineDistro]
})
}

View File

@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI, InitStep, LocalServerEvent, SqliteMigrationProgress } from "./types"
import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types"
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
@@ -11,20 +11,27 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
localServer: {
getState: () => ipcRenderer.invoke("local-server-get-state"),
setConfig: (config) => ipcRenderer.invoke("local-server-set-config", config),
runStep: (step) => ipcRenderer.invoke("local-server-run-step", step),
cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"),
installWsl: () => ipcRenderer.invoke("local-server-install-wsl"),
installDistro: (name) => ipcRenderer.invoke("local-server-install-distro", name),
installOpencode: () => ipcRenderer.invoke("local-server-install-opencode"),
openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"),
wslServers: {
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
subscribe: (cb) => {
const handler = (_: unknown, event: LocalServerEvent) => cb(event)
ipcRenderer.on("local-server-event", handler)
return () => ipcRenderer.removeListener("local-server-event", handler)
const handler = (_: unknown, event: WslServersEvent) => cb(event)
ipcRenderer.on("wsl-servers-event", handler)
return () => ipcRenderer.removeListener("wsl-servers-event", handler)
},
probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
stopServer: (id) => ipcRenderer.invoke("wsl-servers-stop", id),
cancelJob: () => ipcRenderer.invoke("wsl-servers-cancel"),
updateAcknowledgements: (id, acks) => ipcRenderer.invoke("wsl-servers-update-acknowledgements", id, acks),
},
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),

View File

@@ -6,36 +6,33 @@ export type ServerReadyData = {
password: string | null
local: {
key: string
mode: LocalServerMode
distro: string | null
url: string
username: string | null
password: string | null
}
}
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type LocalServerMode = "windows" | "wsl"
export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch"
export type LocalServerMismatchAcknowledgement = {
path: string
version: string
}
export type LocalServerWslCheck = {
export type WslServerStep = "wsl" | "distro" | "opencode"
export type WslRuntimeCheck = {
available: boolean
version: string | null
status: string | null
error: string | null
}
export type LocalServerInstalledDistro = {
export type WslInstalledDistro = {
name: string
state: string | null
version: number | null
isDefault: boolean
}
export type LocalServerOnlineDistro = {
export type WslOnlineDistro = {
name: string
label: string
}
export type LocalServerDistroProbe = {
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
@@ -44,73 +41,82 @@ export type LocalServerDistroProbe = {
isRoot: boolean | null
error: string | null
}
export type LocalServerDistroCheck = {
installed: LocalServerInstalledDistro[]
online: LocalServerOnlineDistro[]
selected: LocalServerDistroProbe | null
error: string | null
}
export type LocalServerOpencodeCheck = {
distro: string | null
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type LocalServerTranscriptLine = {
export type WslTranscriptLine = {
stream: "stdout" | "stderr" | "system"
text: string
at: number
}
export type LocalServerConfig = {
mode: LocalServerMode
distro: string | null
onboarding: {
step: LocalServerStep | null
complete: boolean
pendingRestart: boolean
}
acknowledgements: {
root: string[]
mismatch: LocalServerMismatchAcknowledgement[]
}
export type WslServerAcknowledgements = {
root: boolean
mismatch: { path: string; version: string } | null
}
export type LocalServerStatus =
| { kind: "idle" }
| { kind: "ready" }
| { kind: "running"; step: LocalServerStep | null }
| { kind: "failed"; step: LocalServerStep | null; message: string }
export type LocalServerState = {
config: LocalServerConfig
runtime: {
key: string
mode: LocalServerMode
distro: string | null
}
status: LocalServerStatus
job: { step: LocalServerStep | null; startedAt: number } | null
checks: {
wsl: LocalServerWslCheck | null
distro: LocalServerDistroCheck | null
opencode: LocalServerOpencodeCheck | null
}
transcript: LocalServerTranscriptLine[]
export type WslServerConfig = {
id: string
distro: string
acknowledgements: WslServerAcknowledgements
}
export type LocalServerEvent = {
type: "state"
state: LocalServerState
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type LocalServerAPI = {
getState: () => Promise<LocalServerState>
setConfig: (config: LocalServerConfig) => Promise<void>
runStep: (step: LocalServerStep) => Promise<void>
cancelJob: () => Promise<void>
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
transcript: WslTranscriptLine[]
lastError: string | 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>
installOpencode: () => Promise<void>
openTerminal: () => Promise<void>
subscribe: (cb: (event: LocalServerEvent) => void) => () => 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>
stopServer: (id: string) => Promise<void>
cancelJob: () => Promise<void>
updateAcknowledgements: (id: string, acks: Partial<WslServerAcknowledgements>) => Promise<void>
}
export type LinuxDisplayBackend = "wayland" | "auto"
@@ -122,7 +128,7 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
localServer: LocalServerAPI
wslServers: WslServersAPI
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>

View File

@@ -5,8 +5,8 @@ declare global {
api: ElectronAPI
__OPENCODE__?: {
updaterEnabled?: boolean
wsl?: boolean
deepLinks?: string[]
activeServer?: string
}
}
}

View File

@@ -5,7 +5,6 @@ import {
ACCEPTED_FILE_TYPES,
AppBaseProviders,
AppInterface,
DialogLocalServer,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
@@ -14,10 +13,12 @@ import {
PlatformProvider,
ServerConnection,
useCommand,
type WslServersEvent,
type WslServersState,
} from "@opencode-ai/app"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
@@ -25,8 +26,6 @@ import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme"
@@ -54,27 +53,14 @@ const listenForDeepLinks = () => {
}
function LocalServerStartupError(props: { message: string }) {
const dialog = useDialog()
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-16-medium text-text-strong">Local Server failed to start</p>
<p class="mt-2 text-12-regular text-text-weak whitespace-pre-wrap break-words">{props.message}</p>
<Button
variant="secondary"
size="large"
class="mt-4"
onClick={() =>
dialog.show(() => (
<Dialog title="Local Server" dismissOutside={false}>
<DialogLocalServer />
</Dialog>
))
}
>
Open Local Server
<Button variant="secondary" size="large" class="mt-4" onClick={() => window.api.relaunch()}>
Relaunch
</Button>
</div>
</div>
@@ -90,21 +76,20 @@ const createPlatform = (): Platform => {
return undefined
})()
const wslDistro = async () => {
if (os !== "windows") return
const state = await window.api.localServer.getState().catch(() => null)
if (state?.config.mode !== "wsl") return
return state.config.distro
const activeWslDistro = () => {
const key = window.__OPENCODE__?.activeServer
if (!key || !key.startsWith("wsl:")) return undefined
return key.slice("wsl:".length)
}
const wslHome = async () => {
const distro = await wslDistro()
const distro = activeWslDistro()
if (!distro) return undefined
return window.api.wslPath("~", "windows", distro).catch(() => undefined)
}
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
const distro = await wslDistro()
const distro = activeWslDistro()
if (!result || !distro) return result
if (Array.isArray(result)) {
return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any
@@ -139,6 +124,8 @@ const createPlatform = (): Platform => {
}
})()
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
return {
platform: "desktop",
os,
@@ -179,7 +166,7 @@ const createPlatform = (): Platform => {
if (os === "windows") {
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
const resolvedPath = await (async () => {
const distro = await wslDistro()
const distro = activeWslDistro()
if (distro) {
const converted = await window.api.wslPath(path, "windows", distro).catch(() => null)
if (converted) return converted
@@ -247,17 +234,7 @@ const createPlatform = (): Platform => {
await window.api.setDefaultServerUrl(url)
},
localServer: {
getState: () => window.api.localServer.getState(),
setConfig: (config) => window.api.localServer.setConfig(config),
runStep: (step) => window.api.localServer.runStep(step),
cancelJob: () => window.api.localServer.cancelJob(),
installWsl: () => window.api.localServer.installWsl(),
installDistro: (name) => window.api.localServer.installDistro(name),
installOpencode: () => window.api.localServer.installOpencode(),
openTerminal: () => window.api.localServer.openTerminal(),
subscribe: (cb) => window.api.localServer.subscribe(cb),
},
wslServers: wslServersApi,
getDisplayBackend: async () => {
return window.api.getDisplayBackend().catch(() => null)
@@ -329,22 +306,52 @@ render(() => {
)
const [locale] = createResource(loadLocale)
const [wslServers, setWslServers] = createSignal<WslServersState | null>(null)
if (platform.wslServers) {
void platform.wslServers.getState().then((state) => setWslServers(state))
const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state))
onCleanup(off)
}
const servers = () => {
const data = startup.latest?.sidecar
if (!data) return []
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
...(data.local.mode === "wsl" && data.local.distro
? { variant: "wsl", distro: data.local.distro }
: { variant: "base" }),
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
const list: ServerConnection.Any[] = []
if (data) {
list.push({
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data.local.url,
username: data.local.username ?? undefined,
password: data.local.password ?? undefined,
},
})
}
return [server] as ServerConnection.Any[]
const wsl = wslServers()
if (wsl) {
for (const item of wsl.servers) {
const runtime = item.runtime
const http =
runtime.kind === "ready"
? {
url: runtime.url,
username: runtime.username ?? undefined,
password: runtime.password ?? undefined,
}
: {
url: `http://wsl-${item.config.distro}.invalid`,
}
list.push({
displayName: `WSL: ${item.config.distro}`,
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
http,
})
}
}
return list
}
function handleClick(e: MouseEvent) {