fix(desktop): few WSL bugs (#31095)
This commit is contained in:
parent
b1d14acc35
commit
65a3f7f749
@ -509,11 +509,16 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
tabs.removeServer(url)
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
void platform.setDefaultServer?.(null)
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) {
|
||||
await setDefault(null)
|
||||
}
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -71,6 +71,17 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
if (!distro) return null
|
||||
return current()?.opencodeChecks[distro] ?? null
|
||||
})
|
||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
||||
const distroReady = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe || !selectedDistro()) return false
|
||||
if (selectedInstalled()?.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
})
|
||||
const opencodeReady = createMemo(() => {
|
||||
const check = opencodeCheck()
|
||||
return !!check?.resolvedPath && !check.error
|
||||
})
|
||||
const distroWarningProbe = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe) return null
|
||||
@ -106,17 +117,6 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
const job = current()?.job
|
||||
return job?.kind === "install-opencode" && job.distro === selectedDistro()
|
||||
})
|
||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
||||
const distroReady = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe || !selectedDistro()) return false
|
||||
if (selectedInstalled()?.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
})
|
||||
const opencodeReady = createMemo(() => {
|
||||
const check = opencodeCheck()
|
||||
return !!check?.resolvedPath && !check.error
|
||||
})
|
||||
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
|
||||
const addDisabled = createMemo(() => {
|
||||
const job = current()?.job
|
||||
|
||||
@ -76,11 +76,7 @@ export function WslServerSettings(props: {
|
||||
}))
|
||||
|
||||
const remove = (key: ServerConnection.Key) => {
|
||||
if (!api) return
|
||||
request.mutate(async () => {
|
||||
await api.removeServer(key)
|
||||
await props.controller.handleRemove(key)
|
||||
})
|
||||
request.mutate(() => props.controller.handleRemove(key))
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -4,8 +4,4 @@ type Channel = "dev" | "beta" | "prod"
|
||||
const raw = import.meta.env.OPENCODE_CHANNEL
|
||||
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
|
||||
|
||||
export const SETTINGS_STORE = "opencode.settings"
|
||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||
|
||||
@ -145,8 +145,10 @@ const main = Effect.gen(function* () {
|
||||
})
|
||||
},
|
||||
{
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
logger: {
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
},
|
||||
)
|
||||
const stopSidecars = async () => {
|
||||
@ -327,7 +329,9 @@ const main = Effect.gen(function* () {
|
||||
password,
|
||||
})
|
||||
|
||||
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
|
||||
if (process.platform === "win32") {
|
||||
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => health.wait).pipe(
|
||||
Effect.timeout("30 seconds"),
|
||||
|
||||
@ -2,9 +2,10 @@ import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, utilityProcess } from "electron"
|
||||
import type { Details } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY } from "./constants"
|
||||
import { getLogger } from "./logging"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
|
||||
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
@ -43,7 +44,7 @@ export function setDefaultServerUrl(url: string | null) {
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
Object.assign(process.env, {
|
||||
...(shell ? loadShellEnv(shell) : null),
|
||||
...(shell ? loadShellEnv(shell, getLogger()) : null),
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { userInfo } from "node:os"
|
||||
import { basename } from "node:path"
|
||||
import { getLogger } from "./logging"
|
||||
|
||||
const TIMEOUT = 5_000
|
||||
|
||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
||||
type ShellEnvLogger = {
|
||||
log: (message: string) => void
|
||||
}
|
||||
|
||||
export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
|
||||
const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
|
||||
@ -65,8 +67,7 @@ export function isNushell(shell: string) {
|
||||
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
|
||||
}
|
||||
|
||||
export function loadShellEnv(shell: string) {
|
||||
const logger = getLogger()
|
||||
export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
|
||||
if (isNushell(shell)) {
|
||||
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
|
||||
4
packages/desktop/src/main/store-keys.ts
Normal file
4
packages/desktop/src/main/store-keys.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const SETTINGS_STORE = "opencode.settings"
|
||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
@ -1,7 +1,7 @@
|
||||
import Store from "electron-store"
|
||||
import { app } from "electron"
|
||||
import electron from "electron"
|
||||
|
||||
import { SETTINGS_STORE } from "./constants"
|
||||
import { SETTINGS_STORE } from "./store-keys"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
|
||||
@ -14,7 +14,7 @@ export function getStore(name = SETTINGS_STORE) {
|
||||
if (cached) return cached
|
||||
const next = new Store({
|
||||
name,
|
||||
cwd: app.getPath("userData"),
|
||||
cwd: electron.app.getPath("userData"),
|
||||
fileExtension: "",
|
||||
accessPropertiesByDotNotation: false,
|
||||
})
|
||||
|
||||
@ -6,9 +6,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import type { TitlebarTheme } from "../preload/types"
|
||||
import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
|
||||
import { exportDebugLogs, write as writeLog } from "./logging"
|
||||
import { getStore } from "./store"
|
||||
import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
|
||||
import { createUnresponsiveSampler } from "./unresponsive"
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@ -2,8 +2,14 @@ import { app, ipcMain } from "electron"
|
||||
import type { IpcMainInvokeEvent } from "electron"
|
||||
import type { WslServersController } from "./servers"
|
||||
import { requireWslIpcString } from "./policy"
|
||||
import type { WslServersState } from "../../preload/types"
|
||||
|
||||
export function registerWslIpcHandlers(controller: WslServersController) {
|
||||
if (process.platform !== "win32") {
|
||||
registerUnavailableWslIpcHandlers()
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
const unsubscribe = (id: number) => {
|
||||
const off = subscriptions.get(id)
|
||||
@ -62,3 +68,40 @@ export function registerWslIpcHandlers(controller: WslServersController) {
|
||||
controller.startServer(requireWslIpcString("server id", id)),
|
||||
)
|
||||
}
|
||||
|
||||
function registerUnavailableWslIpcHandlers() {
|
||||
const unavailable = () => {
|
||||
throw new Error("WSL is only available on Windows")
|
||||
}
|
||||
const state = (): WslServersState => ({
|
||||
runtime: {
|
||||
available: false,
|
||||
version: null,
|
||||
error: "WSL is only available on Windows",
|
||||
},
|
||||
installed: [],
|
||||
online: [],
|
||||
distroProbes: {},
|
||||
opencodeChecks: {},
|
||||
pendingRestart: false,
|
||||
servers: [],
|
||||
job: null,
|
||||
})
|
||||
|
||||
ipcMain.handle("wsl-servers-subscribe", (event) => {
|
||||
event.sender.send("wsl-servers-event", { type: "state", state: state() })
|
||||
})
|
||||
ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
|
||||
ipcMain.handle("wsl-servers-get-state", () => state())
|
||||
ipcMain.handle("wsl-servers-probe-runtime", unavailable)
|
||||
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-wsl", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-distro", unavailable)
|
||||
ipcMain.handle("wsl-servers-probe-distro", unavailable)
|
||||
ipcMain.handle("wsl-servers-probe-opencode", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-opencode", unavailable)
|
||||
ipcMain.handle("wsl-servers-open-terminal", unavailable)
|
||||
ipcMain.handle("wsl-servers-add", unavailable)
|
||||
ipcMain.handle("wsl-servers-remove", unavailable)
|
||||
ipcMain.handle("wsl-servers-start", unavailable)
|
||||
}
|
||||
|
||||
@ -6,6 +6,10 @@ import {
|
||||
pollWslHealth,
|
||||
wslServerIdsToStartOnInitialize,
|
||||
} from "./startup"
|
||||
import { createWslServersController, type WslServerConfig } from "./servers"
|
||||
|
||||
let persistedServers: WslServerConfig[] = []
|
||||
let releaseOpencodeResolve: (() => void) | undefined
|
||||
|
||||
test("starts every configured WSL server on initialization", () => {
|
||||
expect(
|
||||
@ -91,3 +95,73 @@ test("derives a required Windows restart from the post-install runtime probe", (
|
||||
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
|
||||
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
|
||||
})
|
||||
|
||||
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
|
||||
persistedServers = []
|
||||
releaseOpencodeResolve = undefined
|
||||
const controller = createWslServersController(
|
||||
"1.16.2",
|
||||
async () => ({
|
||||
listener: {
|
||||
stop: () => undefined,
|
||||
onExit: () => undefined,
|
||||
},
|
||||
url: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
}),
|
||||
testControllerOptions(),
|
||||
)
|
||||
|
||||
await controller.addServer("Debian")
|
||||
await waitFor(() => !!releaseOpencodeResolve)
|
||||
await controller.removeServer("wsl:Debian")
|
||||
releaseOpencodeResolve?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(controller.getState().servers).toEqual([])
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
})
|
||||
|
||||
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
|
||||
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
|
||||
releaseOpencodeResolve = undefined
|
||||
const controller = createWslServersController(
|
||||
"1.16.2",
|
||||
async () => new Promise<never>(() => undefined),
|
||||
testControllerOptions(),
|
||||
)
|
||||
|
||||
await controller.initialize()
|
||||
await waitFor(() => !!releaseOpencodeResolve)
|
||||
await controller.removeServer("wsl:Debian")
|
||||
releaseOpencodeResolve?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(controller.getState().servers).toEqual([])
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
})
|
||||
|
||||
async function waitFor(check: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (check()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
throw new Error("Timed out waiting for condition")
|
||||
}
|
||||
|
||||
function testControllerOptions() {
|
||||
return {
|
||||
readServers: () => persistedServers,
|
||||
writeServers: (servers: WslServerConfig[]) => {
|
||||
persistedServers = servers
|
||||
},
|
||||
readCommandVersion: async () => "1.16.2",
|
||||
resolveOpencode: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseOpencodeResolve = resolve
|
||||
})
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import type {
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
} from "../../preload/types"
|
||||
import { WSL_SERVERS_KEY } from "../constants"
|
||||
import { WSL_SERVERS_KEY } from "../store-keys"
|
||||
import { getStore } from "../store"
|
||||
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
|
||||
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
|
||||
@ -43,18 +43,33 @@ type ControllerLogger = {
|
||||
error: (message: string, meta?: unknown) => void
|
||||
}
|
||||
|
||||
type WslServersControllerOptions = {
|
||||
logger?: ControllerLogger
|
||||
readServers?: () => WslServerConfig[]
|
||||
writeServers?: (servers: WslServerConfig[]) => void
|
||||
resolveOpencode?: typeof resolveWslOpencode
|
||||
readCommandVersion?: typeof readWslCommandVersion
|
||||
}
|
||||
|
||||
export type WslServersController = ReturnType<typeof createWslServersController>
|
||||
|
||||
export function wslServerIdForDistro(distro: string) {
|
||||
return `wsl:${distro}`
|
||||
}
|
||||
|
||||
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
|
||||
export function createWslServersController(
|
||||
appVersion: string,
|
||||
spawnSidecar: SpawnSidecar,
|
||||
options?: WslServersControllerOptions,
|
||||
) {
|
||||
let state: WslServersState = initialState()
|
||||
const listeners = new Set<(event: WslServersEvent) => void>()
|
||||
const sidecars = new Map<string, RunningSidecar>()
|
||||
const startAttempts = new Map<string, number>()
|
||||
let jobAbort: AbortController | undefined
|
||||
const logger = options?.logger
|
||||
const readServers = options?.readServers ?? readPersistedServers
|
||||
const writeServers = options?.writeServers ?? writePersistedServers
|
||||
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener({ type: "state", state })
|
||||
@ -66,7 +81,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
}
|
||||
|
||||
const persistServers = (servers: WslServerConfig[]) => {
|
||||
getStore().set(WSL_SERVERS_KEY, { servers })
|
||||
writeServers(servers)
|
||||
}
|
||||
|
||||
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
|
||||
@ -89,7 +104,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
}
|
||||
|
||||
const refreshFromStore = () => {
|
||||
const persisted = readPersistedServers()
|
||||
const persisted = readServers()
|
||||
const items: WslServerItem[] = persisted.map((config) => {
|
||||
const existing = state.servers.find((item) => item.config.id === config.id)
|
||||
return {
|
||||
@ -113,10 +128,50 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
})
|
||||
}
|
||||
|
||||
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
|
||||
const version = resolved ? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts) : null
|
||||
return opencodeCheck(distro, resolved, version, appVersion)
|
||||
}
|
||||
|
||||
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
const resolved = await resolveWslOpencode(distro, opts)
|
||||
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
|
||||
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
|
||||
setOpencodeCheck(distro, await checkOpencode(distro, opts))
|
||||
}
|
||||
|
||||
const hasServer = (id: string, distro: string) => {
|
||||
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
|
||||
}
|
||||
|
||||
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
|
||||
void checkOpencode(distro)
|
||||
.then((check) => {
|
||||
if (!hasServer(id, distro)) return
|
||||
setOpencodeCheck(distro, check)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", { id, distro, message })
|
||||
})
|
||||
}
|
||||
|
||||
const refreshOpencodeChecks = async () => {
|
||||
await Promise.all(
|
||||
state.servers.map((item) =>
|
||||
checkOpencode(item.config.distro)
|
||||
.then((check) => {
|
||||
if (!hasServer(item.config.id, item.config.distro)) return
|
||||
setOpencodeCheck(item.config.distro, check)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", {
|
||||
id: item.config.id,
|
||||
distro: item.config.distro,
|
||||
message,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
|
||||
@ -170,10 +225,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
||||
})
|
||||
void refreshOpencodeCheck(item.config.distro).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
|
||||
})
|
||||
refreshOpencodeCheckBackground(id, item.config.distro)
|
||||
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@ -225,6 +277,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
|
||||
async initialize() {
|
||||
refreshFromStore()
|
||||
void refreshOpencodeChecks()
|
||||
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
|
||||
},
|
||||
|
||||
@ -311,7 +364,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
id,
|
||||
distro,
|
||||
}
|
||||
persistServers([...readPersistedServers(), config])
|
||||
persistServers([...readServers(), config])
|
||||
setState({
|
||||
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
|
||||
})
|
||||
@ -323,7 +376,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
|
||||
invalidateStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
const remaining = readPersistedServers().filter((item) => item.id !== id)
|
||||
const remaining = readServers().filter((item) => item.id !== id)
|
||||
persistServers(remaining)
|
||||
setState({
|
||||
servers: state.servers.filter((item) => item.config.id !== id),
|
||||
@ -371,6 +424,10 @@ function readPersistedServers(): WslServerConfig[] {
|
||||
return []
|
||||
}
|
||||
|
||||
function writePersistedServers(servers: WslServerConfig[]) {
|
||||
getStore().set(WSL_SERVERS_KEY, { servers })
|
||||
}
|
||||
|
||||
function normalizePersistedServer(value: unknown): WslServerConfig[] {
|
||||
if (!value || typeof value !== "object") return []
|
||||
const record = value as Record<string, unknown>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user