feat(desktop): add Electron browser adapter

This commit is contained in:
LukeParkerDev 2026-07-29 15:41:26 +10:00
commit fb11917aa7
18 changed files with 651 additions and 3 deletions

View file

@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./browser-pane": "./src/browser-pane.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",

View file

@ -0,0 +1,40 @@
import type { ServerProtocol } from "./utils/server-protocol"
export type BrowserPaneTarget = Readonly<{ sessionID: string }>
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
export type BrowserPaneBinding = BrowserPaneTarget &
Readonly<{ bindingID: string; endpoint: BrowserPaneEndpoint }>
export type BrowserPaneBounds = { x: number; y: number; width: number; height: number }
export type BrowserPaneLayout = {
visible: boolean
bounds?: BrowserPaneBounds
}
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
close(): void
}
export type BrowserPanePlatform = {
register(binding: BrowserPaneBinding, onOpen: () => void): BrowserPaneRegistration
}
export function browserPaneAvailable(input: {
platform: boolean
sessionID?: string
protocol?: ServerProtocol
}) {
return input.platform && !!input.sessionID && input.protocol === "v2"
}
export function createBrowserPaneBinding(input: BrowserPaneTarget & { endpoint: BrowserPaneEndpoint }) {
return {
sessionID: input.sessionID,
bindingID: globalThis.crypto.randomUUID(),
endpoint: input.endpoint,
} satisfies BrowserPaneBinding
}

View file

@ -5,6 +5,17 @@ import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
import type { BrowserPanePlatform } from "../browser-pane"
export type {
BrowserPaneBinding,
BrowserPaneBounds,
BrowserPaneEndpoint,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneTarget,
} from "../browser-pane"
export { browserPaneAvailable, createBrowserPaneBinding } from "../browser-pane"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@ -123,6 +134,9 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
}
export type Platform = PlatformBase &

View file

@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import config from "./electron.vite.config"
test("bundles the Node browser client into Electron main", () => {
expect(config.main?.build?.externalizeDeps).toEqual({
include: [`@lydell/node-pty-${process.platform}-${process.arch}`, "bufferutil", "utf-8-validate"],
exclude: ["@opencode-ai/client"],
})
})
test("keeps the bundled Node client out of packaged production dependencies", async () => {
const pkg = await Bun.file("package.json").json()
expect(pkg.dependencies["@opencode-ai/client"]).toBeUndefined()
expect(pkg.devDependencies["@opencode-ai/client"]).toBe("workspace:*")
})

View file

@ -48,7 +48,10 @@ const require = __cjs_mod__.createRequire(import.meta.url);
`,
},
},
externalizeDeps: { include: [nodePtyPkg] },
externalizeDeps: {
include: [nodePtyPkg, "bufferutil", "utf-8-validate"],
exclude: ["@opencode-ai/client"],
},
},
plugins: [
{

View file

@ -11,6 +11,7 @@
},
"scripts": {
"typecheck": "tsgo -b",
"test": "bun test --only-failures",
"predev": "bun ./scripts/predev.ts",
"dev": "electron-vite dev",
"prebuild": "bun ./scripts/prebuild.ts",
@ -37,6 +38,7 @@
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",

View file

@ -0,0 +1,9 @@
import type { BrowserPaneBinding, BrowserPaneLayout } from "@opencode-ai/app/browser-pane"
export const BrowserPaneIPC = {
register: "browser-pane-register", unregister: "browser-pane-unregister", layout: "browser-pane-layout",
open: "browser-pane-open",
} as const
export type BrowserPaneOpenEvent = { readonly bindingID: string }
export type BrowserPaneRegisterInput = BrowserPaneBinding
export type BrowserPaneLayoutInput = { readonly bindingID: string; readonly layout?: BrowserPaneLayout }

View file

@ -0,0 +1,43 @@
import type { BrowserProxy } from "@opencode-ai/client/node"
export async function installBrowserNetwork(input: {
readonly proxy: BrowserProxy; readonly session: Electron.Session; readonly webContents: Electron.WebContents
}) {
let disposed = false
const onLogin = (
event: Electron.Event,
_details: Electron.LoginAuthenticationResponseDetails,
authInfo: Electron.AuthInfo,
callback: (username?: string, password?: string) => void,
) => {
if (
!authInfo.isProxy ||
authInfo.scheme !== "basic" ||
authInfo.host !== input.proxy.host ||
authInfo.port !== input.proxy.port ||
authInfo.realm !== "OpenCode Browser Proxy"
)
return
event.preventDefault()
callback(input.proxy.credentials.username, input.proxy.credentials.password)
}
const cleanup = () => {
if (disposed) return
disposed = true
input.webContents.off("login", onLogin)
void input.session.closeAllConnections()
}
input.webContents.on("login", onLogin)
input.webContents.setWebRTCIPHandlingPolicy("disable_non_proxied_udp")
return input.session
.setProxy({ mode: "fixed_servers", proxyRules: input.proxy.url, proxyBypassRules: "<-loopback>" })
.then(() => input.session.closeAllConnections())
.then(
() => cleanup,
(error) => {
cleanup()
throw error
},
)
}

View file

@ -0,0 +1,21 @@
export function destinationOrigin(input: string) {
if (input === "about:blank") return input
if (!URL.canParse(input)) return undefined
const url = new URL(input)
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return undefined
return url.origin
}
export function allowedDestination(input: string, approvedOrigin: string) {
return input === "about:blank" || destinationOrigin(input) === approvedOrigin
}
export function normalizeBounds(input: { x: number; y: number; width: number; height: number }, parent: Electron.Rectangle) {
if (![input.x, input.y, input.width, input.height].every(Number.isFinite)) return undefined
const x = Math.max(0, Math.min(Math.round(input.x), parent.width))
const y = Math.max(0, Math.min(Math.round(input.y), parent.height))
const right = Math.max(x, Math.min(Math.round(input.x + input.width), parent.width))
const bottom = Math.max(y, Math.min(Math.round(input.y + input.height), parent.height))
if (right === x || bottom === y) return undefined
return { x, y, width: right - x, height: bottom - y }
}

View file

@ -0,0 +1,86 @@
import { afterAll, expect, mock, test } from "bun:test"
let windowDestroyed = false
let contentsDestroyed = false
let removed = 0
let detached = 0
let contentsClosed = 0
let registrationClosed = 0
let attachmentClosed = 0
class WebContentsView {
readonly webContents = {
session: {},
debugger: {},
setWindowOpenHandler() {},
on() {},
isDestroyed: () => contentsDestroyed,
close: () => contentsClosed++,
}
setVisible() {}
setBounds() {}
getBounds() {
return { x: 0, y: 0, width: 800, height: 600 }
}
}
mock.module("electron", () => ({ default: {}, BrowserWindow: class {}, WebContentsView }))
mock.module("@opencode-ai/client/node", () => ({
BrowserDriver: { chromium: () => ({}) },
OpenCode: {
make: () => ({
browser: {
register: async () => ({
attach: async () => ({ close: async () => attachmentClosed++ }),
close: async () => registrationClosed++,
}),
},
}),
},
}))
afterAll(() => mock.restore())
test("cleans up registration after Electron already destroyed its owned objects", async () => {
windowDestroyed = false
contentsDestroyed = false
removed = 0
detached = 0
contentsClosed = 0
registrationClosed = 0
attachmentClosed = 0
const pane = (await import(`./browser-pane?destroyed=${Date.now()}`)).createBrowserPane()
const win = {
isDestroyed: () => windowDestroyed,
once() {},
off: () => detached++,
webContents: { send() {} },
contentView: {
addChildView() {},
removeChildView: () => removed++,
getBounds: () => ({ x: 0, y: 0, width: 800, height: 600 }),
},
}
await pane.register(win, {
sessionID: "ses_desktop_browser",
bindingID: "binding",
endpoint: { url: "http://127.0.0.1:4096" },
})
pane.setLayout(win, {
bindingID: "binding",
layout: { visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 } },
})
await Promise.resolve()
windowDestroyed = true
contentsDestroyed = true
await pane.unregister(win, "binding")
await Promise.resolve()
expect({ detached, removed, contentsClosed, registrationClosed, attachmentClosed }).toEqual({
detached: 0,
removed: 0,
contentsClosed: 0,
registrationClosed: 1,
attachmentClosed: 1,
})
})

View file

@ -0,0 +1,325 @@
export * as BrowserPane from "./browser-pane"
import { randomUUID } from "node:crypto"
import {
BrowserDriver,
OpenCode,
type BrowserAttachment,
type BrowserRegistration,
type ChromiumController,
type ChromiumPort,
type OpenCodeClient,
} from "@opencode-ai/client/node"
import type {
BrowserPaneBinding,
BrowserPaneLayout,
} from "@opencode-ai/app/browser-pane"
import { BrowserWindow, WebContentsView } from "electron"
import { BrowserPaneIPC, type BrowserPaneOpenEvent } from "../browser-pane-ipc"
import { installBrowserNetwork } from "./browser-network"
import { allowedDestination, destinationOrigin, normalizeBounds } from "./browser-pane-policy"
type ViewState = {
readonly url: string
readonly title: string
readonly loading: boolean
readonly canGoBack: boolean
readonly canGoForward: boolean
}
type ViewEvent = { readonly state: ViewState; readonly mainDocumentChanged: boolean }
type Page = {
readonly view: WebContentsView; readonly abort: AbortController
readonly listeners: Set<(event: ViewEvent) => void>
approvedOrigin: string; closed: boolean
attachment?: BrowserAttachment<ChromiumController<Page>>
}
type Entry = {
readonly binding: BrowserPaneBinding; readonly win: BrowserWindow
readonly registration: BrowserRegistration; readonly onClosed: () => void
page?: Page
}
export function createBrowserPane() {
const clients = new Map<string, OpenCodeClient>()
const entries = new Map<string, Entry>()
const unregister = async (win: BrowserWindow, bindingID: string) => {
const entry = ownedEntry(entries, win, bindingID)
await closeRegistration(entry)
}
const closeRegistration = async (entry: Entry) => {
const bindingID = entry.binding.bindingID
if (entries.get(bindingID) !== entry) return
entries.delete(bindingID)
closePage(entry)
// Electron destroys the parent WebContents before BrowserWindow emits closed.
if (!entry.win.isDestroyed()) entry.win.off("closed", entry.onClosed)
await entry.registration.close()
}
const register = async (win: BrowserWindow, input: unknown) => {
const binding = parseBinding(input)
const previous = entries.get(binding.bindingID)
if (previous) await closeRegistration(previous)
const key = JSON.stringify(binding.endpoint)
const client =
clients.get(key) ??
OpenCode.make({
baseUrl: binding.endpoint.url,
headers: binding.endpoint.password
? {
Authorization: `Basic ${Buffer.from(`${binding.endpoint.username ?? "opencode"}:${binding.endpoint.password}`).toString("base64")}`,
}
: undefined,
})
clients.set(key, client)
const registration = await client.browser.register({
sessionID: binding.sessionID,
open: () => {
if (!win.isDestroyed()) {
win.webContents.send(BrowserPaneIPC.open, { bindingID: binding.bindingID } satisfies BrowserPaneOpenEvent)
}
},
})
if (win.isDestroyed()) {
await registration.close()
throw new Error("Browser pane window closed during registration")
}
const onClosed = () => void closeRegistration(entry)
const entry: Entry = { binding, win, registration, onClosed }
entries.set(binding.bindingID, entry)
win.once("closed", onClosed)
}
const setLayout = (win: BrowserWindow, input: unknown) => {
const request = parseLayout(input)
const entry = ownedEntry(entries, win, request.bindingID)
if (!request.layout) return closePage(entry)
if (!entry.page) createPage(entry)
const page = entry.page
if (!page || page.closed) return
if (!request.layout.visible || !request.layout.bounds) {
page.view.setVisible(false)
return
}
const bounds = normalizeBounds(request.layout.bounds, win.contentView.getBounds())
if (!bounds) return page.view.setVisible(false)
page.view.setBounds(bounds)
page.view.setVisible(true)
}
return {
register,
unregister,
setLayout,
dispose() {
entries.forEach((entry) => void closeRegistration(entry))
clients.clear()
},
}
function publish(page: Page, state: ViewState, mainDocumentChanged = false) {
page.listeners.forEach((listener) => listener({ state, mainDocumentChanged }))
}
function createPage(entry: Entry) {
const view = new WebContentsView({
webPreferences: {
partition: `opencode-browser-${randomUUID()}`,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
webviewTag: false,
devTools: false,
},
})
const page: Page = {
view,
abort: new AbortController(),
listeners: new Set(),
approvedOrigin: "about:blank",
closed: false,
}
entry.page = page
view.setVisible(false)
entry.win.contentView.addChildView(view)
const contents = view.webContents
contents.setWindowOpenHandler(() => ({ action: "deny" }))
const guardNavigation = (event: Electron.Event<{ url: string }>) => {
if (allowedDestination(event.url, page.approvedOrigin)) return
event.preventDefault()
}
contents.on("will-navigate", guardNavigation)
contents.on("will-redirect", guardNavigation)
const update = () => publish(page, readState(page))
contents.on("did-start-loading", update)
contents.on("did-stop-loading", update)
contents.on("did-navigate", update)
contents.on("did-navigate-in-page", update)
contents.on("page-title-updated", update)
contents.on("did-start-navigation", (event) => {
if (!event.isMainFrame) return
publish(page, { ...readState(page), url: event.url, loading: true }, !event.isSameDocument)
})
const driver = BrowserDriver.chromium<Page>(async ({ proxy, signal }) => {
const cleanupNetwork = await installBrowserNetwork({ proxy, session: contents.session, webContents: contents })
const browserDebugger = contents.debugger
let disposed = false
let queue = Promise.resolve()
const port = {
resource: page,
state: () => readState(page),
subscribe(listener: (event: ViewEvent) => void) {
page.listeners.add(listener)
return () => page.listeners.delete(listener)
},
navigate(url: string) {
const origin = destinationOrigin(url)
if (!origin) throw new Error("Only HTTP and HTTPS browser navigation is allowed")
page.approvedOrigin = origin
return contents.loadURL(url)
},
back: () => navigateHistory(page, -1),
forward: () => navigateHistory(page, 1),
reload: () => contents.reload(),
stop: () => {
if (!contents.isDestroyed()) contents.stop()
},
send(command) {
const result = queue.then(() => {
if (page.closed || contents.isDestroyed()) throw new Error("The browser page is no longer available")
if (!browserDebugger.isAttached()) browserDebugger.attach("1.3")
return browserDebugger.sendCommand(command.method, command.params)
})
queue = result.then(
() => undefined,
() => undefined,
)
return result
},
viewport: () => view.getBounds(),
async screenshot(maxDimension: number) {
const source = await contents.capturePage()
const size = source.getSize()
const scale = Math.min(1, Math.floor(maxDimension) / Math.max(size.width, size.height))
const image =
scale < 1
? source.resize({
width: Math.max(1, Math.round(size.width * scale)),
height: Math.max(1, Math.round(size.height * scale)),
quality: "good",
})
: source
return { data: new Uint8Array(image.toPNG()), ...image.getSize() }
},
dispose() {
if (disposed) return
disposed = true
cleanupNetwork()
},
} satisfies ChromiumPort<Page>
if (signal.aborted) throw signal.reason
await contents.loadURL("about:blank")
return port
})
void entry.registration.attach({ driver, signal: page.abort.signal }).then(
(attachment) => {
if (page.closed) return attachment.close()
page.attachment = attachment
},
() => undefined,
)
}
}
export type Controller = ReturnType<typeof createBrowserPane>
function closePage(entry: Entry) {
const page = entry.page
if (!page || page.closed) return
entry.page = undefined
page.closed = true
page.abort.abort()
page.listeners.clear()
page.view.setVisible(false)
if (!entry.win.isDestroyed()) entry.win.contentView.removeChildView(page.view)
if (!page.view.webContents.isDestroyed()) page.view.webContents.close({ waitForBeforeUnload: false })
void page.attachment?.close().catch(() => undefined)
}
function readState(page: Page): ViewState {
const contents = page.view.webContents
if (contents.isDestroyed()) return { url: "", title: "", loading: false, canGoBack: false, canGoForward: false }
return {
url: contents.getURL(),
title: contents.getTitle(),
loading: contents.isLoading(),
canGoBack: contents.navigationHistory.canGoBack(),
canGoForward: contents.navigationHistory.canGoForward(),
}
}
function navigateHistory(page: Page, offset: -1 | 1) {
const history = page.view.webContents.navigationHistory
if (!history.canGoToOffset(offset)) return
const url = history.getAllEntries()[history.getActiveIndex() + offset]?.url
const origin = url && destinationOrigin(url)
if (!origin) throw new Error("Only HTTP and HTTPS browser navigation is allowed")
page.approvedOrigin = origin
history.goToOffset(offset)
}
function parseBinding(input: unknown): BrowserPaneBinding {
if (!record(input) || !record(input.endpoint)) throw new TypeError("Invalid browser pane binding")
const sessionID = text(input.sessionID, 256)
const bindingID = text(input.bindingID, 128)
const url = new URL(text(input.endpoint.url, 16_384))
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
throw new TypeError("Browser server URL must be HTTP or HTTPS without embedded credentials")
}
const username = input.endpoint.username === undefined ? undefined : text(input.endpoint.username, 1_024)
const password = input.endpoint.password === undefined ? undefined : text(input.endpoint.password, 4_096)
if (username && !password) throw new TypeError("Browser server username requires a password")
return { sessionID, bindingID, endpoint: { url: url.href, username, password } }
}
function parseLayout(input: unknown): { bindingID: string; layout?: BrowserPaneLayout } {
if (!record(input)) throw new TypeError("Invalid browser pane layout")
const bindingID = text(input.bindingID, 128)
if (input.layout === undefined) return { bindingID }
if (!record(input.layout) || typeof input.layout.visible !== "boolean") throw new TypeError("Invalid browser pane layout")
if (input.layout.bounds !== undefined && !record(input.layout.bounds)) throw new TypeError("Invalid browser pane bounds")
const bounds = input.layout.bounds
return {
bindingID,
layout: {
visible: input.layout.visible,
...(bounds
? { bounds: { x: number(bounds.x), y: number(bounds.y), width: number(bounds.width), height: number(bounds.height) } }
: {}),
},
}
}
function ownedEntry(entries: Map<string, Entry>, win: BrowserWindow, bindingID: string) {
const entry = entries.get(bindingID)
if (!entry || entry.win !== win) throw new Error("Browser pane registration is unavailable")
return entry
}
function text(input: unknown, limit: number) {
if (typeof input !== "string" || !input || input.length > limit) throw new TypeError("Invalid browser pane value")
return input
}
function number(input: unknown) {
if (typeof input !== "number" || !Number.isFinite(input)) throw new TypeError("Invalid browser pane bounds")
return input
}
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}

View file

@ -42,6 +42,7 @@ import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
import { startBackgroundCli } from "./background-cli"
import { createBrowserPane } from "./browser-pane"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@ -97,6 +98,7 @@ function ensureLoopbackNoProxy() {
}
const main = Effect.gen(function* () {
const browser = createBrowserPane()
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
// on macOS apps run in `/` which can cause issues with ripgrep
@ -204,11 +206,13 @@ const main = Effect.gen(function* () {
app.on("before-quit", () => {
setAppQuitting()
browser.dispose()
void stopSidecars()
})
app.on("will-quit", () => {
setAppQuitting()
browser.dispose()
void stopSidecars()
})
@ -227,6 +231,7 @@ const main = Effect.gen(function* () {
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
setAppQuitting()
browser.dispose()
void stopSidecars().finally(() => app.exit(0))
})
}
@ -281,6 +286,7 @@ const main = Effect.gen(function* () {
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
browser,
})
registerWslIpcHandlers(wslServers)
void updater.start()

View file

@ -4,15 +4,24 @@ import { basename } from "node:path"
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import { BrowserPaneIPC } from "../browser-pane-ipc"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import {
getPinchZoomEnabled,
getWindowID,
isTrustedRendererUrl,
setPinchZoomEnabled,
setTitlebar,
updateTitlebar,
} from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
import type { BrowserPane } from "./browser-pane"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
@ -41,6 +50,7 @@ type Deps = {
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
browser: BrowserPane.Controller
}
export function registerIpcHandlers(deps: Deps) {
@ -88,6 +98,20 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)
ipcMain.handle(BrowserPaneIPC.register, (event, binding: unknown) => {
return deps.browser.register(requireTrustedWindow(event), binding)
})
ipcMain.handle(BrowserPaneIPC.unregister, (event, bindingID: unknown) => {
if (typeof bindingID !== "string") throw new TypeError("Invalid browser pane binding ID")
return deps.browser.unregister(requireTrustedWindow(event), bindingID)
})
ipcMain.on(BrowserPaneIPC.layout, (event, input: unknown) => {
const win = trustedWindow(event)
if (!win) return
try {
deps.browser.setLayout(win, input)
} catch {}
})
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
try {
const store = getStore(name)
@ -270,6 +294,17 @@ export function registerIpcHandlers(deps: Deps) {
})
}
function trustedWindow(event: IpcMainEvent | IpcMainInvokeEvent) {
if (!isTrustedRendererUrl(event.senderFrame?.url)) return undefined
return BrowserWindow.fromWebContents(event.sender) ?? undefined
}
function requireTrustedWindow(event: IpcMainInvokeEvent) {
const win = trustedWindow(event)
if (!win) throw new Error("Untrusted browser pane IPC sender")
return win
}
export function sendMenuCommand(win: BrowserWindow, id: string) {
win.webContents.send("menu-command", id)
}

View file

@ -438,7 +438,7 @@ function allowRendererPermissions(win: BrowserWindow) {
})
}
function isTrustedRendererUrl(value?: string) {
export function isTrustedRendererUrl(value?: string) {
return isRendererUrl(value)
}

View file

@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import type { ElectronAPI, WslServersEvent } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import { BrowserPaneIPC, type BrowserPaneOpenEvent } from "../browser-pane-ipc"
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
@ -56,6 +57,16 @@ const api: ElectronAPI = {
check: () => ipcRenderer.invoke("updater-check"),
install: () => ipcRenderer.invoke("updater-install"),
},
browserPane: {
register: (binding) => ipcRenderer.invoke(BrowserPaneIPC.register, binding),
unregister: (bindingID) => ipcRenderer.invoke(BrowserPaneIPC.unregister, bindingID),
setLayout: (bindingID, layout) => ipcRenderer.send(BrowserPaneIPC.layout, { bindingID, layout }),
onOpen: (callback) => {
const handler = (_event: unknown, input: BrowserPaneOpenEvent) => callback(input)
ipcRenderer.on(BrowserPaneIPC.open, handler)
return () => ipcRenderer.removeListener(BrowserPaneIPC.open, handler)
},
},
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),

View file

@ -1,6 +1,11 @@
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 {
BrowserPaneBinding,
BrowserPaneLayout,
} from "@opencode-ai/app/browser-pane"
import type { BrowserPaneOpenEvent } from "../browser-pane-ipc"
export type {
WslDistroProbe,
WslInstalledDistro,
@ -27,6 +32,12 @@ export type UpdaterAPI = {
check: () => Promise<UpdaterState>
install: () => Promise<void>
}
export type BrowserPaneAPI = {
register: (binding: BrowserPaneBinding) => Promise<void>
unregister: (bindingID: string) => Promise<void>
setLayout: (bindingID: string, layout?: BrowserPaneLayout) => void
onOpen: (callback: (event: BrowserPaneOpenEvent) => void) => () => void
}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
@ -47,6 +58,7 @@ export type ElectronAPI = {
awaitInitialization: () => Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
browserPane: BrowserPaneAPI
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>

View file

@ -233,6 +233,26 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
storage,
browserPane: {
register: (binding, onOpen) => {
let closed = false
const ready = window.api.browserPane.register(binding)
const disposeOpen = window.api.browserPane.onOpen((event) => {
if (!closed && event.bindingID === binding.bindingID) onOpen()
})
return {
setLayout: (layout) =>
void ready.then(() => window.api.browserPane.setLayout(binding.bindingID, layout)).catch(() => undefined),
close: () => {
if (closed) return
closed = true
disposeOpen()
void ready.then(() => window.api.browserPane.unregister(binding.bindingID)).catch(() => undefined)
},
}
},
},
updater: {
state: updaterState,
check: () => window.api.updater.check(),

View file

@ -31,6 +31,11 @@ const WHEEL_PINCH_END_DELAY = 160
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
void window.api.getZoomFactor().then((factor) => {
requestedZoom = clamp(factor)
setWebviewZoom(requestedZoom)
})
const applyZoom = (next: number) => {
requestedZoom = next
void window.api