diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts index 1e21661c1a..4619a336ae 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -6,5 +6,5 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod export const SETTINGS_STORE = "opencode.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" -export const WSL_ENABLED_KEY = "wslEnabled" +export const LOCAL_SERVER_KEY = "localServer" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 8f21e5b933..56c9c943fd 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -34,14 +34,15 @@ app.setAppUserModelId(appId) app.setPath("userData", join(app.getPath("appData"), appId)) const { autoUpdater } = pkg -import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" +import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" +import { createLocalServerController } from "./local-server" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" import type { Server } from "virtual:opencode-server" @@ -55,6 +56,7 @@ const loadingComplete = defer() const pendingDeepLinks: string[] = [] const serverReady = defer() +const localServer = createLocalServerController() const logger = initLogging() logger.log("app starting", { @@ -231,10 +233,11 @@ registerIpcHandlers({ initEmitter.off("step", listener) } }, + getLocalServerState: () => localServer.getState(), + setLocalServerConfig: (config) => localServer.setConfig(config), + onLocalServerEvent: (listener) => localServer.subscribe(listener), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), - getWslConfig: () => Promise.resolve(getWslConfig()), - setWslConfig: (config: WslConfig) => setWslConfig(config), getDisplayBackend: async () => null, setDisplayBackend: async () => undefined, parseMarkdown: async (markdown) => parseMarkdown(markdown), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 52d87ed7ee..c837551886 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -2,7 +2,15 @@ import { execFile } from "node:child_process" import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" -import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types" +import type { + InitStep, + LocalServerConfig, + LocalServerEvent, + LocalServerState, + ServerReadyData, + SqliteMigrationProgress, + TitlebarTheme, +} from "../preload/types" import { getStore } from "./store" import { setTitlebar } from "./windows" @@ -14,10 +22,11 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise + getLocalServerState: () => Promise | LocalServerState + setLocalServerConfig: (config: LocalServerConfig) => Promise | void + onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void - getWslConfig: () => Promise - setWslConfig: (config: WslConfig) => Promise | void getDisplayBackend: () => Promise setDisplayBackend: (backend: string | null) => Promise | void parseMarkdown: (markdown: string) => Promise | string @@ -32,17 +41,27 @@ type Deps = { } export function registerIpcHandlers(deps: Deps) { + const offLocalServer = deps.onLocalServerEvent((payload) => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue + win.webContents.send("local-server-event", payload) + } + }) + app.once("will-quit", offLocalServer) + 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("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), ) - ipcMain.handle("get-wsl-config", () => deps.getWslConfig()) - ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config)) ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => deps.setDisplayBackend(backend), diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts new file mode 100644 index 0000000000..ee48a6656a --- /dev/null +++ b/packages/desktop-electron/src/main/local-server.ts @@ -0,0 +1,116 @@ +import type { LocalServerConfig, LocalServerEvent, LocalServerState, LocalServerStep } from "../preload/types" +import { LOCAL_SERVER_KEY } from "./constants" +import { store } from "./store" + +export function defaultLocalServerConfig(): LocalServerConfig { + return { + mode: "windows", + distro: null, + onboarding: { + step: null, + complete: true, + pendingRestart: false, + }, + acknowledgements: { + root: [], + mismatch: [], + }, + } +} + +export function createLocalServerController() { + let state = toState(readLocalServerConfig()) + const listeners = new Set<(event: LocalServerEvent) => void>() + + const emit = (event: LocalServerEvent) => { + for (const listener of listeners) listener(event) + } + + return { + getState() { + return state + }, + setConfig(config: LocalServerConfig) { + const next = normalizeLocalServerConfig(config) + store.set(LOCAL_SERVER_KEY, next) + state = toState(next, state) + emit({ type: "state", state }) + }, + subscribe(listener: (event: LocalServerEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} + +function readLocalServerConfig() { + return normalizeLocalServerConfig(store.get(LOCAL_SERVER_KEY)) +} + +function toState(config: LocalServerConfig, current?: LocalServerState): LocalServerState { + return { + config, + runtime: { + key: localServerKey(config), + mode: config.mode, + distro: config.distro, + }, + status: current?.status ?? { kind: "idle" }, + job: current?.job ?? null, + } +} + +function normalizeLocalServerConfig(value: unknown): LocalServerConfig { + const fallback = defaultLocalServerConfig() + if (!value || typeof value !== "object") return fallback + const record = value as Record + 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) : {} + 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) : {} + 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}` +} diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 55dfdf6e9b..34014ed5d6 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,10 +1,8 @@ import { app } from "electron" -import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" +import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" -export type WslConfig = { enabled: boolean } - export type HealthCheck = { wait: Promise } export function getDefaultServerUrl(): string | null { @@ -21,15 +19,6 @@ export function setDefaultServerUrl(url: string | null) { getStore().delete(DEFAULT_SERVER_URL_KEY) } -export function getWslConfig(): WslConfig { - const value = getStore().get(WSL_ENABLED_KEY) - return { enabled: typeof value === "boolean" ? value : false } -} - -export function setWslConfig(config: WslConfig) { - getStore().set(WSL_ENABLED_KEY, config.enabled) -} - export async function spawnLocalServer(hostname: string, port: number, password: string) { prepareServerEnv(password) const { Log, Server } = await import("virtual:opencode-server") diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 296fcb2f1c..d2e21509e5 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron" -import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types" +import type { ElectronAPI, InitStep, LocalServerEvent, SqliteMigrationProgress } from "./types" const api: ElectronAPI = { killSidecar: () => ipcRenderer.invoke("kill-sidecar"), @@ -11,10 +11,17 @@ 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), + subscribe: (cb) => { + const handler = (_: unknown, event: LocalServerEvent) => cb(event) + ipcRenderer.on("local-server-event", handler) + return () => ipcRenderer.removeListener("local-server-event", handler) + }, + }, getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), - getWslConfig: () => ipcRenderer.invoke("get-wsl-config"), - setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config), getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"), setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend), parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index f8e6d52c7d..6118f318ef 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -8,7 +8,49 @@ export type ServerReadyData = { export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } -export type WslConfig = { enabled: boolean } +export type LocalServerMode = "windows" | "wsl" +export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch" +export type LocalServerMismatchAcknowledgement = { + path: string + version: string +} +export type LocalServerConfig = { + mode: LocalServerMode + distro: string | null + onboarding: { + step: LocalServerStep | null + complete: boolean + pendingRestart: boolean + } + acknowledgements: { + root: string[] + mismatch: LocalServerMismatchAcknowledgement[] + } +} +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 +} +export type LocalServerEvent = { + type: "state" + state: LocalServerState +} +export type LocalServerAPI = { + getState: () => Promise + setConfig: (config: LocalServerConfig) => Promise + subscribe: (cb: (event: LocalServerEvent) => void) => () => void +} export type LinuxDisplayBackend = "wayland" | "auto" export type TitlebarTheme = { @@ -19,10 +61,9 @@ export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: (onStep: (step: InitStep) => void) => Promise + localServer: LocalServerAPI getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise - getWslConfig: () => Promise - setWslConfig: (config: WslConfig) => Promise getDisplayBackend: () => Promise setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise parseMarkdownCommand: (markdown: string) => Promise diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 44f2e6360c..710fd83e35 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -194,16 +194,6 @@ const createPlatform = (): Platform => { return fetch(input, init) }, - getWslEnabled: async () => { - const next = await window.api.getWslConfig().catch(() => null) - if (next) return next.enabled - return window.__OPENCODE__!.wsl ?? false - }, - - setWslEnabled: async (enabled) => { - await window.api.setWslConfig({ enabled }) - }, - getDefaultServer: async () => { const url = await window.api.getDefaultServerUrl().catch(() => null) if (!url) return null