From 616aba7bbd35134d79bac55f92466648bde0c029 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:31:05 +1000 Subject: [PATCH] refactor(desktop): establish typed ipc contract (#43150) --- packages/desktop/src/main/index.ts | 20 +- packages/desktop/src/main/ipc.ts | 237 +++++++++-------- packages/desktop/src/main/updater.ts | 40 +-- packages/desktop/src/main/windows.ts | 8 +- packages/desktop/src/main/wsl/ipc.ts | 156 +++++------ packages/desktop/src/main/wsl/runtime.ts | 2 +- packages/desktop/src/main/wsl/servers.test.ts | 2 +- packages/desktop/src/main/wsl/servers.ts | 2 +- packages/desktop/src/preload/index.ts | 195 +++++++------- packages/desktop/src/preload/types.ts | 158 +++++------ packages/desktop/src/renderer/index.tsx | 5 +- .../src/renderer/initialization.test.ts | 3 +- .../desktop/src/renderer/initialization.ts | 3 +- .../desktop/src/renderer/migration-status.tsx | 2 +- packages/desktop/src/shared/ipc-contract.ts | 247 ++++++++++++++++++ 15 files changed, 672 insertions(+), 408 deletions(-) create mode 100644 packages/desktop/src/shared/ipc-contract.ts diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 29eb750949..c7d42956f0 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -10,10 +10,16 @@ import { app, BrowserWindow } from "electron" import { Deferred, Effect, Fiber } from "effect" import contextMenu from "electron-context-menu" -import type { ServerReadyData } from "../preload/types" +import type { ServerReadyData } from "../shared/ipc-contract" import { checkAppExists, resolveAppPath } from "./apps" import { CHANNEL, VERSION } from "./constants" -import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc" +import { + registerIpcHandlers, + registerUpdaterIpcHandlers, + registerWslIpcHandlers, + sendDeepLinks, + sendMenuCommand, +} from "./ipc" import { forwardInitializationFailure } from "./initialization" import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging" import { createMenu } from "./menu" @@ -23,7 +29,7 @@ import { isFirstLaunchOnboardingPending, } from "./onboarding" import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server" -import { registerUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater" +import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater" import { safeWebContentsURL } from "./window-state" import { getLastFocusedWindow, @@ -34,7 +40,7 @@ import { setDockIcon, restoreMainWindows, } from "./windows" -import { registerWslIpcHandlers } from "./wsl/ipc" +import { createWslIpc } from "./wsl/ipc" import { cleanupStoreFiles } from "./store-cleanup" import { startBackgroundCli } from "./background-cli" import { setNativeTranslations } from "./native-translations" @@ -267,7 +273,7 @@ const main = Effect.gen(function* () { if (setNativeTranslations(bundle)) createMenu(menuDeps) }, }) - registerUpdaterIpc(updater) + registerUpdaterIpcHandlers(createUpdaterIpc(updater)) void updater.start() const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) updateTimer.unref() @@ -314,7 +320,7 @@ const main = Effect.gen(function* () { async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) { if (process.platform !== "win32") { - registerWslIpcHandlers() + registerWslIpcHandlers(createWslIpc()) return async () => {} } @@ -344,7 +350,7 @@ async function startWslServers(cli: { version: string; wslBuild?: { script: stri error: (message, meta) => logger.error(message, meta), }, }) - registerWslIpcHandlers(controller) + registerWslIpcHandlers(createWslIpc(controller)) controller.startConfiguredServers() return async () => controller.stopServers() } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 6fd583c634..c9e59fe77b 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -3,10 +3,18 @@ import { stat } from "node:fs/promises" import { basename, join } from "node:path" import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" -import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" -import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types" +import { + Ipc, + sendIpcEvent, + type FatalRendererError, + type IpcInvoke, + type IpcInvokeArgs, + type IpcInvokeResult, + type IpcSend, + type ServerReadyData, +} from "../shared/ipc-contract" import { runDesktopMenuAction } from "./desktop-menu-actions" import { setForceFocus } from "./debug" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" @@ -22,6 +30,24 @@ import { } from "./windows" import { createDesktopDraftStore } from "./draft-store" import { nativeT } from "./native-translations" +import type { UpdaterIpc } from "./updater" +import type { WslIpc } from "./wsl/ipc" + +type MaybePromise = Value | Promise + +function handle( + channel: Channel, + listener: (event: IpcMainInvokeEvent, ...args: IpcInvokeArgs) => MaybePromise>, +) { + ipcMain.handle(channel, listener) +} + +function on( + channel: Channel, + listener: (event: IpcMainEvent, ...args: IpcSend[Channel]) => void, +) { + ipcMain.on(channel, listener) +} const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -53,27 +79,21 @@ export function registerIpcHandlers(deps: Deps) { app.once("will-quit", () => drafts.close()) app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush())) - ipcMain.handle("await-initialization", () => deps.awaitInitialization()) - 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) => - deps.setDefaultServerUrl(url), - ) - ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending()) - ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) => + handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization()) + handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks()) + handle(Ipc.app.getDefaultServerUrl, () => deps.getDefaultServerUrl()) + handle(Ipc.app.setDefaultServerUrl, (_event, url) => deps.setDefaultServerUrl(url)) + handle(Ipc.app.isFirstLaunchOnboardingPending, () => deps.isFirstLaunchOnboardingPending()) + handle(Ipc.app.finishFirstLaunchOnboarding, (_event, createDefaultProject) => deps.finishFirstLaunchOnboarding(createDefaultProject), ) - ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) - ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) - ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) - ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs()) - ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) => - setForceFocus(event.sender, enabled), - ) - ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) => - deps.recordFatalRendererError(error), - ) - ipcMain.handle("set-native-translations", (event: IpcMainInvokeEvent, value: unknown) => { + handle(Ipc.app.checkAppExists, (_event, appName) => deps.checkAppExists(appName)) + handle(Ipc.app.resolveAppPath, (_event, appName) => deps.resolveAppPath(appName)) + handle(Ipc.app.setBackgroundColor, (_event, color) => deps.setBackgroundColor(color)) + handle(Ipc.app.exportDebugLogs, () => deps.exportDebugLogs()) + handle(Ipc.app.setForceFocus, (event, enabled) => setForceFocus(event.sender, enabled)) + handle(Ipc.app.recordFatalRendererError, (_event, error) => deps.recordFatalRendererError(error)) + handle(Ipc.app.setNativeTranslations, (event, value) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) { throw new Error("Invalid native translation sender") @@ -82,7 +102,7 @@ export function registerIpcHandlers(deps: Deps) { if (!bundle) throw new Error("Invalid native translation bundle") deps.setNativeTranslations(bundle) }) - ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { + handle(Ipc.storage.get, (_event, name, key) => { try { const store = getStore(name) const value = store.get(key) @@ -92,102 +112,90 @@ export function registerIpcHandlers(deps: Deps) { return null } }) - ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { + handle(Ipc.storage.set, (_event, name, key, value) => { getStore(name).set(key, value) }) - ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { + handle(Ipc.storage.delete, (_event, name, key) => { getStore(name).delete(key) void removeStoreFileIfEmpty(name) }) - ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { + handle(Ipc.storage.clear, (_event, name) => { getStore(name).clear() void removeStoreFileIfEmpty(name) }) - ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { + handle(Ipc.storage.keys, (_event, name) => { const store = getStore(name) return Object.keys(store.store) }) - ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { + handle(Ipc.storage.length, (_event, name) => { const store = getStore(name) return Object.keys(store.store).length }) - ipcMain.handle("draft-get", (_event, key: string) => drafts.get(key)) - ipcMain.handle("draft-set", (_event, key: string, value: string) => drafts.set(key, value)) - ipcMain.handle("draft-delete", (_event, key: string) => drafts.set(key, null)) - ipcMain.handle("draft-blob-put", (_event, data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data))) - ipcMain.handle("draft-blob-get", (_event, id: string) => { + handle(Ipc.drafts.get, (_event, key) => drafts.get(key)) + handle(Ipc.drafts.set, (_event, key, value) => drafts.set(key, value)) + handle(Ipc.drafts.delete, (_event, key) => drafts.set(key, null)) + handle(Ipc.drafts.putBlob, (_event, data) => drafts.putBlob(new Uint8Array(data))) + handle(Ipc.drafts.getBlob, (_event, id) => { const data = drafts.getBlob(id) - return data ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : null + return data ? new Uint8Array(data).buffer : null }) - ipcMain.handle( - "open-directory-picker", - async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { - const result = await dialog.showOpenDialog({ - properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"], - title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"), - defaultPath: opts?.defaultPath, - }) - if (result.canceled) return null - return opts?.multiple ? result.filePaths : result.filePaths[0] - }, - ) + handle(Ipc.files.openDirectoryPicker, async (_event, opts) => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"], + title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"), + defaultPath: opts?.defaultPath, + }) + if (result.canceled) return null + return opts?.multiple ? result.filePaths : result.filePaths[0] + }) - ipcMain.handle( - "open-file-picker", - async ( - event: IpcMainInvokeEvent, - opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] }, - ) => { - const result = await dialog.showOpenDialog({ - properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], - title: opts?.title ?? nativeT("desktop.dialog.chooseFile"), - defaultPath: opts?.defaultPath, - filters: pickerFilters(opts?.extensions), - }) - if (result.canceled) return null - const files = await Promise.all( - result.filePaths.map(async (filePath) => ({ - path: filePath, - name: basename(filePath), - size: (await stat(filePath)).size, - })), - ) - assertAttachmentBudget(files) - const token = pickedFiles.add(event.sender.id, result.filePaths) - return { token, files } - }, - ) + handle(Ipc.files.openFilePicker, async (event, opts) => { + const result = await dialog.showOpenDialog({ + properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], + title: opts?.title ?? nativeT("desktop.dialog.chooseFile"), + defaultPath: opts?.defaultPath, + filters: pickerFilters(opts?.extensions), + }) + if (result.canceled) return null + const files = await Promise.all( + result.filePaths.map(async (filePath) => ({ + path: filePath, + name: basename(filePath), + size: (await stat(filePath)).size, + })), + ) + assertAttachmentBudget(files) + const token = pickedFiles.add(event.sender.id, result.filePaths) + return { token, files } + }) - ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => { + handle(Ipc.files.readPickedFile, async (event, token, filePath) => { return pickedFiles.read(event.sender.id, token, filePath) }) - ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => { + handle(Ipc.files.releasePickedFiles, (event, token) => { pickedFiles.release(event.sender.id, token) }) - ipcMain.handle( - "save-file-picker", - async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => { - const result = await dialog.showSaveDialog({ - title: opts?.title ?? nativeT("desktop.dialog.saveFile"), - defaultPath: opts?.defaultPath, - }) - if (result.canceled) return null - return result.filePath ?? null - }, - ) + handle(Ipc.files.saveFilePicker, async (_event, opts) => { + const result = await dialog.showSaveDialog({ + title: opts?.title ?? nativeT("desktop.dialog.saveFile"), + defaultPath: opts?.defaultPath, + }) + if (result.canceled) return null + return result.filePath ?? null + }) - ipcMain.on("open-external", (_event: IpcMainEvent, url: string) => { + on(Ipc.files.openExternal, (_event, url) => { openExternalURL(url) }) - ipcMain.on("open-local-file", (_event: IpcMainEvent, url: string) => { + on(Ipc.files.openLocalFile, (_event, url) => { openLocalFileURL(url) }) - ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { + handle(Ipc.files.openPath, async (_event, path, app) => { if (!app) return shell.openPath(path) await new Promise((resolve, reject) => { const [cmd, args] = @@ -196,7 +204,7 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => { + handle(Ipc.files.revealPath, async (_event, path) => { const exists = await stat(path).then( () => true, () => false, @@ -206,15 +214,15 @@ export function registerIpcHandlers(deps: Deps) { return true }) - ipcMain.handle("read-clipboard-image", () => { + handle(Ipc.files.readClipboardImage, () => { const image = clipboard.readImage() if (image.isEmpty()) return null - const buffer = image.toPNG().buffer + const buffer = new Uint8Array(image.toPNG()).buffer const size = image.getSize() return { buffer, width: size.width, height: size.height } }) - ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => { + handle(Ipc.window.getId, (event) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) throw new Error("Window not found") const id = getWindowID(win) @@ -222,47 +230,47 @@ export function registerIpcHandlers(deps: Deps) { return id }) - ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => { + handle(Ipc.window.getFocused, (event) => { const win = BrowserWindow.fromWebContents(event.sender) return win?.isFocused() ?? false }) - ipcMain.handle("get-window-fullscreen", (event: IpcMainInvokeEvent) => { + handle(Ipc.window.getFullscreen, (event) => { const win = BrowserWindow.fromWebContents(event.sender) return win?.isFullScreen() ?? false }) - ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { + handle(Ipc.window.setFocus, (event) => { const win = BrowserWindow.fromWebContents(event.sender) win?.focus() }) - ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => { + handle(Ipc.window.show, (event) => { const win = BrowserWindow.fromWebContents(event.sender) win?.show() }) - ipcMain.on("relaunch", () => { + on(Ipc.app.relaunch, () => { deps.relaunch() }) - ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) - ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => { + handle(Ipc.window.getZoomFactor, (event) => event.sender.getZoomFactor()) + handle(Ipc.window.setZoomFactor, (event, factor) => { event.sender.setZoomFactor(factor) const win = BrowserWindow.fromWebContents(event.sender) if (!win) return updateTitlebar(win) }) - ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled()) - ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => { + handle(Ipc.window.getPinchZoomEnabled, () => getPinchZoomEnabled()) + handle(Ipc.window.setPinchZoomEnabled, (_event, enabled) => { setPinchZoomEnabled(enabled) }) - ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { + handle(Ipc.window.setTitlebar, (event, theme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return setTitlebar(win, theme) }) - ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => { + handle(Ipc.menu.runAction, (event, action) => { runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, { checkForUpdates: () => void deps.showUpdater(), relaunch: deps.relaunch, @@ -270,10 +278,33 @@ export function registerIpcHandlers(deps: Deps) { }) } +export function registerUpdaterIpcHandlers(updater: UpdaterIpc) { + handle(Ipc.updater.subscribe, (event) => updater.subscribe(event.sender)) + handle(Ipc.updater.unsubscribe, (event) => updater.unsubscribe(event.sender.id)) + handle(Ipc.updater.check, () => updater.check()) + handle(Ipc.updater.install, () => updater.install()) +} + +export function registerWslIpcHandlers(wsl: WslIpc) { + handle(Ipc.wsl.subscribe, (event) => wsl.subscribe(event.sender)) + handle(Ipc.wsl.unsubscribe, (event) => wsl.unsubscribe(event.sender.id)) + handle(Ipc.wsl.getState, () => wsl.getState()) + handle(Ipc.wsl.probeRuntime, () => wsl.probeRuntime()) + handle(Ipc.wsl.refreshDistros, () => wsl.refreshDistros()) + handle(Ipc.wsl.installWsl, () => wsl.installWsl()) + handle(Ipc.wsl.installDistro, (_event, value) => wsl.installDistro(value)) + handle(Ipc.wsl.probeAddable, (_event, value) => wsl.probeAddable(value)) + handle(Ipc.wsl.installOpencode, (_event, value) => wsl.installOpencode(value)) + handle(Ipc.wsl.openTerminal, (_event, value) => wsl.openTerminal(value)) + handle(Ipc.wsl.addServer, (_event, value) => wsl.addServer(value)) + handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value)) + handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value)) +} + export function sendMenuCommand(win: BrowserWindow, id: string) { - win.webContents.send("menu-command", id) + sendIpcEvent(win.webContents, Ipc.menu.command, id) } export function sendDeepLinks(win: BrowserWindow, urls: string[]) { - win.webContents.send("deep-link", urls) + sendIpcEvent(win.webContents, Ipc.app.deepLink, urls) } diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index 1b424a6ba5..3e88a9e102 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -1,4 +1,6 @@ -import { app, dialog, ipcMain } from "electron" +import { app, dialog } from "electron" +import type { WebContents } from "electron" +import { Ipc, sendIpcEvent } from "../shared/ipc-contract" import { UPDATER_ENABLED } from "./constants" import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./updater-controller" import { getLogger } from "./logging" @@ -28,7 +30,7 @@ export function setupAutoUpdater(prepareToRestart: () => Promise) { }) } -export function registerUpdaterIpc(controller: UpdaterController) { +export function createUpdaterIpc(controller: UpdaterController) { const subscriptions = new Map void>() const unsubscribe = (id: number) => { subscriptions.get(id)?.() @@ -36,23 +38,27 @@ export function registerUpdaterIpc(controller: UpdaterController) { } app.once("will-quit", () => subscriptions.forEach((dispose) => dispose())) - ipcMain.handle("updater-subscribe", (event) => { - const id = event.sender.id - subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription - subscriptions.set( - id, - controller.subscribe((state) => { - if (event.sender.isDestroyed()) return unsubscribe(id) - event.sender.send("updater-state", state) - }), - ) - event.sender.once("destroyed", () => unsubscribe(id)) - }) - ipcMain.handle("updater-unsubscribe", (event) => unsubscribe(event.sender.id)) - ipcMain.handle("updater-check", () => controller.check()) - ipcMain.handle("updater-install", () => controller.install()) + return { + subscribe(sender: WebContents) { + const id = sender.id + subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription + subscriptions.set( + id, + controller.subscribe((state) => { + if (sender.isDestroyed()) return unsubscribe(id) + sendIpcEvent(sender, Ipc.updater.state, state) + }), + ) + sender.once("destroyed", () => unsubscribe(id)) + }, + unsubscribe, + check: () => controller.check(), + install: () => controller.install(), + } } +export type UpdaterIpc = ReturnType + export async function showUpdaterDialog(controller: UpdaterController) { const state = await controller.check() if (state.status === "error") { diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 6e7b8f3ff9..800c56fa94 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -7,7 +7,7 @@ import { rmSync } from "node:fs" import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol, shell } from "electron" import { dirname, isAbsolute, join, relative, resolve } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" -import type { TitlebarTheme } from "../preload/types" +import { Ipc, sendIpcEvent, type TitlebarTheme } from "../shared/ipc-contract" import { exportDebugLogs, write as writeLog } from "./logging" import { getStore, removeStoreFile } from "./store" import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys" @@ -132,7 +132,7 @@ export function setPinchZoomEnabled(enabled: boolean) { getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled) for (const win of BrowserWindow.getAllWindows()) { pinchZoomEnabled.set(win, enabled) - win.webContents.send("pinch-zoom-enabled-changed", enabled) + sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled) if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1) updateZoom(win) } @@ -533,7 +533,7 @@ function wireZoom(win: BrowserWindow) { function wireFullscreen(win: BrowserWindow) { const send = (fullscreen: boolean) => { if (win.isDestroyed() || win.webContents.isDestroyed()) return - win.webContents.send("window-fullscreen-changed", fullscreen) + sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen) } win.on("enter-full-screen", () => send(true)) @@ -546,7 +546,7 @@ function clampZoom(value: number) { function updateZoom(win: BrowserWindow) { updateTitlebar(win) - win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor()) + sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor()) } function upsertKeyValue(obj: Record, keyToChange: string, value: any) { diff --git a/packages/desktop/src/main/wsl/ipc.ts b/packages/desktop/src/main/wsl/ipc.ts index ac7e1ac581..82c236a3f7 100644 --- a/packages/desktop/src/main/wsl/ipc.ts +++ b/packages/desktop/src/main/wsl/ipc.ts @@ -1,14 +1,28 @@ -import { app, ipcMain } from "electron" -import type { IpcMainInvokeEvent } from "electron" +import { app } from "electron" +import type { WebContents } from "electron" +import type { WslServerConfig, WslServersState } from "@opencode-ai/app/wsl/types" +import { Ipc, sendIpcEvent } from "../../shared/ipc-contract" import type { WslServersController } from "./servers" -import type { WslServersState } from "../../preload/types" import { nativeT } from "../native-translations" -export function registerWslIpcHandlers(controller?: WslServersController) { - if (!controller) { - registerUnavailableWslIpcHandlers() - return - } +export type WslIpc = { + subscribe(sender: WebContents): void + unsubscribe(id: number): void + getState(): WslServersState + probeRuntime(): Promise + refreshDistros(): Promise + installWsl(): Promise + installDistro(value: string): Promise + probeAddable(value: string[]): Promise + installOpencode(value: string): Promise + openTerminal(value: string): Promise + addServer(value: string): Promise + removeServer(value: string): Promise + startServer(value: string): Promise +} + +export function createWslIpc(controller?: WslServersController): WslIpc { + if (!controller) return createUnavailableWslIpc() const subscriptions = new Map void>() const unsubscribe = (id: number) => { @@ -23,62 +37,38 @@ export function registerWslIpcHandlers(controller?: WslServersController) { subscriptions.clear() }) - ipcMain.handle("wsl-servers-subscribe", (event) => { - const id = event.sender.id - if (subscriptions.has(id)) return - subscriptions.set( - id, - controller.subscribe((payload) => { - if (event.sender.isDestroyed()) { - unsubscribe(id) - return - } - event.sender.send("wsl-servers-event", payload) - }), - ) - event.sender.once("destroyed", () => unsubscribe(id)) - }) - ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id)) - ipcMain.handle("wsl-servers-get-state", () => controller.getState()) - ipcMain.handle("wsl-servers-probe-runtime", () => controller.probeRuntime()) - ipcMain.handle("wsl-servers-refresh-distros", () => controller.refreshDistros()) - ipcMain.handle("wsl-servers-install-wsl", () => controller.installWsl()) - ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => - controller.installDistro(requireWslIpcString("distro", name)), - ) - ipcMain.handle("wsl-servers-probe-addable", (_event: IpcMainInvokeEvent, distros: string[]) => - controller.probeAddable(requireWslIpcStrings("distro", distros)), - ) - ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => - controller.installOpencode(requireWslIpcString("distro", name)), - ) - ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => - controller.openTerminal(requireWslIpcString("distro", name)), - ) - ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => - controller.addServer(requireWslIpcString("distro", distro)), - ) - ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => - controller.removeServer(requireWslIpcString("server id", id)), - ) - ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => - controller.startServer(requireWslIpcString("server id", id)), - ) + return { + subscribe(sender) { + const id = sender.id + if (subscriptions.has(id)) return + subscriptions.set( + id, + controller.subscribe((payload) => { + if (sender.isDestroyed()) { + unsubscribe(id) + return + } + sendIpcEvent(sender, Ipc.wsl.event, payload) + }), + ) + sender.once("destroyed", () => unsubscribe(id)) + }, + unsubscribe, + getState: () => controller.getState(), + probeRuntime: () => controller.probeRuntime(), + refreshDistros: () => controller.refreshDistros(), + installWsl: () => controller.installWsl(), + installDistro: (value) => controller.installDistro(requireWslIpcString("distro", value)), + probeAddable: (value) => controller.probeAddable(requireWslIpcStrings("distro", value)), + installOpencode: (value) => controller.installOpencode(requireWslIpcString("distro", value)), + openTerminal: (value) => controller.openTerminal(requireWslIpcString("distro", value)), + addServer: (value) => controller.addServer(requireWslIpcString("distro", value)), + removeServer: (value) => controller.removeServer(requireWslIpcString("server id", value)), + startServer: (value) => controller.startServer(requireWslIpcString("server id", value)), + } } -function requireWslIpcString(name: string, value: unknown) { - if (typeof value === "string" && value.length > 0) return value - throw new Error(`Invalid ${name}`) -} - -function requireWslIpcStrings(name: string, value: unknown) { - if (!Array.isArray(value)) throw new Error(`Invalid ${name}`) - const values = value.map((item) => requireWslIpcString(name, item)) - if (values.length) return values - throw new Error(`Invalid ${name}`) -} - -function registerUnavailableWslIpcHandlers() { +function createUnavailableWslIpc(): WslIpc { const unavailable = () => { throw new Error(nativeT("desktop.wsl.error.windowsOnly")) } @@ -97,19 +87,31 @@ function registerUnavailableWslIpcHandlers() { 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-addable", 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) + return { + subscribe: (sender) => sendIpcEvent(sender, Ipc.wsl.event, { type: "state", state: state() }), + unsubscribe: () => undefined, + getState: state, + probeRuntime: unavailable, + refreshDistros: unavailable, + installWsl: unavailable, + installDistro: unavailable, + probeAddable: unavailable, + installOpencode: unavailable, + openTerminal: unavailable, + addServer: unavailable, + removeServer: unavailable, + startServer: unavailable, + } +} + +function requireWslIpcString(name: string, value: unknown) { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) +} + +function requireWslIpcStrings(name: string, value: unknown) { + if (!Array.isArray(value)) throw new Error(`Invalid ${name}`) + const values = value.map((item) => requireWslIpcString(name, item)) + if (values.length) return values + throw new Error(`Invalid ${name}`) } diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts index 95b24711c7..751795d398 100644 --- a/packages/desktop/src/main/wsl/runtime.ts +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process" import { existsSync } from "node:fs" import { join } from "node:path" import * as pty from "@lydell/node-pty" -import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types" +import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode-ai/app/wsl/types" import { parseCliVersion } from "../cli-version" import { nativeT } from "../native-translations" diff --git a/packages/desktop/src/main/wsl/servers.test.ts b/packages/desktop/src/main/wsl/servers.test.ts index 458d82f78f..93593d1598 100644 --- a/packages/desktop/src/main/wsl/servers.test.ts +++ b/packages/desktop/src/main/wsl/servers.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { WslServerConfig } from "../../preload/types" +import type { WslServerConfig } from "@opencode-ai/app/wsl/types" import { wslCliInstallCommand } from "./runtime" import { createWslServersController } from "./servers" diff --git a/packages/desktop/src/main/wsl/servers.ts b/packages/desktop/src/main/wsl/servers.ts index fddd6dd673..9ced327cf7 100644 --- a/packages/desktop/src/main/wsl/servers.ts +++ b/packages/desktop/src/main/wsl/servers.ts @@ -7,7 +7,7 @@ import type { WslServerRuntime, WslServersEvent, WslServersState, -} from "../../preload/types" +} from "@opencode-ai/app/wsl/types" import { WSL_SERVERS_KEY } from "../store-keys" import { getStore } from "../store" import { nativeT } from "../native-translations" diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 29c9cd7751..c2c6a344bd 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,133 +1,138 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" -import type { ElectronAPI, WslServersEvent } from "./types" +import type { IpcRendererEvent } from "electron" +import type { ElectronAPI } from "./types" import type { UpdaterState } from "@opencode-ai/app/updater" +import { + Ipc, + type IpcEvent, + type IpcEventListener, + type IpcInvoke, + type IpcInvokeArgs, + type IpcInvokeResult, + type IpcSend, +} from "../shared/ipc-contract" + +function invoke(channel: Channel, ...args: IpcInvokeArgs) { + return ipcRenderer.invoke(channel, ...args) as Promise> +} + +function send(channel: Channel, ...args: IpcSend[Channel]) { + ipcRenderer.send(channel, ...args) +} + +function listen(channel: Channel, listener: IpcEventListener) { + const handler = (_event: IpcRendererEvent, ...args: IpcEvent[Channel]) => listener(...args) + ipcRenderer.on(channel, handler) + return () => ipcRenderer.removeListener(channel, handler) +} const updaterCallbacks = new Set<(state: UpdaterState) => void>() let updaterState: UpdaterState | undefined let updaterSubscription: Promise | undefined -const updaterHandler = (_: unknown, state: UpdaterState) => { +let updaterListener: (() => void) | undefined +const updaterHandler = (state: UpdaterState) => { updaterState = state updaterCallbacks.forEach((callback) => callback(state)) } const api: ElectronAPI = { - awaitInitialization: () => ipcRenderer.invoke("await-initialization"), + awaitInitialization: () => invoke(Ipc.app.awaitInitialization), wslServers: { - getState: () => ipcRenderer.invoke("wsl-servers-get-state"), + getState: () => invoke(Ipc.wsl.getState), subscribe: (cb) => { - const handler = (_: unknown, event: WslServersEvent) => cb(event) - ipcRenderer.on("wsl-servers-event", handler) - void ipcRenderer.invoke("wsl-servers-subscribe") + const dispose = listen(Ipc.wsl.event, cb) + void invoke(Ipc.wsl.subscribe) return () => { - ipcRenderer.removeListener("wsl-servers-event", handler) - void ipcRenderer.invoke("wsl-servers-unsubscribe") + dispose() + void invoke(Ipc.wsl.unsubscribe) } }, - probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"), - refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"), - installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"), - installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name), - probeAddable: (distros) => ipcRenderer.invoke("wsl-servers-probe-addable", distros), - installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name), - openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name), - addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro), - removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id), - startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id), + probeRuntime: () => invoke(Ipc.wsl.probeRuntime), + refreshDistros: () => invoke(Ipc.wsl.refreshDistros), + installWsl: () => invoke(Ipc.wsl.installWsl), + installDistro: (name) => invoke(Ipc.wsl.installDistro, name), + probeAddable: (distros) => invoke(Ipc.wsl.probeAddable, distros), + installOpencode: (name) => invoke(Ipc.wsl.installOpencode, name), + openTerminal: (name) => invoke(Ipc.wsl.openTerminal, name), + addServer: (distro) => invoke(Ipc.wsl.addServer, distro), + removeServer: (id) => invoke(Ipc.wsl.removeServer, id), + startServer: (id) => invoke(Ipc.wsl.startServer, id), }, updater: { subscribe: async (cb) => { updaterCallbacks.add(cb) if (updaterState) cb(updaterState) if (!updaterSubscription) { - ipcRenderer.on("updater-state", updaterHandler) - updaterSubscription = ipcRenderer.invoke("updater-subscribe") + updaterListener = listen(Ipc.updater.state, updaterHandler) + updaterSubscription = invoke(Ipc.updater.subscribe) } await updaterSubscription return () => { updaterCallbacks.delete(cb) if (updaterCallbacks.size > 0) return - ipcRenderer.removeListener("updater-state", updaterHandler) + updaterListener?.() + updaterListener = undefined updaterSubscription = undefined - void ipcRenderer.invoke("updater-unsubscribe") + void invoke(Ipc.updater.unsubscribe) } }, - check: () => ipcRenderer.invoke("updater-check"), - install: () => ipcRenderer.invoke("updater-install"), + check: () => invoke(Ipc.updater.check), + install: () => invoke(Ipc.updater.install), }, - consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"), - getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), - setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), - isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"), + consumeInitialDeepLinks: () => invoke(Ipc.app.consumeInitialDeepLinks), + getDefaultServerUrl: () => invoke(Ipc.app.getDefaultServerUrl), + setDefaultServerUrl: (url) => invoke(Ipc.app.setDefaultServerUrl, url), + isFirstLaunchOnboardingPending: () => invoke(Ipc.app.isFirstLaunchOnboardingPending), finishFirstLaunchOnboarding: (createDefaultProject) => - ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject), - checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName), - resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName), - storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key), - storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value), - storeDelete: (name, key) => ipcRenderer.invoke("store-delete", name, key), - storeClear: (name) => ipcRenderer.invoke("store-clear", name), - storeKeys: (name) => ipcRenderer.invoke("store-keys", name), - storeLength: (name) => ipcRenderer.invoke("store-length", name), - draftGet: (key) => ipcRenderer.invoke("draft-get", key), - draftSet: (key, value) => ipcRenderer.invoke("draft-set", key, value), - draftDelete: (key) => ipcRenderer.invoke("draft-delete", key), - draftBlobPut: (data) => ipcRenderer.invoke("draft-blob-put", data), - draftBlobGet: (id) => ipcRenderer.invoke("draft-blob-get", id), + invoke(Ipc.app.finishFirstLaunchOnboarding, createDefaultProject), + checkAppExists: (appName) => invoke(Ipc.app.checkAppExists, appName), + resolveAppPath: (appName) => invoke(Ipc.app.resolveAppPath, appName), + storeGet: (name, key) => invoke(Ipc.storage.get, name, key), + storeSet: (name, key, value) => invoke(Ipc.storage.set, name, key, value), + storeDelete: (name, key) => invoke(Ipc.storage.delete, name, key), + storeClear: (name) => invoke(Ipc.storage.clear, name), + storeKeys: (name) => invoke(Ipc.storage.keys, name), + storeLength: (name) => invoke(Ipc.storage.length, name), + draftGet: (key) => invoke(Ipc.drafts.get, key), + draftSet: (key, value) => invoke(Ipc.drafts.set, key, value), + draftDelete: (key) => invoke(Ipc.drafts.delete, key), + draftBlobPut: (data) => invoke(Ipc.drafts.putBlob, data), + draftBlobGet: (id) => invoke(Ipc.drafts.getBlob, id), - getWindowID: () => ipcRenderer.invoke("get-window-id"), - onMenuCommand: (cb) => { - const handler = (_: unknown, id: string) => cb(id) - ipcRenderer.on("menu-command", handler) - return () => ipcRenderer.removeListener("menu-command", handler) - }, - onDeepLink: (cb) => { - const handler = (_: unknown, urls: string[]) => cb(urls) - ipcRenderer.on("deep-link", handler) - return () => ipcRenderer.removeListener("deep-link", handler) - }, + getWindowID: () => invoke(Ipc.window.getId), + onMenuCommand: (cb) => listen(Ipc.menu.command, cb), + onDeepLink: (cb) => listen(Ipc.app.deepLink, cb), - openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts), - openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts), - readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path), - releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token), + openDirectoryPicker: (opts) => invoke(Ipc.files.openDirectoryPicker, opts), + openFilePicker: (opts) => invoke(Ipc.files.openFilePicker, opts), + readPickedFile: (token, path) => invoke(Ipc.files.readPickedFile, token, path), + releasePickedFiles: (token) => invoke(Ipc.files.releasePickedFiles, token), getPathForFile: (file) => webUtils.getPathForFile(file), - saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts), - openExternal: (url) => ipcRenderer.send("open-external", url), - openLocalFile: (url) => ipcRenderer.send("open-local-file", url), - openPath: (path, app) => ipcRenderer.invoke("open-path", path, app), - revealPath: (path) => ipcRenderer.invoke("reveal-path", path), - readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"), - getWindowFocused: () => ipcRenderer.invoke("get-window-focused"), - getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"), - onWindowFullscreenChanged: (cb) => { - const handler = (_: unknown, fullscreen: boolean) => cb(fullscreen) - ipcRenderer.on("window-fullscreen-changed", handler) - return () => ipcRenderer.removeListener("window-fullscreen-changed", handler) - }, - setWindowFocus: () => ipcRenderer.invoke("set-window-focus"), - showWindow: () => ipcRenderer.invoke("show-window"), - relaunch: () => ipcRenderer.send("relaunch"), - getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), - setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), - getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"), - setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled), - onPinchZoomEnabledChanged: (cb) => { - const handler = (_: unknown, enabled: boolean) => cb(enabled) - ipcRenderer.on("pinch-zoom-enabled-changed", handler) - return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler) - }, - onZoomFactorChanged: (cb) => { - const handler = (_: unknown, factor: number) => cb(factor) - ipcRenderer.on("zoom-factor-changed", handler) - return () => ipcRenderer.removeListener("zoom-factor-changed", handler) - }, - setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme), - runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action), - setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), - exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"), - setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled), - recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error), - setNativeTranslations: (bundle) => ipcRenderer.invoke("set-native-translations", bundle), + saveFilePicker: (opts) => invoke(Ipc.files.saveFilePicker, opts), + openExternal: (url) => send(Ipc.files.openExternal, url), + openLocalFile: (url) => send(Ipc.files.openLocalFile, url), + openPath: (path, app) => invoke(Ipc.files.openPath, path, app), + revealPath: (path) => invoke(Ipc.files.revealPath, path), + readClipboardImage: () => invoke(Ipc.files.readClipboardImage), + getWindowFocused: () => invoke(Ipc.window.getFocused), + getWindowFullscreen: () => invoke(Ipc.window.getFullscreen), + onWindowFullscreenChanged: (cb) => listen(Ipc.window.fullscreenChanged, cb), + setWindowFocus: () => invoke(Ipc.window.setFocus), + showWindow: () => invoke(Ipc.window.show), + relaunch: () => send(Ipc.app.relaunch), + getZoomFactor: () => invoke(Ipc.window.getZoomFactor), + setZoomFactor: (factor) => invoke(Ipc.window.setZoomFactor, factor), + getPinchZoomEnabled: () => invoke(Ipc.window.getPinchZoomEnabled), + setPinchZoomEnabled: (enabled) => invoke(Ipc.window.setPinchZoomEnabled, enabled), + onPinchZoomEnabledChanged: (cb) => listen(Ipc.window.pinchZoomEnabledChanged, cb), + onZoomFactorChanged: (cb) => listen(Ipc.window.zoomFactorChanged, cb), + setTitlebar: (theme) => invoke(Ipc.window.setTitlebar, theme), + runDesktopMenuAction: (action) => invoke(Ipc.menu.runAction, action), + setBackgroundColor: (color) => invoke(Ipc.app.setBackgroundColor, color), + exportDebugLogs: () => invoke(Ipc.app.exportDebugLogs), + setForceFocus: (enabled) => invoke(Ipc.app.setForceFocus, enabled), + recordFatalRendererError: (error) => invoke(Ipc.app.recordFatalRendererError, error), + setNativeTranslations: (bundle) => invoke(Ipc.app.setNativeTranslations, bundle), } contextBridge.exposeInMainWorld("api", api) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index dc3e930ccb..bc96d1ca07 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,110 +1,74 @@ -import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" -import type { UpdaterState } from "@opencode-ai/app/updater" -import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" -export type { - WslDistroProbe, - WslInstalledDistro, - WslJob, - WslOnlineDistro, - WslOpencodeCheck, - WslRuntimeCheck, - WslServerConfig, - WslServerItem, - WslServerRuntime, - WslServersEvent, - WslServersState, -} from "@opencode-ai/app/wsl/types" - -export type ServerReadyData = { - url: string - username: string | null - password: string | null -} +import { + Ipc, + type IpcEventListener, + type IpcEventSubscription, + type IpcInvokeMethod, + type IpcSendMethod, +} from "../shared/ipc-contract" export type WslServersAPI = WslServersPlatform export type UpdaterAPI = { - subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void> - check: () => Promise - install: () => Promise -} - -export type TitlebarTheme = { - mode: "light" | "dark" - scheme?: "system" | "light" | "dark" -} -export type FatalRendererError = { - error: string - url: string - version?: string - platform: string - os?: string + subscribe: (cb: IpcEventListener) => Promise<() => void> + check: IpcInvokeMethod + install: IpcInvokeMethod } export type ElectronAPI = { - awaitInitialization: () => Promise + awaitInitialization: IpcInvokeMethod wslServers: WslServersAPI updater: UpdaterAPI - consumeInitialDeepLinks: () => Promise - getDefaultServerUrl: () => Promise - setDefaultServerUrl: (url: string | null) => Promise - isFirstLaunchOnboardingPending: () => Promise - finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise - checkAppExists: (appName: string) => Promise - resolveAppPath: (appName: string) => Promise - storeGet: (name: string, key: string) => Promise - storeSet: (name: string, key: string, value: string) => Promise - storeDelete: (name: string, key: string) => Promise - storeClear: (name: string) => Promise - storeKeys: (name: string) => Promise - storeLength: (name: string) => Promise - draftGet: (key: string) => Promise - draftSet: (key: string, value: string) => Promise - draftDelete: (key: string) => Promise - draftBlobPut: (data: ArrayBuffer) => Promise - draftBlobGet: (id: string) => Promise + consumeInitialDeepLinks: IpcInvokeMethod + getDefaultServerUrl: IpcInvokeMethod + setDefaultServerUrl: IpcInvokeMethod + isFirstLaunchOnboardingPending: IpcInvokeMethod + finishFirstLaunchOnboarding: IpcInvokeMethod + checkAppExists: IpcInvokeMethod + resolveAppPath: IpcInvokeMethod + storeGet: IpcInvokeMethod + storeSet: IpcInvokeMethod + storeDelete: IpcInvokeMethod + storeClear: IpcInvokeMethod + storeKeys: IpcInvokeMethod + storeLength: IpcInvokeMethod + draftGet: IpcInvokeMethod + draftSet: IpcInvokeMethod + draftDelete: IpcInvokeMethod + draftBlobPut: IpcInvokeMethod + draftBlobGet: IpcInvokeMethod - getWindowID: () => Promise - onMenuCommand: (cb: (id: string) => void) => () => void - onDeepLink: (cb: (urls: string[]) => void) => () => void + getWindowID: IpcInvokeMethod + onMenuCommand: IpcEventSubscription + onDeepLink: IpcEventSubscription - openDirectoryPicker: (opts?: { - multiple?: boolean - title?: string - defaultPath?: string - }) => Promise - openFilePicker: (opts?: { - multiple?: boolean - title?: string - defaultPath?: string - extensions?: string[] - }) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null> - readPickedFile: (token: string, path: string) => Promise - releasePickedFiles: (token: string) => Promise + openDirectoryPicker: IpcInvokeMethod + openFilePicker: IpcInvokeMethod + readPickedFile: IpcInvokeMethod + releasePickedFiles: IpcInvokeMethod getPathForFile: (file: File) => string - saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise - openExternal: (url: string) => void - openLocalFile: (url: string) => void - openPath: (path: string, app?: string) => Promise - revealPath: (path: string) => Promise - readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null> - getWindowFocused: () => Promise - getWindowFullscreen: () => Promise - onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void - setWindowFocus: () => Promise - showWindow: () => Promise - relaunch: () => void - getZoomFactor: () => Promise - setZoomFactor: (factor: number) => Promise - getPinchZoomEnabled: () => Promise - setPinchZoomEnabled: (enabled: boolean) => Promise - onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void - onZoomFactorChanged: (cb: (factor: number) => void) => () => void - setTitlebar: (theme: TitlebarTheme) => Promise - runDesktopMenuAction: (action: DesktopMenuAction) => Promise - setBackgroundColor: (color: string) => Promise - exportDebugLogs: () => Promise - setForceFocus: (enabled: boolean) => Promise - recordFatalRendererError: (error: FatalRendererError) => Promise - setNativeTranslations: (bundle: DesktopNativeBundle) => Promise + saveFilePicker: IpcInvokeMethod + openExternal: IpcSendMethod + openLocalFile: IpcSendMethod + openPath: IpcInvokeMethod + revealPath: IpcInvokeMethod + readClipboardImage: IpcInvokeMethod + getWindowFocused: IpcInvokeMethod + getWindowFullscreen: IpcInvokeMethod + onWindowFullscreenChanged: IpcEventSubscription + setWindowFocus: IpcInvokeMethod + showWindow: IpcInvokeMethod + relaunch: IpcSendMethod + getZoomFactor: IpcInvokeMethod + setZoomFactor: IpcInvokeMethod + getPinchZoomEnabled: IpcInvokeMethod + setPinchZoomEnabled: IpcInvokeMethod + onPinchZoomEnabledChanged: IpcEventSubscription + onZoomFactorChanged: IpcEventSubscription + setTitlebar: IpcInvokeMethod + runDesktopMenuAction: IpcInvokeMethod + setBackgroundColor: IpcInvokeMethod + exportDebugLogs: IpcInvokeMethod + setForceFocus: IpcInvokeMethod + recordFatalRendererError: IpcInvokeMethod + setNativeTranslations: IpcInvokeMethod } diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 85a3da0066..c34ed1a216 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -223,9 +223,10 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { async openPath(path: string, app?: string) { if (os === "windows") { const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null - return window.api.openPath(path, resolvedApp ?? undefined) + await window.api.openPath(path, resolvedApp ?? undefined) + return } - return window.api.openPath(path, app) + await window.api.openPath(path, app) }, async revealPath(path: string) { return window.api.revealPath(path) diff --git a/packages/desktop/src/renderer/initialization.test.ts b/packages/desktop/src/renderer/initialization.test.ts index 30eaebd680..c008799db1 100644 --- a/packages/desktop/src/renderer/initialization.test.ts +++ b/packages/desktop/src/renderer/initialization.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Ipc } from "../shared/ipc-contract" import { initializationData, initializationReady } from "./initialization" describe("desktop renderer initialization", () => { @@ -16,7 +17,7 @@ describe("desktop renderer initialization", () => { test("removes Electron's remote invocation wrapper from startup errors", () => { const error = new Error( - "Error invoking remote method 'await-initialization': Error: Cannot migrate session_message projections", + `Error invoking remote method '${Ipc.app.awaitInitialization}': Error: Cannot migrate session_message projections`, ) try { diff --git a/packages/desktop/src/renderer/initialization.ts b/packages/desktop/src/renderer/initialization.ts index b68eae09cb..f203522630 100644 --- a/packages/desktop/src/renderer/initialization.ts +++ b/packages/desktop/src/renderer/initialization.ts @@ -5,7 +5,7 @@ export function initializationData(state: (() => A | undefined) & { error: un function markLocalServerStartup(error: unknown) { const failure = error instanceof Error ? error : new Error(String(error)) - const prefix = "Error invoking remote method 'await-initialization': Error: " + const prefix = `Error invoking remote method '${Ipc.app.awaitInitialization}': Error: ` if (failure.message.startsWith(prefix)) { const previous = failure.message failure.message = failure.message.slice(prefix.length) @@ -20,3 +20,4 @@ export function initializationReady(state: (() => A | undefined) & { error: u initializationData(state) return true } +import { Ipc } from "../shared/ipc-contract" diff --git a/packages/desktop/src/renderer/migration-status.tsx b/packages/desktop/src/renderer/migration-status.tsx index 128356290a..3f8ec4bef0 100644 --- a/packages/desktop/src/renderer/migration-status.tsx +++ b/packages/desktop/src/renderer/migration-status.tsx @@ -3,7 +3,7 @@ import { useLanguage } from "@opencode-ai/app" import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" import { showToastV2, toasterV2, ToastV2 } from "@opencode-ai/ui/v2/toast-v2" import { createRoot, createSignal, onCleanup, onMount } from "solid-js" -import type { ServerReadyData } from "../preload/types" +import type { ServerReadyData } from "../shared/ipc-contract" type Progress = Extract["progress"] diff --git a/packages/desktop/src/shared/ipc-contract.ts b/packages/desktop/src/shared/ipc-contract.ts new file mode 100644 index 0000000000..f79eadcbf0 --- /dev/null +++ b/packages/desktop/src/shared/ipc-contract.ts @@ -0,0 +1,247 @@ +import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" +import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" +import type { UpdaterState } from "@opencode-ai/app/updater" +import type { WslServerConfig, WslServersEvent, WslServersState } from "@opencode-ai/app/wsl/types" + +export const Ipc = { + app: { + awaitInitialization: "await-initialization", + consumeInitialDeepLinks: "consume-initial-deep-links", + deepLink: "deep-link", + getDefaultServerUrl: "get-default-server-url", + setDefaultServerUrl: "set-default-server-url", + isFirstLaunchOnboardingPending: "is-first-launch-onboarding-pending", + finishFirstLaunchOnboarding: "finish-first-launch-onboarding", + checkAppExists: "check-app-exists", + resolveAppPath: "resolve-app-path", + relaunch: "relaunch", + setBackgroundColor: "set-background-color", + exportDebugLogs: "export-debug-logs", + setForceFocus: "set-force-focus", + recordFatalRendererError: "record-fatal-renderer-error", + setNativeTranslations: "set-native-translations", + }, + storage: { + get: "store-get", + set: "store-set", + delete: "store-delete", + clear: "store-clear", + keys: "store-keys", + length: "store-length", + }, + drafts: { + get: "draft-get", + set: "draft-set", + delete: "draft-delete", + putBlob: "draft-blob-put", + getBlob: "draft-blob-get", + }, + files: { + openDirectoryPicker: "open-directory-picker", + openFilePicker: "open-file-picker", + readPickedFile: "read-picked-file", + releasePickedFiles: "release-picked-files", + saveFilePicker: "save-file-picker", + openExternal: "open-external", + openLocalFile: "open-local-file", + openPath: "open-path", + revealPath: "reveal-path", + readClipboardImage: "read-clipboard-image", + }, + window: { + getId: "get-window-id", + getFocused: "get-window-focused", + getFullscreen: "get-window-fullscreen", + fullscreenChanged: "window-fullscreen-changed", + setFocus: "set-window-focus", + show: "show-window", + getZoomFactor: "get-zoom-factor", + setZoomFactor: "set-zoom-factor", + zoomFactorChanged: "zoom-factor-changed", + getPinchZoomEnabled: "get-pinch-zoom-enabled", + setPinchZoomEnabled: "set-pinch-zoom-enabled", + pinchZoomEnabledChanged: "pinch-zoom-enabled-changed", + setTitlebar: "set-titlebar", + }, + menu: { + command: "menu-command", + runAction: "run-desktop-menu-action", + }, + updater: { + subscribe: "updater-subscribe", + unsubscribe: "updater-unsubscribe", + check: "updater-check", + install: "updater-install", + state: "updater-state", + }, + wsl: { + subscribe: "wsl-servers-subscribe", + unsubscribe: "wsl-servers-unsubscribe", + getState: "wsl-servers-get-state", + probeRuntime: "wsl-servers-probe-runtime", + refreshDistros: "wsl-servers-refresh-distros", + installWsl: "wsl-servers-install-wsl", + installDistro: "wsl-servers-install-distro", + probeAddable: "wsl-servers-probe-addable", + installOpencode: "wsl-servers-install-opencode", + openTerminal: "wsl-servers-open-terminal", + addServer: "wsl-servers-add", + removeServer: "wsl-servers-remove", + startServer: "wsl-servers-start", + event: "wsl-servers-event", + }, +} as const + +export type ServerReadyData = { + url: string + username: string | null + password: string | null +} + +export type TitlebarTheme = { + mode: "light" | "dark" + scheme?: "system" | "light" | "dark" +} + +export type FatalRendererError = { + error: string + url: string + version?: string + platform: string + os?: string +} + +export type DirectoryPickerOptions = { + multiple?: boolean + title?: string + defaultPath?: string +} + +export type FilePickerOptions = DirectoryPickerOptions & { + extensions?: string[] +} + +export type PickedFiles = { + token: string + files: { path: string; name: string; size: number }[] +} + +export type SaveFilePickerOptions = { + title?: string + defaultPath?: string +} + +export type ClipboardImage = { + buffer: ArrayBuffer + width: number + height: number +} + +export type IpcInvoke = { + [Ipc.app.awaitInitialization]: { args: []; result: ServerReadyData } + [Ipc.app.consumeInitialDeepLinks]: { args: []; result: string[] } + [Ipc.app.getDefaultServerUrl]: { args: []; result: string | null } + [Ipc.app.setDefaultServerUrl]: { args: [url: string | null]; result: void } + [Ipc.app.isFirstLaunchOnboardingPending]: { args: []; result: boolean } + [Ipc.app.finishFirstLaunchOnboarding]: { args: [createDefaultProject: boolean]; result: string | null } + [Ipc.app.checkAppExists]: { args: [appName: string]; result: boolean } + [Ipc.app.resolveAppPath]: { args: [appName: string]; result: string | null } + [Ipc.app.setBackgroundColor]: { args: [color: string]; result: void } + [Ipc.app.exportDebugLogs]: { args: []; result: string } + [Ipc.app.setForceFocus]: { args: [enabled: boolean]; result: void } + [Ipc.app.recordFatalRendererError]: { args: [error: FatalRendererError]; result: void } + [Ipc.app.setNativeTranslations]: { args: [bundle: DesktopNativeBundle]; result: void } + + [Ipc.storage.get]: { args: [name: string, key: string]; result: string | null } + [Ipc.storage.set]: { args: [name: string, key: string, value: string]; result: void } + [Ipc.storage.delete]: { args: [name: string, key: string]; result: void } + [Ipc.storage.clear]: { args: [name: string]; result: void } + [Ipc.storage.keys]: { args: [name: string]; result: string[] } + [Ipc.storage.length]: { args: [name: string]; result: number } + + [Ipc.drafts.get]: { args: [key: string]; result: string | null } + [Ipc.drafts.set]: { args: [key: string, value: string]; result: void } + [Ipc.drafts.delete]: { args: [key: string]; result: void } + [Ipc.drafts.putBlob]: { args: [data: ArrayBuffer]; result: string } + [Ipc.drafts.getBlob]: { args: [id: string]; result: ArrayBuffer | null } + + [Ipc.files.openDirectoryPicker]: { + args: [options?: DirectoryPickerOptions] + result: string | string[] | null + } + [Ipc.files.openFilePicker]: { args: [options?: FilePickerOptions]; result: PickedFiles | null } + [Ipc.files.readPickedFile]: { args: [token: string, path: string]; result: ArrayBuffer } + [Ipc.files.releasePickedFiles]: { args: [token: string]; result: void } + [Ipc.files.saveFilePicker]: { args: [options?: SaveFilePickerOptions]; result: string | null } + [Ipc.files.openPath]: { args: [path: string, app?: string]; result: string | undefined } + [Ipc.files.revealPath]: { args: [path: string]; result: boolean } + [Ipc.files.readClipboardImage]: { args: []; result: ClipboardImage | null } + + [Ipc.window.getId]: { args: []; result: string } + [Ipc.window.getFocused]: { args: []; result: boolean } + [Ipc.window.getFullscreen]: { args: []; result: boolean } + [Ipc.window.setFocus]: { args: []; result: void } + [Ipc.window.show]: { args: []; result: void } + [Ipc.window.getZoomFactor]: { args: []; result: number } + [Ipc.window.setZoomFactor]: { args: [factor: number]; result: void } + [Ipc.window.getPinchZoomEnabled]: { args: []; result: boolean } + [Ipc.window.setPinchZoomEnabled]: { args: [enabled: boolean]; result: void } + [Ipc.window.setTitlebar]: { args: [theme: TitlebarTheme]; result: void } + [Ipc.menu.runAction]: { args: [action: DesktopMenuAction]; result: void } + + [Ipc.updater.subscribe]: { args: []; result: void } + [Ipc.updater.unsubscribe]: { args: []; result: void } + [Ipc.updater.check]: { args: []; result: UpdaterState } + [Ipc.updater.install]: { args: []; result: void } + + [Ipc.wsl.subscribe]: { args: []; result: void } + [Ipc.wsl.unsubscribe]: { args: []; result: void } + [Ipc.wsl.getState]: { args: []; result: WslServersState } + [Ipc.wsl.probeRuntime]: { args: []; result: void } + [Ipc.wsl.refreshDistros]: { args: []; result: void } + [Ipc.wsl.installWsl]: { args: []; result: void } + [Ipc.wsl.installDistro]: { args: [name: string]; result: void } + [Ipc.wsl.probeAddable]: { args: [distros: string[]]; result: void } + [Ipc.wsl.installOpencode]: { args: [name: string]; result: void } + [Ipc.wsl.openTerminal]: { args: [name: string]; result: void } + [Ipc.wsl.addServer]: { args: [distro: string]; result: WslServerConfig } + [Ipc.wsl.removeServer]: { args: [id: string]; result: void } + [Ipc.wsl.startServer]: { args: [id: string]; result: void } +} + +export type IpcSend = { + [Ipc.app.relaunch]: [] + [Ipc.files.openExternal]: [url: string] + [Ipc.files.openLocalFile]: [url: string] +} + +export type IpcEvent = { + [Ipc.app.deepLink]: [urls: string[]] + [Ipc.menu.command]: [id: string] + [Ipc.updater.state]: [state: UpdaterState] + [Ipc.wsl.event]: [event: WslServersEvent] + [Ipc.window.fullscreenChanged]: [fullscreen: boolean] + [Ipc.window.pinchZoomEnabledChanged]: [enabled: boolean] + [Ipc.window.zoomFactorChanged]: [factor: number] +} + +export type IpcInvokeArgs = IpcInvoke[Channel]["args"] +export type IpcInvokeResult = IpcInvoke[Channel]["result"] +export type IpcInvokeMethod = ( + ...args: IpcInvokeArgs +) => Promise> +export type IpcSendMethod = (...args: IpcSend[Channel]) => void +export type IpcEventListener = (...args: IpcEvent[Channel]) => void +export type IpcEventSubscription = (listener: IpcEventListener) => () => void + +type IpcEventSender = { + send(channel: Channel, ...args: IpcEvent[Channel]): void +} + +export function sendIpcEvent( + sender: IpcEventSender, + channel: Channel, + ...args: IpcEvent[Channel] +) { + sender.send(channel, ...args) +}