diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts
index 5ea9d4f7613b..d8fd8e5c72a3 100644
--- a/packages/app/e2e/regression/session-request-docks.spec.ts
+++ b/packages/app/e2e/regression/session-request-docks.spec.ts
@@ -94,7 +94,7 @@ test("shows a pending permission dock", async ({ page }) => {
await expect(permission).toBeVisible()
await expect(permission.getByText("git status")).toBeVisible()
await expect(permission.getByText("git diff")).toBeVisible()
- await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
+ await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(2)
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const reply = page.waitForRequest((request) => request.method() === "POST")
diff --git a/packages/app/package.json b/packages/app/package.json
index 0e68f1106cbc..7697a8e98e22 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -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",
diff --git a/packages/app/src/browser-pane.ts b/packages/app/src/browser-pane.ts
new file mode 100644
index 000000000000..23beb571d1cf
--- /dev/null
+++ b/packages/app/src/browser-pane.ts
@@ -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
+}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index da1770cd130e..9c7e92465429 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -527,8 +527,6 @@ type SessionHeaderV2ActionsState = {
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
- const language = useLanguage()
-
return (
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 6235f35c45ae..374fbb9f1217 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -711,9 +711,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return
}
setStore("fileTree", "tab", tab)
- },
- open() {
- if (!store.fileTree) {
+ },
+ open() {
+ if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
@@ -726,12 +726,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
setStore("fileTree", "opened", false)
},
- toggle() {
- if (!store.fileTree) {
+ toggle() {
+ const next = !(store.fileTree?.opened ?? true)
+ if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
- setStore("fileTree", "opened", (x) => !x)
+ setStore("fileTree", "opened", next)
},
resize(width: number) {
if (!store.fileTree) {
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 4c374961a3f6..7c3fd164cafa 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -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
+
+ /** Native browser pane hosted by the platform (desktop only). */
+ browserPane?: BrowserPanePlatform
}
export type Platform = PlatformBase &
diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts
index 566be37e6471..4dfde8e7c2c3 100644
--- a/packages/app/src/i18n/parity.test.ts
+++ b/packages/app/src/i18n/parity.test.ts
@@ -20,7 +20,6 @@ const appLocales = [
"zht",
] as const
const desktopLocales = appLocales.filter((locale) => locale !== "th" && locale !== "tr")
-
const domains = [
{
name: "app",
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index c6e3a5fcbb71..bc6f60446592 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -11,6 +11,7 @@ import {
Match,
Switch,
createMemo,
+ createResource,
createEffect,
createComputed,
createSignal,
@@ -50,7 +51,8 @@ import { useLayout } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { useNotification } from "@/context/notification"
import { PromptProvider, usePrompt } from "@/context/prompt"
-import { usePlatform } from "@/context/platform"
+import { browserPaneAvailable, createBrowserPaneBinding, usePlatform } from "@/context/platform"
+import type { BrowserPaneRegistration } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
@@ -103,6 +105,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
+import { SessionBrowserPane } from "./session/browser-pane"
type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick
@@ -352,6 +355,7 @@ function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boo
export default function Page() {
const serverSync = useServerSync()
+ const server = useServer()
const layout = useLayout()
const local = useLocal()
const file = useFile()
@@ -447,12 +451,87 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
+ const [serverProtocol] = createResource(
+ () => serverSDK().protocol,
+ (protocol) => protocol,
+ )
+ const browserAvailable = createMemo(() =>
+ browserPaneAvailable({
+ platform: !!platform.browserPane,
+ sessionID: params.id,
+ protocol: serverProtocol(),
+ }),
+ )
+ const browserServer = createMemo(
+ () => {
+ if (!browserAvailable()) return undefined
+ const routeServer = serverSDK().server
+ const serverKey = ServerConnection.key(routeServer)
+ const connection = server.list.find((item) => ServerConnection.key(item) === serverKey) ?? routeServer
+ return {
+ endpoint: {
+ url: connection.http.url,
+ username: connection.http.username,
+ password: connection.http.password,
+ },
+ }
+ },
+ undefined,
+ {
+ equals: (left, right) => {
+ if (!left || !right) return left === right
+ return (
+ left.endpoint.url === right.endpoint.url &&
+ left.endpoint.username === right.endpoint.username &&
+ left.endpoint.password === right.endpoint.password
+ )
+ },
+ },
+ )
+ const browserBinding = createMemo(() => {
+ if (!params.id) return undefined
+ const target = browserServer()
+ if (!target) return undefined
+ return createBrowserPaneBinding({ sessionID: params.id, endpoint: target.endpoint })
+ })
+ const [browserPane, setBrowserPane] = createStore<{
+ registration?: BrowserPaneRegistration
+ opened: boolean
+ }>({ opened: false })
+ createEffect(() => {
+ const binding = browserBinding()
+ const browser = platform.browserPane
+ if (!binding || !browser) {
+ setBrowserPane({ registration: undefined, opened: false })
+ return
+ }
+ const registration = browser.register(binding, () => {
+ view().reviewPanel.close()
+ layout.fileTree.close()
+ setBrowserPane("opened", true)
+ })
+ setBrowserPane("registration", registration)
+ onCleanup(() => {
+ setBrowserPane({ registration: undefined, opened: false })
+ registration.close()
+ })
+ })
+ createEffect(() => {
+ if (!browserPane.opened) return
+ if (!view().reviewPanel.opened() && !layout.fileTree.opened()) return
+ setBrowserPane("opened", false)
+ })
+ const desktopBrowserBinding = createMemo(() => {
+ if (!isDesktop() || !browserAvailable() || !browserPane.opened) return undefined
+ return browserPane.registration
+ })
+ const desktopBrowserOpen = createMemo(() => !!desktopBrowserBinding())
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
const terminalOpen = createMemo(() => view().terminal.opened())
const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
const desktopInlineTerminalOnlyOpen = createMemo(
- () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
+ () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen() && !desktopBrowserOpen(),
)
const desktopFileTreeOpen = createMemo(
() =>
@@ -463,7 +542,9 @@ export default function Page() {
}),
)
const desktopSessionResizeOpen = createMemo(() =>
- newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(),
+ newSessionDesign()
+ ? desktopV2ReviewOpen() || desktopBrowserOpen() || desktopTerminalOpen()
+ : desktopReviewOpen() || desktopBrowserOpen(),
)
const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
let panelRow: HTMLDivElement | undefined
@@ -505,6 +586,7 @@ export default function Page() {
const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({
review: desktopV2ReviewOpen(),
+ browser: desktopBrowserOpen(),
terminal: desktopTerminalOpen(),
files: desktopFileTreeOpen(),
}),
@@ -2298,27 +2380,42 @@ export default function Page() {
-
-
-
+
+
+
+ }
+ >
+ {(registration) => }
+
-
+
+ {(registration) => (
+
+
+
+ )}
+
+
{
+ frame = undefined
+ if (!surface) return
+ const rect = surface.getBoundingClientRect()
+ const zoom = platform.webviewZoom?.() ?? 1
+ const left = Math.round(rect.left * zoom)
+ const top = Math.round(rect.top * zoom)
+ const right = Math.round(rect.right * zoom)
+ const bottom = Math.round(rect.bottom * zoom)
+ props.registration.setLayout({
+ visible: store.visible && !dialog.active,
+ bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
+ })
+ if (performance.now() < until) frame = requestAnimationFrame(measure)
+ }
+
+ const schedule = (duration = 0) => {
+ until = Math.max(until, performance.now() + duration)
+ if (frame !== undefined) return
+ frame = requestAnimationFrame(measure)
+ }
+
+ createEffect(() => {
+ props.registration
+ platform.webviewZoom?.()
+ dialog.active
+ store.visible
+ schedule(300)
+ })
+
+ onMount(() => {
+ const resize = new ResizeObserver(() => schedule())
+ if (surface) resize.observe(surface)
+ const onResize = () => schedule(300)
+ const onVisibility = () => setStore("visible", document.visibilityState === "visible")
+ window.addEventListener("resize", onResize)
+ document.addEventListener("visibilitychange", onVisibility)
+ schedule(300)
+ onCleanup(() => {
+ resize.disconnect()
+ window.removeEventListener("resize", onResize)
+ document.removeEventListener("visibilitychange", onVisibility)
+ if (frame !== undefined) cancelAnimationFrame(frame)
+ props.registration.setLayout()
+ })
+ })
+
+ return
+}
diff --git a/packages/app/src/pages/session/composer/session-permission-dock.test.ts b/packages/app/src/pages/session/composer/session-permission-dock.test.ts
new file mode 100644
index 000000000000..2ce5554c60c5
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-permission-dock.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, test } from "bun:test"
+import { canRememberPermission } from "./session-permission-dock"
+
+describe("canRememberPermission", () => {
+ test("hides Always when the request has no persistent resources", () => {
+ expect(canRememberPermission({ always: [] })).toBe(false)
+ expect(canRememberPermission({ always: ["https://example.com/*"] })).toBe(true)
+ })
+})
diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx
index 35a62f75cfe3..3694c6c4c60d 100644
--- a/packages/app/src/pages/session/composer/session-permission-dock.tsx
+++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx
@@ -5,6 +5,10 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
+export function canRememberPermission(request: Pick) {
+ return request.always.length > 0
+}
+
export function SessionPermissionDock(props: {
request: PermissionRequest
responding: boolean
@@ -37,14 +41,16 @@ export function SessionPermissionDock(props: {
-
+
+
+
diff --git a/packages/app/src/pages/session/session-panel-layout.test.ts b/packages/app/src/pages/session/session-panel-layout.test.ts
index 499dd7411a0b..29c9f4d896c2 100644
--- a/packages/app/src/pages/session/session-panel-layout.test.ts
+++ b/packages/app/src/pages/session/session-panel-layout.test.ts
@@ -3,15 +3,19 @@ import { sessionPanelLayout } from "./session-panel-layout"
describe("sessionPanelLayout", () => {
test("keeps one V2 owner while changing panel geometry", () => {
- expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({
+ expect(sessionPanelLayout({ review: false, browser: false, terminal: false, files: false })).toEqual({
visible: false,
stacked: false,
})
- expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({
+ expect(sessionPanelLayout({ review: false, browser: false, terminal: true, files: false })).toEqual({
visible: true,
stacked: false,
})
- expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({
+ expect(sessionPanelLayout({ review: true, browser: false, terminal: true, files: false })).toEqual({
+ visible: true,
+ stacked: true,
+ })
+ expect(sessionPanelLayout({ review: false, browser: true, terminal: true, files: false })).toEqual({
visible: true,
stacked: true,
})
diff --git a/packages/app/src/pages/session/session-panel-layout.ts b/packages/app/src/pages/session/session-panel-layout.ts
index 49feaebedeef..7c5e22f8ff8c 100644
--- a/packages/app/src/pages/session/session-panel-layout.ts
+++ b/packages/app/src/pages/session/session-panel-layout.ts
@@ -1,6 +1,6 @@
-export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) {
+export function sessionPanelLayout(input: { review: boolean; browser: boolean; terminal: boolean; files: boolean }) {
return {
- visible: input.review || input.terminal || input.files,
- stacked: input.review && input.terminal,
+ visible: input.review || input.browser || input.terminal || input.files,
+ stacked: (input.review || input.browser) && input.terminal,
}
}
diff --git a/packages/desktop/electron.vite.config.test.ts b/packages/desktop/electron.vite.config.test.ts
new file mode 100644
index 000000000000..bad9dbaa87f2
--- /dev/null
+++ b/packages/desktop/electron.vite.config.test.ts
@@ -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:*")
+})
diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts
index 899710988853..30d7ec7bd669 100644
--- a/packages/desktop/electron.vite.config.ts
+++ b/packages/desktop/electron.vite.config.ts
@@ -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: [
{
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 2f3b90ae9ba1..f63bc926a9c1 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -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:",
diff --git a/packages/desktop/src/browser-pane-ipc.ts b/packages/desktop/src/browser-pane-ipc.ts
new file mode 100644
index 000000000000..35cd47078838
--- /dev/null
+++ b/packages/desktop/src/browser-pane-ipc.ts
@@ -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 }
diff --git a/packages/desktop/src/main/browser-network.ts b/packages/desktop/src/main/browser-network.ts
new file mode 100644
index 000000000000..c32bb523cffb
--- /dev/null
+++ b/packages/desktop/src/main/browser-network.ts
@@ -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
+ },
+ )
+}
diff --git a/packages/desktop/src/main/browser-pane-policy.ts b/packages/desktop/src/main/browser-pane-policy.ts
new file mode 100644
index 000000000000..9deca7078709
--- /dev/null
+++ b/packages/desktop/src/main/browser-pane-policy.ts
@@ -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 }
+}
diff --git a/packages/desktop/src/main/browser-pane.test.ts b/packages/desktop/src/main/browser-pane.test.ts
new file mode 100644
index 000000000000..b0a2e06ce8ef
--- /dev/null
+++ b/packages/desktop/src/main/browser-pane.test.ts
@@ -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,
+ })
+})
diff --git a/packages/desktop/src/main/browser-pane.ts b/packages/desktop/src/main/browser-pane.ts
new file mode 100644
index 000000000000..a423583f931d
--- /dev/null
+++ b/packages/desktop/src/main/browser-pane.ts
@@ -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>
+}
+type Entry = {
+ readonly binding: BrowserPaneBinding; readonly win: BrowserWindow
+ readonly registration: BrowserRegistration; readonly onClosed: () => void
+ page?: Page
+}
+
+export function createBrowserPane() {
+ const clients = new Map()
+ const entries = new Map()
+
+ 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(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
+ 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
+
+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, 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 {
+ return typeof input === "object" && input !== null && !Array.isArray(input)
+}
diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts
index 381996fb0e6c..5d5a5a5609ed 100644
--- a/packages/desktop/src/main/index.ts
+++ b/packages/desktop/src/main/index.ts
@@ -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 = {
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()
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index 5ecd4cb8c45a..9ade934a146d 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/packages/desktop/src/main/ipc.ts
@@ -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
recordFatalRendererError: (error: FatalRendererError) => Promise | 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)
}
diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts
index 0b88032950de..d2532fd14430 100644
--- a/packages/desktop/src/main/windows.ts
+++ b/packages/desktop/src/main/windows.ts
@@ -438,7 +438,7 @@ function allowRendererPermissions(win: BrowserWindow) {
})
}
-function isTrustedRendererUrl(value?: string) {
+export function isTrustedRendererUrl(value?: string) {
return isRendererUrl(value)
}
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index 1a0056dfe711..c50434cc864f 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -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),
diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts
index ea201af63edc..92f7702bb39f 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -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
install: () => Promise
}
+export type BrowserPaneAPI = {
+ register: (binding: BrowserPaneBinding) => Promise
+ unregister: (bindingID: string) => Promise
+ 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
wslServers: WslServersAPI
updater: UpdaterAPI
+ browserPane: BrowserPaneAPI
consumeInitialDeepLinks: () => Promise
getDefaultServerUrl: () => Promise
setDefaultServerUrl: (url: string | null) => Promise
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index ac5ded25f1f9..a2af59a668e0 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -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(),
diff --git a/packages/desktop/src/renderer/webview-zoom.ts b/packages/desktop/src/renderer/webview-zoom.ts
index 7993bf5ee232..b1dec8946804 100644
--- a/packages/desktop/src/renderer/webview-zoom.ts
+++ b/packages/desktop/src/renderer/webview-zoom.ts
@@ -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