This commit is contained in:
LukeParkerDev
2026-04-28 07:57:25 +10:00
parent cea9a9905d
commit ed4a41f1e0
14 changed files with 295 additions and 273 deletions

View File

@@ -47,7 +47,7 @@ function versionOlderThan(current: string | null | undefined, expected: string |
}
interface DialogSelectServerProps {
initialView?: "list" | "add-wsl"
initialView?: "add-wsl"
onNavigateHome?: () => void
}
@@ -76,6 +76,10 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
})
}
function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } {
return conn.type === "sidecar" && conn.variant === "wsl"
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
@@ -182,6 +186,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const wslServers = useWslServers()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
let disposed = false
onCleanup(() => {
disposed = true
})
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
@@ -355,7 +363,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key)
const isSelectable = (conn: ServerConnection.Any) => !isPlaceholderServerUrl(conn.http.url)
const wslRuntime = (conn: ServerConnection.Any) => {
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
if (!isWslSidecar(conn)) return
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
}
const canRetryWsl = (conn: ServerConnection.Any) => {
@@ -390,6 +398,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (disposed) return
for (const [key, value] of Object.entries(results)) {
cachedServerStatus.set(ServerConnection.Key.make(key), value)
}
@@ -404,13 +413,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
})
const wslCheck = (conn: ServerConnection.Any) => {
if (conn.type !== "sidecar" || conn.variant !== "wsl") return null
if (!isWslSidecar(conn)) return null
return wslState()?.opencodeChecks[conn.distro] ?? null
}
const displayVersion = (conn: ServerConnection.Any) => {
if (conn.type === "sidecar" && conn.variant === "wsl") return wslCheck(conn)?.version ?? undefined
return undefined
return wslCheck(conn)?.version ?? undefined
}
async function select(conn: ServerConnection.Any, persist?: boolean) {
@@ -630,17 +638,17 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
}
function handleRemoveWsl(conn: ServerConnection.Any) {
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
if (!isWslSidecar(conn)) return
removeWslMutation.mutate(ServerConnection.key(conn))
}
function handleRetryWsl(conn: ServerConnection.Any) {
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
if (!isWslSidecar(conn)) return
retryWslMutation.mutate(ServerConnection.key(conn))
}
function handleUpdateWsl(conn: ServerConnection.Any) {
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
if (!isWslSidecar(conn)) return
updateWslMutation.mutate(conn.distro)
}
@@ -697,11 +705,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
>
{(i) => {
const key = ServerConnection.key(i)
const isWslSidecar = i.type === "sidecar" && i.variant === "wsl"
const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined
const wsl = isWslSidecar(i)
const wslDistro = wsl ? i.distro : undefined
const blocked = () => !isSelectable(i) || health(key)?.healthy === false
const canChangeDefault = () => canDefault() && i.type !== "ssh"
const canRemove = () => i.type === "http" || isWslSidecar
const canRemove = () => i.type === "http" || wsl
const hasMenuActionsBeforeDelete = () => canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))
const outdated = () => {
const check = wslCheck(i)
@@ -739,7 +747,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
showCredentials
/>
<div class="flex items-center justify-center gap-3 pl-4">
<Show when={isWslSidecar && opencodeAction()}>
<Show when={wsl && opencodeAction()}>
{(label) => (
<Button
variant="secondary"
@@ -782,7 +790,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={isWslSidecar && canRetryWsl(i)}>
<Show when={wsl && canRetryWsl(i)}>
<DropdownMenu.Item onSelect={() => handleRetryWsl(i)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -807,7 +815,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<Show when={canRemove()}>
<DropdownMenu.Item
onSelect={() => {
if (isWslSidecar) {
if (wsl) {
handleRemoveWsl(i)
return
}

View File

@@ -3,10 +3,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { WslServerStep } from "@/context/platform"
import type { WslServersPlatform, WslServerStep } from "@/context/platform"
import { usePlatform } from "@/context/platform"
import { useWslServers } from "@/context/wsl-servers"
@@ -41,6 +41,15 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
})
const current = () => wslServers.data
const wslApi = () => platform.wslServers
const withWslApi = async <T,>(run: (api: WslServersPlatform) => Promise<T>) => {
const api = wslApi()
if (!api) return
return run(api)
}
let disposed = false
onCleanup(() => {
disposed = true
})
const busy = createMemo(() => !!current()?.job || store.adding)
const selectedDistro = () => store.selectedDistro
const selectedProbe = createMemo(() => {
@@ -132,75 +141,39 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
const activeStep = createMemo(() => store.step ?? recommendedStep())
const probeRuntimeMutation = useMutation(() => ({
mutationFn: async () => {
const api = wslApi()
if (!api) return
await api.probeRuntime()
},
mutationFn: () => withWslApi((api) => api.probeRuntime()),
}))
const refreshDistrosMutation = useMutation(() => ({
mutationFn: async () => {
const api = wslApi()
if (!api) return
await api.refreshDistros()
},
mutationFn: () => withWslApi((api) => api.refreshDistros()),
}))
const installWslMutation = useMutation(() => ({
mutationFn: async () => {
const api = wslApi()
if (!api) return
await api.installWsl()
},
mutationFn: () => withWslApi((api) => api.installWsl()),
}))
const installDistroMutation = useMutation(() => ({
mutationFn: async (name: string) => {
const api = wslApi()
if (!api) return
await api.installDistro(name)
},
mutationFn: (name: string) => withWslApi((api) => api.installDistro(name)),
}))
const probeDistroMutation = useMutation(() => ({
mutationFn: async (name: string) => {
const api = wslApi()
if (!api) return
await api.probeDistro(name)
},
mutationFn: (name: string) => withWslApi((api) => api.probeDistro(name)),
}))
const probeOpencodeMutation = useMutation(() => ({
mutationFn: async (name: string) => {
const api = wslApi()
if (!api) return
await api.probeOpencode(name)
},
mutationFn: (name: string) => withWslApi((api) => api.probeOpencode(name)),
}))
const installOpencodeMutation = useMutation(() => ({
mutationFn: async (name: string) => {
const api = wslApi()
if (!api) return
await api.installOpencode(name)
},
mutationFn: (name: string) => withWslApi((api) => api.installOpencode(name)),
}))
const openTerminalMutation = useMutation(() => ({
mutationFn: async (name: string) => {
const api = wslApi()
if (!api) return
await api.openTerminal(name)
},
mutationFn: (name: string) => withWslApi((api) => api.openTerminal(name)),
}))
const addServerMutation = useMutation(() => ({
mutationFn: async (distro: string) => {
const api = wslApi()
if (!api) return
return api.addServer(distro)
},
mutationFn: (distro: string) => withWslApi((api) => api.addServer(distro)),
}))
const autoProbe = createMemo(() => {
@@ -233,6 +206,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
@@ -309,7 +283,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
}
})
const run = async (action: () => Promise<void>) => {
const run = async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
@@ -317,6 +291,12 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = selectedDistro()
if (!distro) return
void run(() => action(distro))
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
@@ -427,11 +407,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="ghost"
size="small"
disabled={busy()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => probeDistroMutation.mutateAsync(distro))
}}
onClick={() => runSelectedDistro((distro) => probeDistroMutation.mutateAsync(distro))}
>
Refresh
</Button>
@@ -560,11 +536,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => openTerminalMutation.mutateAsync(distro))
}}
onClick={() => runSelectedDistro((distro) => openTerminalMutation.mutateAsync(distro))}
>
Open terminal
</Button>
@@ -572,11 +544,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="ghost"
size="large"
disabled={busy() || !selectedDistro()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => probeDistroMutation.mutateAsync(distro))
}}
onClick={() => runSelectedDistro((distro) => probeDistroMutation.mutateAsync(distro))}
>
Refresh
</Button>
@@ -605,11 +573,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="ghost"
size="large"
disabled={busy()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => probeOpencodeMutation.mutateAsync(distro))
}}
onClick={() => runSelectedDistro((distro) => probeOpencodeMutation.mutateAsync(distro))}
>
Refresh
</Button>
@@ -619,11 +583,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="secondary"
size="large"
disabled={busy()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => installOpencodeMutation.mutateAsync(distro))
}}
onClick={() => runSelectedDistro((distro) => installOpencodeMutation.mutateAsync(distro))}
>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>

View File

@@ -352,7 +352,7 @@ function wireMenu() {
}
registerIpcHandlers({
httpFetch: (input) => bridgedHttpFetch(input),
httpFetch: (input) => bridgedHttpFetch(input, readyWslUrls()),
killSidecar: () => killSidecar(),
relaunch: () => relaunchApp(),
awaitInitialization: async (sendStep) => {
@@ -401,6 +401,12 @@ registerIpcHandlers({
setBackgroundColor: (color) => setBackgroundColor(color),
})
function readyWslUrls() {
return wslServers
.getState()
.servers.flatMap((item) => (item.runtime.kind === "ready" ? [item.runtime.url] : []))
}
function killSidecar() {
if (!server) return
server.stop()
@@ -421,13 +427,19 @@ function relaunchApp() {
// silently drops idle loopback sockets, so reusing one hangs until timeout.
// `agent: false` + `Connection: close` forces a fresh TCP connection per
// request, which is the only reliable way to hit a WSL-forwarded port.
function bridgedHttpFetch(input: {
url: string
method: string
headers: Record<string, string>
body?: string
timeoutMs?: number
}): Promise<{
const BRIDGED_HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
const MAX_BRIDGED_HTTP_BODY_BYTES = 25 * 1024 * 1024
function bridgedHttpFetch(
input: {
url: string
method: string
headers: Record<string, string>
body?: string
timeoutMs?: number
},
allowedUrls: string[],
): Promise<{
status: number
statusText: string
headers: Record<string, string>
@@ -445,12 +457,25 @@ function bridgedHttpFetch(input: {
reject(new Error(`httpFetch: only http: is supported (got ${parsed.protocol})`))
return
}
if (!allowedUrls.some((url) => sameOrigin(parsed, url))) {
reject(new Error("httpFetch: url is not an active WSL sidecar"))
return
}
const method = input.method.toUpperCase()
if (!BRIDGED_HTTP_METHODS.has(method)) {
reject(new Error(`httpFetch: unsupported method ${input.method}`))
return
}
if (input.body && Buffer.byteLength(input.body) > MAX_BRIDGED_HTTP_BODY_BYTES) {
reject(new Error(`httpFetch: request body exceeded ${MAX_BRIDGED_HTTP_BODY_BYTES} bytes`))
return
}
const req = nodeHttp.request({
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : 80,
path: `${parsed.pathname}${parsed.search}`,
method: input.method,
method,
headers: { ...input.headers, connection: "close" },
agent: false,
})
@@ -468,7 +493,15 @@ function bridgedHttpFetch(input: {
req.once("response", (res) => {
const chunks: Buffer[] = []
res.on("data", (chunk: Buffer) => chunks.push(chunk))
let bytes = 0
res.on("data", (chunk: Buffer) => {
bytes += chunk.length
if (bytes <= MAX_BRIDGED_HTTP_BODY_BYTES) {
chunks.push(chunk)
return
}
res.destroy(new Error(`httpFetch: response exceeded ${MAX_BRIDGED_HTTP_BODY_BYTES} bytes`))
})
res.once("end", () => {
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(res.headers)) {
@@ -492,6 +525,15 @@ function bridgedHttpFetch(input: {
})
}
function sameOrigin(input: URL, allowed: string) {
try {
const url = new URL(allowed)
return input.protocol === url.protocol && input.hostname === url.hostname && input.port === url.port
} catch {
return false
}
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {

View File

@@ -78,79 +78,147 @@ export function registerIpcHandlers(deps: Deps) {
console.log(`[store ${op}] ${JSON.stringify({ name, key, ...meta })}`)
}
const offWslServers = deps.onWslServersEvent((payload) => {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed()) continue
win.webContents.send("wsl-servers-event", payload)
}
})
app.once("will-quit", offWslServers)
const requireString = (name: string, value: unknown) => {
if (typeof value === "string" && value.length > 0) return value
throw new Error(`Invalid ${name}`)
}
ipcMain.handle(
const trustedSender = (event: IpcMainEvent | IpcMainInvokeEvent) => {
const raw = event.senderFrame?.url ?? event.sender.getURL()
try {
const url = new URL(raw)
if (url.protocol === "oc:" && url.hostname === "renderer") return true
if (!app.isPackaged && (url.hostname === "127.0.0.1" || url.hostname === "localhost")) return true
} catch {
return false
}
return false
}
const requireTrustedSender = (event: IpcMainEvent | IpcMainInvokeEvent) => {
if (trustedSender(event)) return
throw new Error("Untrusted IPC sender")
}
const handle = <Args extends unknown[]>(
channel: string,
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
) => {
ipcMain.handle(channel, (event, ...args) => {
requireTrustedSender(event)
return listener(event, ...(args as Args))
})
}
const on = <Args extends unknown[]>(channel: string, listener: (event: IpcMainEvent, ...args: Args) => void) => {
ipcMain.on(channel, (event, ...args) => {
if (!trustedSender(event)) return
listener(event, ...(args as Args))
})
}
const wslSubscriptions = new Map<number, () => void>()
const unsubscribeWsl = (id: number) => {
const off = wslSubscriptions.get(id)
if (!off) return
off()
wslSubscriptions.delete(id)
}
app.once("will-quit", () => {
for (const off of wslSubscriptions.values()) off()
wslSubscriptions.clear()
})
handle(
"http-fetch",
(
_event: IpcMainInvokeEvent,
input: { url: string; method: string; headers: Record<string, string>; body?: string; timeoutMs?: number },
) => deps.httpFetch(input),
)
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
handle("kill-sidecar", () => deps.killSidecar())
handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
})
ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState())
ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime())
ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros())
ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl())
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallDistro(name),
handle("wsl-servers-subscribe", (event) => {
const id = event.sender.id
if (wslSubscriptions.has(id)) return
wslSubscriptions.set(
id,
deps.onWslServersEvent((payload) => {
if (event.sender.isDestroyed()) {
unsubscribeWsl(id)
return
}
event.sender.send("wsl-servers-event", payload)
}),
)
event.sender.once("destroyed", () => unsubscribeWsl(id))
})
handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id))
handle("wsl-servers-get-state", () => deps.getWslServersState())
handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime())
handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros())
handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl())
handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallDistro(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeDistro(name),
handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeDistro(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeOpencode(name),
handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeOpencode(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallOpencode(name),
handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallOpencode(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersOpenTerminal(name),
handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersOpenTerminal(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(distro))
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(id))
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(id))
ipcMain.handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStopServer(id))
ipcMain.handle("wsl-servers-cancel", () => deps.wslServersCancelJob())
ipcMain.handle(
handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
deps.wslServersAddServer(requireString("distro", distro)),
)
handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
deps.wslServersRemoveServer(requireString("server id", id)),
)
handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
deps.wslServersStartServer(requireString("server id", id)),
)
handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) =>
deps.wslServersStopServer(requireString("server id", id)),
)
handle("wsl-servers-cancel", () => deps.wslServersCancelJob())
handle(
"wsl-servers-update-acknowledgements",
(_event: IpcMainInvokeEvent, id: string, acks: Partial<WslServerAcknowledgements>) =>
deps.wslServersUpdateAcknowledgements(id, acks),
deps.wslServersUpdateAcknowledgements(requireString("server id", id), acks),
)
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
handle("get-window-config", () => deps.getWindowConfig())
handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
handle("get-default-server-url", () => deps.getDefaultServerUrl())
handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
)
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
handle("get-display-backend", () => deps.getDisplayBackend())
handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
)
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle(
handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
handle(
"wsl-path",
(_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) =>
deps.wslPath(path, mode, distro),
)
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
on("loading-window-complete", () => deps.loadingWindowComplete())
handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
handle("check-update", () => deps.checkUpdate())
handle("install-update", () => deps.installUpdate())
handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
const store = getStore(name)
const value = store.get(key)
debugStore("get", name, key, {
@@ -165,27 +233,27 @@ export function registerIpcHandlers(deps: Deps) {
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
})
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
debugStore("set", name, key, { length: value.length })
getStore(name).set(key, value)
})
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
debugStore("delete", name, key)
getStore(name).delete(key)
})
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
getStore(name).clear()
})
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
const store = getStore(name)
return Object.keys(store.store)
})
ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
const store = getStore(name)
return Object.keys(store.store).length
})
ipcMain.handle(
handle(
"open-directory-picker",
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
const result = await dialog.showOpenDialog({
@@ -198,7 +266,7 @@ export function registerIpcHandlers(deps: Deps) {
},
)
ipcMain.handle(
handle(
"open-file-picker",
async (
_event: IpcMainInvokeEvent,
@@ -215,7 +283,7 @@ export function registerIpcHandlers(deps: Deps) {
},
)
ipcMain.handle(
handle(
"save-file-picker",
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
const result = await dialog.showSaveDialog({
@@ -227,11 +295,11 @@ export function registerIpcHandlers(deps: Deps) {
},
)
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
on("open-link", (_event: IpcMainEvent, url: string) => {
void shell.openExternal(url)
})
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
if (!app) return shell.openPath(path)
await new Promise<void>((resolve, reject) => {
const [cmd, args] =
@@ -240,7 +308,7 @@ export function registerIpcHandlers(deps: Deps) {
})
})
ipcMain.handle("read-clipboard-image", () => {
handle("read-clipboard-image", () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null
const buffer = image.toPNG().buffer
@@ -248,34 +316,34 @@ export function registerIpcHandlers(deps: Deps) {
return { buffer, width: size.width, height: size.height }
})
ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
new Notification({ title, body }).show()
})
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
handle("get-window-count", () => BrowserWindow.getAllWindows().length)
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
handle("get-window-focused", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
})
ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
handle("set-window-focus", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.focus()
})
ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
handle("show-window", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.show()
})
ipcMain.on("relaunch", () => {
on("relaunch", () => {
deps.relaunch()
})
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)

View File

@@ -5,7 +5,7 @@ import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl"
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl"
export type HealthCheck = { wait: Promise<void> }
@@ -228,10 +228,6 @@ function prepareServerEnv(password: string) {
Object.assign(process.env, env)
}
function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
function forwardLines(
stream: NodeJS.ReadableStream,
source: WslCommandLine["stream"],

View File

@@ -28,6 +28,7 @@ import {
probeWslRuntime,
readWslCommandVersion,
resolveWslOpencode,
summarize,
upgradeWslOpencode,
wslNeedsRestart,
} from "./wsl"
@@ -53,7 +54,6 @@ export function wslServerIdForDistro(distro: string) {
}
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
const mainLogger: ControllerLogger | undefined = logger
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
@@ -151,6 +151,17 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
}
const refreshDistroLists = async (opts: { signal?: AbortSignal; onLine?: (line: WslCommandLine) => void }) => {
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros(opts),
listOnlineWslDistros(opts),
])
return {
installed: installedResult.status === "fulfilled" ? installedResult.value : [],
online: onlineResult.status === "fulfilled" ? onlineResult.value : [],
}
}
const nextStartAttempt = (id: string) => {
const next = (startAttempts.get(id) ?? 0) + 1
startAttempts.set(id, next)
@@ -172,7 +183,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
await stopServerInternal(id)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "starting" })
mainLogger?.log("wsl sidecar starting", { id, distro: item.config.distro })
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro)
if (!isCurrentStartAttempt(id, attempt)) {
@@ -195,26 +206,26 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
sidecars.delete(id)
const message = startupFailure(code, signal)
setRuntime(id, { kind: "failed", message })
mainLogger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
})
void refreshOpencodeCheck(item.config.distro).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
mainLogger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
})
mainLogger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!isCurrentStartAttempt(id, attempt)) return
if (isMissingDistroError(message)) {
removeMissingServer(id)
mainLogger?.error("wsl server removed after missing distro", { id, distro: item.config.distro, message })
logger?.error("wsl server removed after missing distro", { id, distro: item.config.distro, message })
return
}
setRuntime(id, { kind: "failed", message })
// Without this, an Ubuntu-style silent failure leaves no trace in
// main.log — the controller captures the message in its state but
// nothing surfaces unless the user opens the WSL servers dialog.
mainLogger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
}
}
@@ -274,13 +285,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
async refreshDistros() {
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: "Listing WSL distros" })
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros({ signal: abort.signal, onLine }),
listOnlineWslDistros({ signal: abort.signal, onLine }),
])
const installed = installedResult.status === "fulfilled" ? installedResult.value : []
const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
setState({ installed, online })
setState(await refreshDistroLists({ signal: abort.signal, onLine }))
})
},
@@ -309,16 +314,10 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
throw new Error(message)
}
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros({ signal: abort.signal, onLine }),
listOnlineWslDistros({ signal: abort.signal, onLine }),
])
const installed = installedResult.status === "fulfilled" ? installedResult.value : []
const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
const distros = await refreshDistroLists({ signal: abort.signal, onLine })
const probe = await probeWslDistro(name, { signal: abort.signal, onLine })
setState({
installed,
online,
...distros,
distroProbes: { ...state.distroProbes, [name]: probe },
})
})
@@ -534,14 +533,6 @@ function opencodeCheck(
}
}
function summarize(value: string) {
return value
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
.join("\n")
}
function isMissingDistroError(message: string) {
return /WSL_E_DISTRO_NOT_FOUND|There is no distribution with the supplied name/i.test(message)
}

View File

@@ -186,12 +186,6 @@ export type WslRegistryDistro = {
version: number
}
// Distros that are designed to run as root and don't have a user-level
// first-run setup. Ubuntu/Debian/Kali/etc. all run a first-boot hook that
// prompts for a UNIX username on first invocation; if that never runs,
// wsl.exe -d <distro> hangs silently forever.
const ALWAYS_ROOT_DISTROS = new Set(["docker-desktop", "docker-desktop-data"])
// Read LXSS metadata from the Windows registry. This never invokes
// wsl.exe, so it is safe to call when wsl.exe itself is wedged.
// DefaultUid === 0 on a user-oriented distro means the first-run
@@ -243,20 +237,6 @@ export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise<
return out
}
export type WslFirstRunCheck =
| { status: "ok" }
| { status: "needs-first-run"; defaultUid: number }
| { status: "not-installed" }
export async function checkWslDistroFirstRun(distro: string, opts?: RunWslOptions): Promise<WslFirstRunCheck> {
const distros = await readWslDistrosFromRegistry(opts)
const entry = distros.find((d) => d.name === distro)
if (!entry) return { status: "not-installed" }
if (ALWAYS_ROOT_DISTROS.has(entry.name)) return { status: "ok" }
if (entry.defaultUid === 0) return { status: "needs-first-run", defaultUid: entry.defaultUid }
return { status: "ok" }
}
export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
return runWslInDistro(["sh", "-lc", script], distro, opts)
}
@@ -307,10 +287,6 @@ export async function listOnlineWslDistros(opts?: RunWslOptions) {
return parseOnlineDistros(result.stdout)
}
export async function installWslRuntime(opts?: RunWslOptions) {
return runWsl(["--install", "--no-distribution"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
}
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
const script = [
"$ErrorActionPreference = 'Stop'",
@@ -510,7 +486,7 @@ function firstLine(value: string) {
)
}
function summarize(value: string) {
export function summarize(value: string) {
return value
.split(/\r?\n/g)
.map((line) => line.trim())
@@ -518,7 +494,7 @@ function summarize(value: string) {
.join("\n")
}
function shellEscape(value: string) {
export function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}

View File

@@ -17,7 +17,11 @@ const api: ElectronAPI = {
subscribe: (cb) => {
const handler = (_: unknown, event: WslServersEvent) => cb(event)
ipcRenderer.on("wsl-servers-event", handler)
return () => ipcRenderer.removeListener("wsl-servers-event", handler)
void ipcRenderer.invoke("wsl-servers-subscribe")
return () => {
ipcRenderer.removeListener("wsl-servers-event", handler)
void ipcRenderer.invoke("wsl-servers-unsubscribe")
}
},
probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),

View File

@@ -14,8 +14,6 @@ export type ServerReadyData = {
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type WslServerStep = "wsl" | "distro" | "opencode"
export type WslRuntimeCheck = {
available: boolean
version: string | null

View File

@@ -180,13 +180,14 @@ const createPlatform = (): Platform => {
})
}
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
const distro = activeWslDistro()
if (!result || !distro) return result
const convert = (path: string) => window.api.wslPath(path, "linux", distro).catch(() => path)
if (Array.isArray(result)) {
return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any
return (await Promise.all(result.map(convert))) as T
}
return window.api.wslPath(result, "linux", distro).catch(() => result) as any
return (await convert(result)) as T
}
const storage = (() => {

View File

@@ -67,18 +67,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
#[tauri::command]
#[specta::specta]
pub fn get_wsl_config(_app: AppHandle) -> Result<WslConfig, String> {
// let store = app
// .store(SETTINGS_STORE)
// .map_err(|e| format!("Failed to open settings store: {}", e))?;
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
// let enabled = store
// .get(WSL_ENABLED_KEY)
// .as_ref()
// .and_then(|v| v.as_bool())
// .unwrap_or(false);
let enabled = store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(WslConfig { enabled: false })
Ok(WslConfig { enabled })
}
#[tauri::command]

View File

@@ -76,14 +76,15 @@ const createPlatform = (): Platform => {
return commands.wslPath("~", "windows").catch(() => undefined)
}
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
if (!result) return result
const wsl = await commands.getWslConfig().catch(() => null)
if (!wsl?.enabled) return result
const convert = (path: string) => commands.wslPath(path, "linux").catch(() => path)
if (Array.isArray(result)) {
return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any
return (await Promise.all(result.map(convert))) as T
}
return commands.wslPath(result, "linux").catch(() => result) as any
return (await convert(result)) as T
}
return {

View File

@@ -1,7 +1,6 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js"
import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js"
import { useI18n } from "../context/i18n"
import { DialogContext } from "../context/dialog"
import { IconButton } from "./icon-button"
export interface DialogProps extends ParentProps {
@@ -20,12 +19,6 @@ export interface DialogProps extends ParentProps {
export function Dialog(props: DialogProps) {
const i18n = useI18n()
const dialogCtx = useContext(DialogContext)
createEffect(() => {
if (!dialogCtx) return
if (props.dismissOutside === undefined) return
dialogCtx.active?.setDismissOutside(props.dismissOutside)
})
return (
<div
data-component="dialog"
@@ -49,6 +42,9 @@ export function Dialog(props: DialogProps) {
autofocusEl.focus()
}
}}
onInteractOutside={(e) => {
if (props.dismissOutside === false) e.preventDefault()
}}
>
<Show when={props.title || props.action}>
<div data-slot="dialog-header">

View File

@@ -23,14 +23,10 @@ type Active = {
owner: Owner
onClose?: () => void
setClosing: (closing: boolean) => void
dismissOutside: () => boolean
setDismissOutside: (value: boolean) => void
}
const Context = createContext<ReturnType<typeof init>>()
export const DialogContext = Context
function init() {
const [active, setActive] = createSignal<Active | undefined>()
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -93,17 +89,12 @@ function init() {
const id = Math.random().toString(36).slice(2)
let dispose: (() => void) | undefined
let setClosing: ((closing: boolean) => void) | undefined
let setDismissOutsideSignal: ((value: boolean) => void) | undefined
let dismissOutsideAccessor: (() => boolean) | undefined
const node = runWithOwner(owner, () =>
createRoot((d: () => void) => {
dispose = d
const [closing, setClosingSignal] = createSignal(false)
setClosing = setClosingSignal
const [dismissOutside, setDismissOutside] = createSignal(true)
dismissOutsideAccessor = dismissOutside
setDismissOutsideSignal = setDismissOutside
return (
<Kobalte
modal
@@ -114,12 +105,7 @@ function init() {
}}
>
<Kobalte.Portal>
<Kobalte.Overlay
data-component="dialog-overlay"
onClick={() => {
if (dismissOutside()) close()
}}
/>
<Kobalte.Overlay data-component="dialog-overlay" />
{element()}
</Kobalte.Portal>
</Kobalte>
@@ -127,7 +113,7 @@ function init() {
}),
)
if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return
if (!dispose || !setClosing) return
setActive({
id,
@@ -136,8 +122,6 @@ function init() {
owner,
onClose,
setClosing,
dismissOutside: dismissOutsideAccessor,
setDismissOutside: setDismissOutsideSignal,
})
}
@@ -182,8 +166,5 @@ export function useDialog() {
close() {
ctx.close()
},
setDismissOutside(value: boolean) {
ctx.active?.setDismissOutside(value)
},
}
}