refactor: scaffold Electron local server config

This commit is contained in:
LukeParkerDev
2026-04-16 14:12:40 +10:00
parent ba145fd61a
commit 8af7b5cd65
8 changed files with 203 additions and 38 deletions

View File

@@ -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"

View File

@@ -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<void>()
const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
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),

View File

@@ -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<ServerReadyData>
getLocalServerState: () => Promise<LocalServerState> | LocalServerState
setLocalServerConfig: (config: LocalServerConfig) => Promise<void> | void
onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
getWslConfig: () => Promise<WslConfig>
setWslConfig: (config: WslConfig) => Promise<void> | void
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | 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),

View File

@@ -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<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}`
}

View File

@@ -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<void> }
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")

View File

@@ -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),

View File

@@ -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<LocalServerState>
setConfig: (config: LocalServerConfig) => Promise<void>
subscribe: (cb: (event: LocalServerEvent) => void) => () => void
}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
@@ -19,10 +61,9 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
localServer: LocalServerAPI
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
getWslConfig: () => Promise<WslConfig>
setWslConfig: (config: WslConfig) => Promise<void>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>

View File

@@ -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