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