mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 20:05:23 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
483
packages/desktop-electron/src/main/wsl-servers.ts
Normal file
483
packages/desktop-electron/src/main/wsl-servers.ts
Normal 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,
|
||||
}
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,8 +5,8 @@ declare global {
|
||||
api: ElectronAPI
|
||||
__OPENCODE__?: {
|
||||
updaterEnabled?: boolean
|
||||
wsl?: boolean
|
||||
deepLinks?: string[]
|
||||
activeServer?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user