Compare commits

...

6 Commits

Author SHA1 Message Date
LukeParkerDev 5bfe2d7d58 fix(desktop): externalize optional websocket peers 2026-07-28 21:28:26 +10:00
LukeParkerDev 8484a99635 fix(desktop): guard browser cleanup after close 2026-07-28 21:27:01 +10:00
LukeParkerDev a3085440bd chore(desktop): wire browser client 2026-07-28 21:26:08 +10:00
LukeParkerDev fb266e61a2 fix(desktop): stop fatal browser reattachment 2026-07-28 21:26:08 +10:00
LukeParkerDev 3d11fa9138 feat(desktop): add Electron browser adapter 2026-07-28 21:26:08 +10:00
LukeParkerDev e8c398712d feat(client): add shared Chromium driver 2026-07-28 21:24:24 +10:00
30 changed files with 3297 additions and 33 deletions
+15 -12
View File
@@ -174,12 +174,14 @@
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"ws": "8.21.0",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/ws": "8.18.1",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
@@ -432,6 +434,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:",
@@ -1039,23 +1042,23 @@
},
},
"trustedDependencies": [
"esbuild",
"protobufjs",
"electron",
"web-tree-sitter",
"esbuild",
"electron",
"protobufjs",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
},
"overrides": {
"@opentui/core": "catalog:",
+1
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",
+73
View File
@@ -0,0 +1,73 @@
import type { ServerProtocol } from "./utils/server-protocol"
export type BrowserPaneTarget = Readonly<{
serverKey: string
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 = {
attached: boolean
visible: boolean
destroy?: boolean
background?: string
bounds?: BrowserPaneBounds
}
export type BrowserPaneCommand =
| { type: "navigate"; url: string }
| { type: "back" }
| { type: "forward" }
| { type: "reload" }
| { type: "stop" }
export type BrowserPaneState = {
url: string
title: string
loading: boolean
canGoBack: boolean
canGoForward: boolean
error?: string
}
export type BrowserPanePlatform = {
setLayout(binding: BrowserPaneBinding, layout: BrowserPaneLayout): void
command(binding: BrowserPaneBinding, command: BrowserPaneCommand): Promise<void>
subscribe(binding: BrowserPaneBinding, cb: (state: BrowserPaneState) => void): Promise<() => void>
}
export function browserPaneAvailable(input: {
platform: boolean
enabled: boolean
sessionID?: string
protocol?: ServerProtocol
}) {
return input.platform && input.enabled && !!input.sessionID && input.protocol === "v2"
}
export function createBrowserPaneBinding(input: BrowserPaneTarget & { endpoint: BrowserPaneEndpoint }) {
const endpoint = Object.freeze({
url: input.endpoint.url,
username: input.endpoint.username,
password: input.endpoint.password,
})
return Object.freeze({
serverKey: input.serverKey,
sessionID: input.sessionID,
bindingID: globalThis.crypto.randomUUID(),
endpoint,
}) satisfies BrowserPaneBinding
}
+21 -16
View File
@@ -16,25 +16,26 @@ The Promise root remains structural and has no Core, Effect, Schema, Protocol, o
## Node browser attachments
The Node entrypoint owns the control connection, Session lease, authenticated proxy, and network tunnels. Consumers provide a browser adapter once with `BrowserDriver.define`; normal attachment calls only provide a Session ID and that descriptor.
The Node entrypoint owns the control connection, Session lease, authenticated proxy, and network tunnels. Chromium hosts provide a small platform port once with `BrowserDriver.chromium`; the SDK owns command semantics, CDP input, snapshots, generations, cancellation, and limits.
```ts
import { BrowserDriver, BrowserDriverError, OpenCode } from "@opencode-ai/client/node"
import { BrowserDriver, OpenCode } from "@opencode-ai/client/node"
const driver = BrowserDriver.define(async ({ proxy, signal }) => {
const browser = await launchBrowser({ proxy, signal })
const driver = BrowserDriver.chromium(async ({ proxy, signal }) => {
const view = await createChromiumView({ proxy, signal })
return {
resource: browser,
state: () => browser.state(),
subscribe: (listener) => browser.subscribe(listener),
execute: async (command, options) => {
try {
return await browser.execute(command, options)
} catch (cause) {
throw new BrowserDriverError("internal", "Browser command failed", { cause })
}
},
dispose: () => browser.close(),
resource: view,
state: () => view.state(),
subscribe: (listener) => view.subscribe((state, mainDocumentChanged) => listener({ state, mainDocumentChanged })),
navigate: (url) => view.navigate(url),
back: () => view.back(),
forward: () => view.forward(),
reload: () => view.reload(),
stop: () => view.stop(),
send: (method, params) => view.sendCDP(method, params),
viewport: () => view.viewport(),
screenshot: ({ maxDimension }) => view.capturePNG({ maxDimension }),
dispose: () => view.close(),
}
})
@@ -44,6 +45,8 @@ const client = OpenCode.make({
})
const attachment = await client.browser.attach({ sessionID, driver })
await attachment.resource.navigate("example.com")
const view = attachment.resource.resource
await attachment.close()
```
@@ -51,7 +54,9 @@ await attachment.close()
Driver factories should return after configuring their resource rather than await a proxied navigation: tunnel dialing is deliberately held behind the first lease acknowledgement, which is published after the driver supplies its initial state.
`BrowserDriver` descriptors are structural factory functions, so adapters remain compatible across duplicate client package instances. The Node entrypoint also re-exports canonical `Browser` contracts. Throw `BrowserDriverError` for typed command failures; structurally equivalent errors are accepted only when their `code` is a valid `Browser.ErrorCode`.
Port state events set `mainDocumentChanged` only when the main document changes; this advances the public generation and invalidates element refs. `send` must dispatch CDP calls in invocation order. `screenshot` returns PNG bytes and dimensions, proportionally scaled to `maxDimension` without upscaling. The returned controller serializes local navigation with remote commands; `stop` immediately interrupts active work, and controller disposal is idempotent. An aborted or timed-out operation that reached the platform disposes the port so late native completion cannot cross the queue fence.
`BrowserDriver` descriptors are structural factory functions, so adapters remain compatible across duplicate client package instances. The Node entrypoint also re-exports canonical `Browser` contracts. `BrowserDriver.define` remains the advanced escape hatch for non-Chromium semantics; throw `BrowserDriverError` for typed command failures there. Structurally equivalent errors are accepted only when their `code` is a valid `Browser.ErrorCode`.
Effect consumers construct canonical decoded inputs:
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import type { Browser } from "@opencode-ai/schema/browser"
import { chromiumDriver, type ChromiumDriver, type ChromiumPort } from "./chromium.js"
/** Connection details for the attachment's private authenticated proxy. */
export interface BrowserProxy {
@@ -50,6 +51,12 @@ export const BrowserDriver = {
if (typeof create !== "function") throw new TypeError("Browser driver factory must be a function")
return Object.freeze(create)
},
chromium<Resource>(
create: (context: BrowserDriverContext) => PromiseLike<ChromiumPort<Resource>> | ChromiumPort<Resource>,
): ChromiumDriver<Resource> {
if (typeof create !== "function") throw new TypeError("Chromium port factory must be a function")
return Object.freeze(chromiumDriver(create))
},
}
export function browserDriverFactory<Resource>(driver: BrowserDriver<Resource>) {
+1
View File
@@ -23,6 +23,7 @@ export type {
BrowserDriverInstance,
BrowserProxy,
} from "./browser/driver.js"
export type { ChromiumController, ChromiumDriver, ChromiumPort } from "./browser/chromium.js"
export type { BrowserAttachment, BrowserAttachOptions, BrowserClient } from "./browser/client.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "../promise/generated/types.js"
export type OpenCodeClient = ReturnType<typeof import("./client.js").make>
+363
View File
@@ -0,0 +1,363 @@
import { describe, expect, test } from "bun:test"
import {
Browser,
BrowserDriver,
BrowserDriverError,
type BrowserDriverContext,
type ChromiumPort,
} from "@opencode-ai/client/node"
type PortListener = Parameters<ChromiumPort<unknown>["subscribe"]>[0]
type PortState = ReturnType<ChromiumPort<unknown>["state"]>
describe("Chromium browser driver", () => {
test("increments generations and invalidates refs only for main-document changes", async () => {
const app = await setup()
const snapshot = await app.instance.execute(
{ type: "snapshot", generation: 0 },
{ signal: new AbortController().signal },
)
if (snapshot.type !== "snapshot") throw new Error("Expected snapshot result")
expect(snapshot.content).toContain('e1 [button] "Save" disabled=false')
await app.instance.execute(
{ type: "click", ref: Browser.Ref.make("e1"), generation: 0 },
{ signal: new AbortController().signal },
)
app.port.emit({ title: "Updated" }, false)
expect(app.controller.state()).toMatchObject({ title: "Updated", generation: 0 })
app.port.emit({ url: "https://next.example/" }, true)
expect(app.controller.state()).toMatchObject({ url: "https://next.example/", generation: 1 })
expect(app.port.calls.some((call) => call.method === "Runtime.releaseObject")).toBe(true)
expect(
requireDriverError(
await rejected(
app.instance.execute(
{ type: "click", ref: Browser.Ref.make("e1"), generation: 1 },
{ signal: new AbortController().signal },
),
),
),
).toMatchObject({ code: "stale_ref", message: "The element reference is stale. Call browser_snapshot again." })
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "stale_ref", message: "The browser page changed. Call browser_snapshot again." })
await app.controller.dispose()
})
test("parses bounded snapshots and rejects untrusted oversized nodes", async () => {
const app = await setup()
const result = await app.instance.execute(
{ type: "snapshot", generation: 0 },
{ signal: new AbortController().signal },
)
if (result.type !== "snapshot") throw new Error("Expected snapshot result")
expect(result.content).toBe(
["Page: Example", "URL: https://example.com/", "", ' e1 [button] "Save" disabled=false'].join("\n"),
)
expect(app.port.expressions[0]).toContain("while (visited++ < 500)")
expect(app.port.expressions[0]).toContain('editable ? ""')
expect(app.port.expressions[0]).not.toContain("textContent")
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(app.port.expressions[0] ?? "")).not.toThrow()
app.port.snapshotValue = {
nodes: Array.from({ length: 501 }, () => ({ role: "button", name: "Save", value: "", depth: 0 })),
nextRef: 501,
}
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "internal", message: "Invalid browser snapshot nodes." })
app.port.snapshotValue = {
nodes: [{ token: "e2", role: "bad role", name: "Save", value: "", depth: 0 }],
nextRef: 2,
}
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "internal", message: "Invalid browser snapshot nodes." })
expect(app.port.calls.filter((call) => call.method === "Runtime.releaseObject").length).toBeGreaterThanOrEqual(2)
await app.controller.dispose()
})
test("releases paired input and fences the port when cancellation races a press", async () => {
const app = await setup()
await app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal })
const abort = new AbortController()
const input: string[] = []
app.port.onSend = (method, params) => {
if (method !== "Input.dispatchMouseEvent") return app.port.response(method, params)
if (params?.type === "mousePressed") {
input.push("press")
abort.abort()
return new Promise<never>(() => undefined)
}
if (params?.type === "mouseReleased") input.push("release")
return app.port.response(method, params)
}
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "click", ref: Browser.Ref.make("e1"), generation: 0 }, { signal: abort.signal }),
),
),
).toMatchObject({ code: "aborted", message: "The browser action was aborted." })
await app.controller.dispose()
expect(input).toEqual(["press", "release"])
expect(app.port.stopCount).toBe(1)
expect(app.port.disposeCount).toBe(1)
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
).code,
).toBe("not_attached")
})
test("serializes local controller work with remote commands", async () => {
const app = await setup()
const navigation = deferred()
const started = deferred()
app.port.onNavigate = () => {
started.resolve()
return navigation.promise
}
const local = app.controller.navigate("example.com")
await started.promise
const remote = app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal })
await Promise.resolve()
expect(app.port.calls.some((call) => call.method === "Runtime.evaluate")).toBe(false)
expect(app.port.navigateURLs).toEqual(["https://example.com/"])
navigation.resolve()
await local
expect((await remote).type).toBe("snapshot")
expect(app.port.calls.some((call) => call.method === "Runtime.evaluate")).toBe(true)
await app.controller.dispose()
})
test("maps navigation, CDP, and adapter failures to driver error codes", async () => {
const app = await setup()
expect(requireDriverError(await rejected(app.controller.navigate("javascript:alert(1)")))).toMatchObject({
code: "invalid_url",
})
expect(app.port.navigateURLs).toEqual([])
app.port.onNavigate = () => Promise.reject(new Error("net::ERR_NAME_NOT_RESOLVED"))
expect(requireDriverError(await rejected(app.controller.navigate("missing.example")))).toMatchObject({
code: "navigation_failed",
message: "net::ERR_NAME_NOT_RESOLVED",
})
app.port.onSend = () => Promise.reject(new Error("Could not find object with given id"))
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "stale_ref", message: "The element reference is stale. Call browser_snapshot again." })
app.port.onSend = () => Promise.reject(new Error("Method not found"))
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "internal", message: "Method not found" })
app.port.onSend = () => Promise.reject(new BrowserDriverError("page_crashed", "Renderer gone"))
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "page_crashed", message: "Renderer gone" })
await app.controller.dispose()
})
test("preserves viewport scroll and bounded PNG capture contracts", async () => {
const app = await setup()
app.port.viewportSize = { width: 101, height: 99 }
await app.instance.execute(
{ type: "scroll", direction: "down", pixels: 5_000, generation: 0 },
{ signal: new AbortController().signal },
)
expect(app.port.calls.find((call) => call.params?.type === "mouseWheel")?.params).toEqual({
type: "mouseWheel",
x: 51,
y: 50,
deltaX: 0,
deltaY: 2_000,
})
const result = await app.instance.execute(
{ type: "screenshot", generation: 0 },
{ signal: new AbortController().signal },
)
if (result.type !== "screenshot") throw new Error("Expected screenshot result")
expect(app.port.screenshotLimits).toEqual([2_000])
expect(result).toMatchObject({ mediaType: "image/png", width: 100, height: 50 })
expect(result.data).toEqual(new Uint8Array([137, 80, 78, 71]))
app.port.screenshotValue = {
data: new Uint8Array(5 * 1_024 * 1_024 + 1),
width: 100,
height: 50,
}
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "screenshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "result_too_large", message: "The browser screenshot exceeds 5 MiB." })
await app.controller.dispose()
})
})
class FakeChromiumPort implements ChromiumPort<{ readonly name: string }> {
readonly resource = { name: "fake-chromium" }
readonly listeners = new Set<PortListener>()
readonly calls: Array<{ readonly method: string; readonly params?: Record<string, unknown> }> = []
readonly expressions: string[] = []
readonly navigateURLs: string[] = []
readonly screenshotLimits: number[] = []
current: PortState = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
}
snapshotValue: unknown = {
nodes: [{ token: "e1", role: "button", name: "Save", value: "", depth: 1, disabled: false }],
nextRef: 1,
}
screenshotValue = { data: new Uint8Array([137, 80, 78, 71]), width: 100, height: 50 }
viewportSize = { width: 800, height: 600 }
onNavigate?: (url: string) => PromiseLike<void> | void
onSend?: (method: string, params?: Record<string, unknown>) => unknown
stopCount = 0
disposeCount = 0
private object = 0
state() {
return this.current
}
subscribe(listener: PortListener) {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
navigate(url: string) {
this.navigateURLs.push(url)
return Promise.resolve().then(() => this.onNavigate?.(url))
}
back() {}
forward() {}
reload() {}
stop() {
this.stopCount++
}
send(method: string, params?: Record<string, unknown>) {
this.calls.push({ method, ...(params ? { params } : {}) })
if (method === "Runtime.evaluate" && typeof params?.expression === "string") {
this.expressions.push(params.expression)
}
return Promise.resolve().then(() => (this.onSend ? this.onSend(method, params) : this.response(method, params)))
}
response(method: string, params?: Record<string, unknown>) {
if (method === "Runtime.evaluate") return { result: { objectId: `snapshot-${++this.object}` } }
if (method !== "Runtime.callFunctionOn") return {}
if (params?.functionDeclaration === "function() { return this.result }") {
return { result: { value: this.snapshotValue } }
}
if (typeof params?.functionDeclaration === "string" && params.functionDeclaration.includes("const editable")) {
return { result: { value: true } }
}
return { result: { value: { x: 25, y: 40 } } }
}
viewport() {
return this.viewportSize
}
screenshot(options: { readonly maxDimension: number }) {
this.screenshotLimits.push(options.maxDimension)
return Promise.resolve(this.screenshotValue)
}
dispose() {
this.disposeCount++
}
emit(state: Partial<PortState>, mainDocumentChanged: boolean) {
this.current = { ...this.current, ...state }
this.listeners.forEach((listener) => listener({ state: this.current, mainDocumentChanged }))
}
}
async function setup() {
const port = new FakeChromiumPort()
const lifetime = new AbortController()
const driver = BrowserDriver.chromium(() => port)
const instance = await driver({
proxy: {
url: "https://127.0.0.1:1234",
host: "127.0.0.1",
port: 1234,
credentials: { username: "user", password: "pass" },
certificateFingerprint: "fingerprint",
},
signal: lifetime.signal,
} satisfies BrowserDriverContext)
return { port, lifetime, instance, controller: instance.resource }
}
function deferred() {
let resolve!: () => void
const promise = new Promise<void>((next) => {
resolve = next
})
return { promise, resolve }
}
function rejected(promise: Promise<unknown>) {
return promise.then(
() => new Error("Expected operation to reject"),
(error: unknown) => error,
)
}
function requireDriverError(input: unknown) {
if (!(input instanceof BrowserDriverError)) throw input
return input
}
@@ -127,6 +127,7 @@ function nodeScenario(moduleURL: string) {
const sdk = await import(${JSON.stringify(moduleURL)})
if (typeof sdk.OpenCode.make !== "function") throw new Error("Missing OpenCode.make")
if (typeof sdk.BrowserDriver.define !== "function") throw new Error("Missing BrowserDriver.define")
if (typeof sdk.BrowserDriver.chromium !== "function") throw new Error("Missing BrowserDriver.chromium")
if (typeof sdk.BrowserDriverError !== "function") throw new Error("Missing BrowserDriverError")
if (!sdk.Browser.State) throw new Error("Missing canonical Browser export")
+18 -1
View File
@@ -1,4 +1,13 @@
import { Browser, BrowserDriver, BrowserDriverError, OpenCode, type BrowserAttachment } from "@opencode-ai/client/node"
import {
Browser,
BrowserDriver,
BrowserDriverError,
OpenCode,
type BrowserAttachment,
type ChromiumController,
type ChromiumDriver,
type ChromiumPort,
} from "@opencode-ai/client/node"
const state: Browser.State = {
url: "about:blank",
@@ -19,6 +28,8 @@ const factory: BrowserDriver<{ readonly proxyURL: string }> = (context) => ({
dispose: () => undefined,
})
const driver = BrowserDriver.define(factory)
declare const port: ChromiumPort<{ readonly page: true }>
const chromium: ChromiumDriver<{ readonly page: true }> = BrowserDriver.chromium(() => port)
declare const client: ReturnType<typeof OpenCode.make>
const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = client.browser.attach({
@@ -27,3 +38,9 @@ const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = cl
})
void attachment
const chromiumAttachment: Promise<BrowserAttachment<ChromiumController<{ readonly page: true }>>> =
client.browser.attach({
sessionID: "ses_chromium_type_fixture",
driver: chromium,
})
void chromiumAttachment
@@ -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:*")
})
+4 -1
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: [
{
+2
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:",
@@ -0,0 +1,129 @@
export * as BrowserDesktop from "./browser-desktop"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/node"
import { BrowserWindow } from "electron"
import { createBrowserPaneController } from "./browser-pane"
import { browserPaneEndpointRevision, browserPaneEndpointUpdate } from "./browser-pane-coordination"
import { BrowserPaneIpc } from "./browser-pane-ipc"
export function createBrowserDesktop() {
const bindings = new Map<
string,
{ readonly serverKey: string; readonly fingerprint: string; readonly revision: number }
>()
const windows = new Map<number, Map<string, { readonly fingerprint: string; readonly revision: number }>>()
const endpoints = new Map<
string,
{ readonly fingerprint: string; readonly revision: number; readonly client: OpenCodeClient }
>()
const pane = createBrowserPaneController({
client(identity) {
const endpoint = endpoints.get(identity.serverKey)
if (!endpoint || endpoint.revision !== identity.endpointRevision) {
throw new Error("Browser server endpoint is unavailable")
}
return endpoint.client
},
})
const observations = (win: BrowserWindow) => {
const existing = windows.get(win.id)
if (existing) return existing
const created = new Map<string, { readonly fingerprint: string; readonly revision: number }>()
windows.set(win.id, created)
win.once("closed", () => windows.delete(win.id))
return created
}
const bind = (win: BrowserWindow, input: unknown) => {
const binding = BrowserPaneIpc.binding(input)
const fingerprint = JSON.stringify(binding.endpoint)
const observed = observations(win)
const known = bindings.get(binding.bindingID)
if (known) {
if (known.serverKey !== binding.serverKey || known.fingerprint !== fingerprint) {
throw new Error("Browser pane binding endpoint changed after creation")
}
return BrowserPaneIpc.identity(binding, known.revision)
}
const previous = endpoints.get(binding.serverKey)
const update = browserPaneEndpointUpdate(previous, observed.get(binding.serverKey), fingerprint)
if (update === "current" && previous) {
bindings.set(binding.bindingID, { serverKey: binding.serverKey, fingerprint, revision: previous.revision })
observed.set(binding.serverKey, { fingerprint, revision: previous.revision })
return BrowserPaneIpc.identity(binding, previous.revision)
}
if (update === "stale" && previous) {
const revision = observed.get(binding.serverKey)?.revision ?? previous.revision - 1
bindings.set(binding.bindingID, { serverKey: binding.serverKey, fingerprint, revision })
return BrowserPaneIpc.identity(binding, revision)
}
if (previous) {
pane.invalidate(binding.serverKey)
BrowserWindow.getAllWindows().forEach((other) => {
if (other.id === win.id) return
observations(other).set(binding.serverKey, {
fingerprint: previous.fingerprint,
revision: previous.revision,
})
})
}
const revision = (previous?.revision ?? -1) + 1
const authorization =
binding.endpoint.password === undefined
? undefined
: `Basic ${Buffer.from(`${binding.endpoint.username ?? "opencode"}:${binding.endpoint.password}`).toString("base64")}`
endpoints.set(
binding.serverKey,
Object.freeze({
fingerprint,
revision,
client: Object.freeze(
OpenCode.make({
baseUrl: binding.endpoint.url,
headers: authorization ? Object.freeze({ Authorization: authorization }) : undefined,
}),
),
}),
)
bindings.set(binding.bindingID, { serverKey: binding.serverKey, fingerprint, revision })
observed.set(binding.serverKey, { fingerprint, revision })
return BrowserPaneIpc.identity(binding, revision)
}
const currentIdentity = (binding: ReturnType<typeof BrowserPaneIpc.binding>) => {
const known = bindings.get(binding.bindingID)
if (known?.serverKey === binding.serverKey && known.fingerprint === JSON.stringify(binding.endpoint)) {
return BrowserPaneIpc.identity(binding, known.revision)
}
const endpoint = endpoints.get(binding.serverKey)
return BrowserPaneIpc.identity(binding, browserPaneEndpointRevision(endpoint, JSON.stringify(binding.endpoint)))
}
return {
setLayout(win: BrowserWindow, binding: unknown, layout: unknown) {
const parsedBinding = BrowserPaneIpc.binding(binding)
const parsedLayout = BrowserPaneIpc.layout(layout)
const identity = parsedLayout.attached ? bind(win, parsedBinding) : currentIdentity(parsedBinding)
if (parsedLayout.attached && endpoints.get(identity.serverKey)?.revision !== identity.endpointRevision) return
pane.setLayout(win, identity, parsedLayout)
},
command(win: BrowserWindow, binding: unknown, command: unknown) {
const parsed = BrowserPaneIpc.binding(binding)
return pane.command(win, currentIdentity(parsed), BrowserPaneIpc.command(command))
},
state(win: BrowserWindow, binding: unknown) {
const parsed = BrowserPaneIpc.binding(binding)
const identity = currentIdentity(parsed)
return { ...identity, state: pane.state(win, identity) }
},
dispose() {
pane.dispose()
bindings.clear()
windows.clear()
endpoints.clear()
},
}
}
export type Controller = ReturnType<typeof createBrowserDesktop>
@@ -0,0 +1,59 @@
export * as BrowserNetwork from "./browser-network"
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)
input.session.setCertificateVerifyProc(null)
void input.session.closeAllConnections()
}
input.session.setCertificateVerifyProc((request, callback) => {
if (request.hostname !== input.proxy.host) {
callback(-3)
return
}
callback(request.certificate.fingerprint === input.proxy.certificateFingerprint ? 0 : -2)
})
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
},
)
}
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import {
browserPaneEndpointRevision,
browserPaneEndpointUpdate,
browserPaneRetryCandidate,
emptyBrowserPaneState,
} from "./browser-pane-coordination"
import type { BrowserPaneIdentity } from "./browser-pane-lifecycle"
const identity = (serverKey: string, sessionID: string, bindingID: string): BrowserPaneIdentity => ({
serverKey,
sessionID,
bindingID,
endpointRevision: 0,
})
describe("browser pane coordination", () => {
test("creates a fresh empty state for a new desired identity", () => {
const state = emptyBrowserPaneState()
expect(state).toEqual({ url: "", title: "", loading: false, canGoBack: false, canGoForward: false })
expect(state).not.toBe(emptyBrowserPaneState())
})
test("does not relabel a stale endpoint binding with the current revision", () => {
const endpoint = { fingerprint: "new", revision: 4 }
expect(browserPaneEndpointRevision(endpoint, "new")).toBe(4)
expect(browserPaneEndpointRevision(endpoint, "old")).toBe(5)
})
test("requires a stale window to observe the current endpoint before replacing it", () => {
const endpoint = { fingerprint: "current", revision: 4 }
expect(browserPaneEndpointUpdate(endpoint, { revision: 3 }, "stale")).toBe("stale")
expect(browserPaneEndpointUpdate(endpoint, { revision: 4 }, "next")).toBe("replace")
expect(browserPaneEndpointUpdate(endpoint, { revision: 3 }, "current")).toBe("current")
})
test("selects one blocked window deterministically", () => {
const target = identity("server", "session", "target")
expect(
browserPaneRetryCandidate(
[
{ id: 8, desired: identity("server", "session", "later") },
{ id: 3, desired: identity("server", "session", "first") },
{ id: 1, desired: identity("server", "other", "other") },
],
target,
),
).toBe(3)
})
test("does not retry while another window owns or is claiming the Session", () => {
const target = identity("server", "session", "target")
expect(
browserPaneRetryCandidate(
[
{ id: 2, owner: identity("server", "session", "pending") },
{ id: 3, desired: identity("server", "session", "blocked") },
],
target,
),
).toBeUndefined()
})
})
@@ -0,0 +1,37 @@
import { sameBrowserPaneSession, type BrowserPaneIdentity } from "./browser-pane-lifecycle"
export function emptyBrowserPaneState() {
return { url: "", title: "", loading: false, canGoBack: false, canGoForward: false }
}
export function browserPaneEndpointRevision(
endpoint: { readonly fingerprint: string; readonly revision: number } | undefined,
fingerprint: string,
) {
if (!endpoint) return 0
return endpoint.fingerprint === fingerprint ? endpoint.revision : endpoint.revision + 1
}
export function browserPaneEndpointUpdate(
endpoint: { readonly fingerprint: string; readonly revision: number } | undefined,
observed: { readonly revision: number } | undefined,
fingerprint: string,
) {
if (!endpoint || endpoint.fingerprint === fingerprint) return endpoint ? "current" : "replace"
if (observed && observed.revision < endpoint.revision) return "stale"
return "replace"
}
export function browserPaneRetryCandidate(
entries: ReadonlyArray<{
readonly id: number
readonly owner?: BrowserPaneIdentity
readonly desired?: BrowserPaneIdentity
}>,
identity: BrowserPaneIdentity,
) {
if (entries.some((entry) => entry.owner && sameBrowserPaneSession(entry.owner, identity))) return undefined
return entries
.filter((entry) => !entry.owner && entry.desired && sameBrowserPaneSession(entry.desired, identity))
.toSorted((left, right) => left.id - right.id)[0]?.id
}
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import { BrowserPaneIpc } from "./browser-pane-ipc"
const binding = {
serverKey: "wsl:Debian",
sessionID: "ses_test",
bindingID: "binding",
endpoint: { url: "http://127.0.0.1:4096", username: "opencode", password: "secret" },
}
describe("browser pane IPC", () => {
test("copies a valid server-scoped binding", () => {
const parsed = BrowserPaneIpc.binding(binding)
expect(parsed).toEqual({ ...binding, endpoint: { ...binding.endpoint, url: "http://127.0.0.1:4096/" } })
expect(BrowserPaneIpc.identity(parsed, 2)).toEqual({
serverKey: binding.serverKey,
sessionID: binding.sessionID,
bindingID: binding.bindingID,
endpointRevision: 2,
})
})
test("defaults password-only auth and rejects a username without a password", () => {
expect(() => BrowserPaneIpc.binding({ ...binding, endpoint: { url: "http://user:pass@localhost" } })).toThrow()
expect(BrowserPaneIpc.binding({ ...binding, endpoint: { url: "http://localhost", password: "secret" } })).toEqual({
...binding,
endpoint: { url: "http://localhost/", username: "opencode", password: "secret" },
})
expect(() =>
BrowserPaneIpc.binding({ ...binding, endpoint: { url: "http://localhost", username: "user" } }),
).toThrow()
})
test("validates layouts and commands", () => {
expect(
BrowserPaneIpc.layout({ attached: true, visible: true, bounds: { x: 1, y: 2, width: 3, height: 4 } }),
).toEqual({ attached: true, visible: true, bounds: { x: 1, y: 2, width: 3, height: 4 } })
expect(BrowserPaneIpc.command({ type: "navigate", url: "http://localhost:3000" })).toEqual({
type: "navigate",
url: "http://localhost:3000",
})
expect(() => BrowserPaneIpc.command({ type: "evaluate" })).toThrow()
expect(() => BrowserPaneIpc.identity(BrowserPaneIpc.binding(binding), -1)).toThrow()
})
})
@@ -0,0 +1,84 @@
export * as BrowserPaneIpc from "./browser-pane-ipc"
import type { BrowserPaneBinding, BrowserPaneCommand, BrowserPaneLayout } from "@opencode-ai/app/browser-pane"
import type { BrowserPaneIdentity } from "./browser-pane-lifecycle"
export function binding(input: unknown): BrowserPaneBinding {
if (!record(input) || !record(input.endpoint)) throw new TypeError("Invalid browser pane binding")
const serverKey = bounded(input.serverKey, "server key", 2_048)
const sessionID = bounded(input.sessionID, "Session ID", 256)
const bindingID = bounded(input.bindingID, "binding ID", 128)
const username = optional(input.endpoint.username, "server username", 1_024)
const password = optional(input.endpoint.password, "server password", 4_096)
if (username !== undefined && password === undefined) {
throw new TypeError("Browser server endpoint username requires a password")
}
const parsed = new URL(bounded(input.endpoint.url, "server URL", 16_384))
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password) {
throw new TypeError("Browser server URL must be HTTP or HTTPS without embedded credentials")
}
return Object.freeze({
serverKey,
sessionID,
bindingID,
endpoint: Object.freeze({
url: parsed.href,
...(password === undefined ? {} : { username: username ?? "opencode" }),
...(password === undefined ? {} : { password }),
}),
})
}
export function identity(input: BrowserPaneBinding, endpointRevision: number): BrowserPaneIdentity {
if (!Number.isSafeInteger(endpointRevision) || endpointRevision < 0) throw new TypeError("Invalid endpoint revision")
return { serverKey: input.serverKey, sessionID: input.sessionID, bindingID: input.bindingID, endpointRevision }
}
export function layout(input: unknown): BrowserPaneLayout {
if (!record(input) || typeof input.attached !== "boolean" || typeof input.visible !== "boolean") {
throw new TypeError("Invalid browser pane layout")
}
const bounds = record(input.bounds)
? {
x: finite(input.bounds.x),
y: finite(input.bounds.y),
width: finite(input.bounds.width),
height: finite(input.bounds.height),
}
: undefined
return {
attached: input.attached,
visible: input.visible,
...(input.destroy === true ? { destroy: true } : {}),
...(typeof input.background === "string" ? { background: input.background.slice(0, 128) } : {}),
...(bounds ? { bounds } : {}),
}
}
export function command(input: unknown): BrowserPaneCommand {
if (!record(input) || typeof input.type !== "string") throw new TypeError("Invalid browser pane command")
if (input.type === "navigate") return { type: "navigate", url: bounded(input.url, "browser URL", 16_384) }
if (input.type === "back" || input.type === "forward" || input.type === "reload" || input.type === "stop") {
return { type: input.type }
}
throw new TypeError("Invalid browser pane command")
}
function bounded(input: unknown, name: string, limit: number) {
if (typeof input !== "string" || input.length === 0 || input.length > limit) throw new TypeError(`Invalid ${name}`)
return input
}
function optional(input: unknown, name: string, limit: number) {
if (input === undefined) return undefined
return bounded(input, name, limit)
}
function finite(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)
}
@@ -0,0 +1,131 @@
import { describe, expect, test } from "bun:test"
import {
BrowserPaneSupersededError,
createBrowserPaneLifecycle,
sameBrowserPaneIdentity,
sameBrowserPaneSession,
type BrowserPaneIdentity,
} from "./browser-pane-lifecycle"
type Context = { readonly id: number; closed: boolean }
function deferred() {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function rejected(promise: Promise<unknown>) {
return promise.then(
() => new Error("Expected operation to reject"),
(error) => (error instanceof Error ? error : new Error(String(error))),
)
}
function identity(serverKey: string, sessionID: string, bindingID: string, endpointRevision = 0): BrowserPaneIdentity {
return { serverKey, sessionID, bindingID, endpointRevision }
}
function setup() {
const ready: ReturnType<typeof deferred>[] = []
const contexts: Context[] = []
const closed: number[] = []
const lifecycle = createBrowserPaneLifecycle({
create: () => {
const context = { id: contexts.length + 1, closed: false }
const next = deferred()
contexts.push(context)
ready.push(next)
return { context, ready: next.promise }
},
close: (context) => {
context.closed = true
closed.push(context.id)
},
})
return { lifecycle, ready, contexts, closed }
}
describe("browser pane lifecycle", () => {
test("uses exact attachment identity while coordinating ownership by server Session", async () => {
const app = setup()
const owner = identity("server-a", "session-a", "binding-a", 2)
const pending = app.lifecycle.claim(owner)
expect(app.lifecycle.claim({ ...owner })).toBe(pending)
app.ready[0].resolve()
const claim = await pending
expect(await app.lifecycle.claim({ ...owner })).toBe(claim)
expect(app.lifecycle.isCurrent({ ...claim })).toBe(false)
expect(sameBrowserPaneIdentity(owner, { ...owner })).toBe(true)
expect(sameBrowserPaneIdentity(owner, { ...owner, endpointRevision: 3 })).toBe(false)
expect(sameBrowserPaneSession(owner, identity("server-a", "session-a", "binding-b"))).toBe(true)
expect(sameBrowserPaneSession(owner, identity("server-b", "session-a", "binding-a"))).toBe(false)
})
test("supersedes and closes a pending context before creating its replacement", async () => {
const app = setup()
const first = app.lifecycle.claim(identity("server-a", "session-a", "binding-a"))
const second = app.lifecycle.claim(identity("server-a", "session-b", "binding-b"))
expect(app.contexts[0].closed).toBe(true)
app.ready[0].resolve()
expect(await rejected(first)).toBeInstanceOf(BrowserPaneSupersededError)
app.ready[1].resolve()
expect((await second).context).toBe(app.contexts[1])
expect(app.closed).toEqual([1])
})
test("removes a crashed context and permits a clean exact-identity replacement", async () => {
const app = setup()
const owner = identity("server-a", "session-a", "binding-a")
const first = app.lifecycle.claim(owner)
const crashed = app.lifecycle.state()?.context
if (!crashed) throw new Error("Pending browser context missing")
expect(app.lifecycle.crash(crashed)).toEqual(owner)
expect(app.lifecycle.crash(crashed)).toBeUndefined()
expect(crashed.closed).toBe(true)
const retry = app.lifecycle.claim(owner)
app.ready[0].reject(new Error("renderer gone"))
expect(await rejected(first)).toBeInstanceOf(BrowserPaneSupersededError)
app.ready[1].resolve()
expect((await retry).context).toBe(app.contexts[1])
})
test("releases only an exact owner and never retains the released context", async () => {
const app = setup()
const owner = identity("server-a", "session-a", "binding-a")
const pending = app.lifecycle.claim(owner)
app.ready[0].resolve()
const claim = await pending
expect(app.lifecycle.release({ ...owner, bindingID: "stale" })).toBe(false)
expect(app.lifecycle.current()).toBe(claim)
expect(app.lifecycle.release(owner)).toBe(true)
expect(app.lifecycle.release(owner)).toBe(false)
expect(claim.context.closed).toBe(true)
const replacement = app.lifecycle.claim(owner)
app.ready[1].resolve()
expect((await replacement).context).not.toBe(claim.context)
})
test("disposes the active context exactly once", async () => {
const app = setup()
const pending = app.lifecycle.claim(identity("server-a", "session-a", "binding-a"))
app.ready[0].resolve()
await pending
app.lifecycle.dispose()
app.lifecycle.dispose()
expect(app.closed).toEqual([1])
expect((await rejected(app.lifecycle.claim(identity("server-a", "session-b", "binding-b")))).message).toBe(
"Browser pane lifecycle is disposed",
)
})
})
@@ -0,0 +1,141 @@
export type BrowserPaneIdentity = {
readonly serverKey: string
readonly sessionID: string
readonly bindingID: string
readonly endpointRevision: number
}
export function sameBrowserPaneSession(left: BrowserPaneIdentity, right: BrowserPaneIdentity) {
return left.serverKey === right.serverKey && left.sessionID === right.sessionID
}
export function sameBrowserPaneIdentity(left: BrowserPaneIdentity, right: BrowserPaneIdentity) {
return (
sameBrowserPaneSession(left, right) &&
left.bindingID === right.bindingID &&
left.endpointRevision === right.endpointRevision
)
}
export class BrowserPaneSupersededError extends Error {
constructor() {
super("Browser pane context was superseded")
this.name = "BrowserPaneSupersededError"
}
}
export type BrowserPaneClaim<Context> = BrowserPaneIdentity & { readonly context: Context }
type Slot<Context> = BrowserPaneIdentity & {
readonly context: Context
pending?: Promise<BrowserPaneClaim<Context>>
claim?: BrowserPaneClaim<Context>
}
export function createBrowserPaneLifecycle<Context extends object>(input: {
readonly create: (identity: BrowserPaneIdentity) => {
readonly context: Context
readonly ready: PromiseLike<unknown>
}
readonly close: (context: Context) => void
}) {
let disposed = false
let active: Slot<Context> | undefined
const closed = new WeakSet<Context>()
const close = (context: Context) => {
if (closed.has(context)) return
closed.add(context)
input.close(context)
}
const remove = (slot: Slot<Context>) => {
if (active === slot) active = undefined
slot.claim = undefined
close(slot.context)
}
const release = (identity?: BrowserPaneIdentity) => {
if (!active || (identity && !sameBrowserPaneIdentity(active, identity))) return false
remove(active)
return true
}
const claim = (target: BrowserPaneIdentity): Promise<BrowserPaneClaim<Context>> => {
if (disposed) return Promise.reject(new Error("Browser pane lifecycle is disposed"))
if (active && sameBrowserPaneIdentity(active, target)) {
if (active.claim) return Promise.resolve(active.claim)
if (active.pending) return active.pending
}
release()
const identity: BrowserPaneIdentity = {
serverKey: target.serverKey,
sessionID: target.sessionID,
bindingID: target.bindingID,
endpointRevision: target.endpointRevision,
}
const created = (() => {
try {
return input.create(identity)
} catch (error) {
return { error }
}
})()
if ("error" in created) return Promise.reject(created.error)
const next: Slot<Context> = { ...identity, context: created.context }
active = next
next.pending = Promise.resolve(created.ready).then(
() => {
if (active !== next) throw new BrowserPaneSupersededError()
const result: BrowserPaneClaim<Context> = { ...identity, context: next.context }
next.claim = result
next.pending = undefined
return result
},
(error) => {
if (active !== next) throw new BrowserPaneSupersededError()
remove(next)
throw error
},
)
return next.pending
}
const crash = (context: Context) => {
if (!active || active.context !== context) return undefined
const identity: BrowserPaneIdentity = {
serverKey: active.serverKey,
sessionID: active.sessionID,
bindingID: active.bindingID,
endpointRevision: active.endpointRevision,
}
remove(active)
return identity
}
return {
claim,
release,
crash,
state: () =>
active && {
context: active.context,
serverKey: active.serverKey,
sessionID: active.sessionID,
bindingID: active.bindingID,
endpointRevision: active.endpointRevision,
},
current: () => active?.claim,
contains: (context: Context) => active?.context === context,
isCurrent: (claim: BrowserPaneClaim<Context>) => active?.claim === claim,
dispose() {
if (disposed) return
disposed = true
if (active) remove(active)
},
}
}
export type BrowserPaneLifecycle<Context extends object> = ReturnType<typeof createBrowserPaneLifecycle<Context>>
@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import {
allowedBrowserDestination,
allowedBrowserURL,
browserBottomMasks,
browserContextPartition,
browserDestinationOrigin,
browserHistoryDestinationOrigin,
normalizeBrowserBounds,
} from "./browser-pane-policy"
describe("browser pane policy", () => {
test("allows only the explicitly approved HTTP, HTTPS, or blank navigation origin", () => {
expect(browserDestinationOrigin("https://example.com/path")).toBe("https://example.com")
expect(allowedBrowserDestination("https://example.com/next", "https://example.com")).toBe(true)
expect(allowedBrowserDestination("https://other.example/", "https://example.com")).toBe(false)
expect(allowedBrowserDestination("http://example.com/", "https://example.com")).toBe(false)
expect(allowedBrowserDestination("https://example.com/next")).toBe(false)
expect(allowedBrowserDestination("about:blank")).toBe(true)
expect(allowedBrowserURL("https://user:pass@example.com")).toBe(false)
expect(allowedBrowserURL("data:text/html,test")).toBe(false)
expect(allowedBrowserURL("about:srcdoc")).toBe(false)
})
test("selects the approved origin for back and forward history entries", () => {
const history = {
getActiveIndex: () => 1,
getAllEntries: () => [
{ url: "https://before.example/path" },
{ url: "https://current.example/path" },
{ url: "https://after.example/path" },
],
}
expect(browserHistoryDestinationOrigin(history, -1)).toBe("https://before.example")
expect(browserHistoryDestinationOrigin(history, 1)).toBe("https://after.example")
expect(browserHistoryDestinationOrigin(history, 2)).toBeUndefined()
})
test("isolates every ephemeral Electron context", () => {
const partition = browserContextPartition("https://server-a", "session-a", "binding-a", "context-a")
expect(partition.startsWith("persist:")).toBe(false)
expect(partition).not.toBe(browserContextPartition("https://server-b", "session-a", "binding-a", "context-a"))
expect(partition).not.toBe(browserContextPartition("https://server-a", "session-b", "binding-a", "context-a"))
expect(partition).not.toBe(browserContextPartition("https://server-a", "session-a", "binding-b", "context-a"))
expect(partition).not.toBe(browserContextPartition("https://server-a", "session-a", "binding-a", "context-b"))
})
test("clamps browser bounds to the parent content view", () => {
expect(
normalizeBrowserBounds({ x: 90.4, y: 40.6, width: 50.2, height: 70.8 }, { x: 0, y: 0, width: 120, height: 100 }),
).toEqual({ x: 90, y: 41, width: 30, height: 59 })
expect(
normalizeBrowserBounds({ x: 10, y: 10, width: 0, height: 20 }, { x: 0, y: 0, width: 100, height: 100 }),
).toBeUndefined()
})
test("builds bottom-only corner masks", () => {
expect(browserBottomMasks({ x: 100, y: 50, width: 400, height: 300 })).toEqual([
{ x: 100, y: 348, width: 6, height: 2 },
{ x: 494, y: 348, width: 6, height: 2 },
{ x: 100, y: 346, width: 3, height: 2 },
{ x: 497, y: 346, width: 3, height: 2 },
{ x: 100, y: 344, width: 2, height: 2 },
{ x: 498, y: 344, width: 2, height: 2 },
{ x: 100, y: 340, width: 1, height: 4 },
{ x: 499, y: 340, width: 1, height: 4 },
])
})
})
@@ -0,0 +1,70 @@
import { Buffer } from "node:buffer"
export type BrowserPaneBounds = {
readonly x: number
readonly y: number
readonly width: number
readonly height: number
}
export function allowedBrowserURL(input: string) {
if (input === "about:blank") return true
if (!URL.canParse(input)) return false
const url = new URL(input)
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password
}
export function browserDestinationOrigin(input: string) {
if (input === "about:blank") return input
if (!allowedBrowserURL(input)) return undefined
return new URL(input).origin
}
export function browserHistoryDestinationOrigin(
history: {
readonly getActiveIndex: () => number
readonly getAllEntries: () => ReadonlyArray<{ readonly url: string }>
},
offset: number,
) {
const entry = history.getAllEntries()[history.getActiveIndex() + offset]
return entry ? browserDestinationOrigin(entry.url) : undefined
}
export function allowedBrowserDestination(input: string, approvedOrigin?: string) {
if (input === "about:blank") return true
return approvedOrigin !== undefined && browserDestinationOrigin(input) === approvedOrigin
}
export function browserContextPartition(serverKey: string, sessionID: string, bindingID: string, contextID: string) {
const encode = (value: string) => Buffer.from(value).toString("base64url")
return `opencode-browser-${encode(serverKey)}-${encode(sessionID)}-${encode(bindingID)}-${encode(contextID)}`
}
export function normalizeBrowserBounds(input: BrowserPaneBounds, parent: BrowserPaneBounds) {
if (![input.x, input.y, input.width, input.height].every(Number.isFinite)) return undefined
const left = Math.max(0, Math.min(Math.round(input.x), parent.width))
const top = Math.max(0, Math.min(Math.round(input.y), parent.height))
const right = Math.max(left, Math.min(Math.round(input.x + input.width), parent.width))
const bottom = Math.max(top, Math.min(Math.round(input.y + input.height), parent.height))
if (right === left || bottom === top) return undefined
return { x: left, y: top, width: right - left, height: bottom - top }
}
/** Opaque corner masks cut only the bottom edge while the native view keeps square top corners. */
export function browserBottomMasks(bounds: BrowserPaneBounds) {
if (bounds.width < 20 || bounds.height < 10) return []
const segments = [
{ offset: 0, height: 2, width: 6 },
{ offset: 2, height: 2, width: 3 },
{ offset: 4, height: 2, width: 2 },
{ offset: 6, height: 4, width: 1 },
]
return segments.flatMap((segment) => {
const y = bounds.y + bounds.height - segment.offset - segment.height
return [
{ x: bounds.x, y, width: segment.width, height: segment.height },
{ x: bounds.x + bounds.width - segment.width, y, width: segment.width, height: segment.height },
]
})
}
+738
View File
@@ -0,0 +1,738 @@
import { randomUUID } from "node:crypto"
import {
BrowserDriver,
type BrowserAttachment,
type ChromiumController,
type ChromiumPort,
type OpenCodeClient,
} from "@opencode-ai/client/node"
import { BrowserWindow, View, WebContentsView } from "electron"
import { installBrowserNetwork } from "./browser-network"
import { browserPaneRetryCandidate, emptyBrowserPaneState } from "./browser-pane-coordination"
import {
allowedBrowserDestination,
browserBottomMasks,
browserContextPartition,
browserDestinationOrigin,
browserHistoryDestinationOrigin,
normalizeBrowserBounds,
type BrowserPaneBounds,
} from "./browser-pane-policy"
import {
BrowserPaneSupersededError,
createBrowserPaneLifecycle,
sameBrowserPaneIdentity,
sameBrowserPaneSession,
type BrowserPaneClaim,
type BrowserPaneIdentity,
type BrowserPaneLifecycle,
} from "./browser-pane-lifecycle"
export type { BrowserPaneBounds, BrowserPaneIdentity }
type ChromiumViewState = {
readonly url: string
readonly title: string
readonly loading: boolean
readonly canGoBack: boolean
readonly canGoForward: boolean
}
type ChromiumViewEvent = {
readonly state: ChromiumViewState
readonly mainDocumentChanged: boolean
}
export type BrowserPaneState = ChromiumViewState & { readonly error?: string }
export type BrowserPaneStateUpdate = BrowserPaneIdentity & { readonly state: BrowserPaneState }
export type BrowserPaneLayout = {
readonly attached: boolean
readonly visible: boolean
readonly destroy?: boolean
readonly background?: string
readonly bounds?: BrowserPaneBounds
}
export type BrowserPaneCommand =
| { readonly type: "navigate"; readonly url: string }
| { readonly type: "back" }
| { readonly type: "forward" }
| { readonly type: "reload" }
| { readonly type: "stop" }
export interface BrowserPaneClients {
readonly client: (identity: BrowserPaneIdentity) => OpenCodeClient
}
type Page = {
readonly win: BrowserWindow
readonly identity: BrowserPaneIdentity
readonly view: WebContentsView
readonly session: Electron.Session
approvedOrigin?: string
readonly attachmentAbort: AbortController
readonly listeners: Set<(event: ChromiumViewEvent) => void>
attachment?: BrowserAttachment<ChromiumController<Page>>
portDispose?: () => void
closed: boolean
readonly cleanups: Array<() => void>
state: BrowserPaneState
}
type Entry = {
readonly win: BrowserWindow
readonly masks: View[]
readonly lifecycle: BrowserPaneLifecycle<Page>
desired?: DesiredLayout
failed?: BrowserPaneIdentity
disposed: boolean
readonly cleanups: Array<() => void>
state: BrowserPaneState
}
type AttachedLayout = BrowserPaneLayout
type DesiredLayout = { readonly identity: BrowserPaneIdentity; readonly layout: AttachedLayout }
export function createBrowserPaneController(clients: BrowserPaneClients) {
const entries = new Map<number, Entry>()
const publish = (
entry: Entry,
page?: Page,
input?: {
readonly error?: string
readonly identity?: BrowserPaneIdentity
readonly state?: ChromiumViewState
readonly mainDocumentChanged?: boolean
},
) => {
if (page && !entry.lifecycle.contains(page)) return entry.state
const source = input?.state ?? (page ? readViewState(page) : entry.state)
const message = input?.error?.slice(0, 1_024)
const state: BrowserPaneState = {
url: source.url.slice(0, 16_384),
title: source.title.slice(0, 1_024),
loading: source.loading,
canGoBack: source.canGoBack,
canGoForward: source.canGoForward,
...(message ? { error: message } : {}),
}
if (page) {
page.state = state
page.listeners.forEach((listener) =>
listener({ state, mainDocumentChanged: input?.mainDocumentChanged === true }),
)
if (!entry.lifecycle.contains(page)) return state
}
entry.state = state
const owner = input?.identity ?? entry.lifecycle.state() ?? page?.identity ?? entry.desired?.identity
if (!entry.win.isDestroyed() && owner) {
entry.win.webContents.send("browser-pane-state", {
serverKey: owner.serverKey,
sessionID: owner.sessionID,
bindingID: owner.bindingID,
endpointRevision: owner.endpointRevision,
state,
} satisfies BrowserPaneStateUpdate)
}
return state
}
const disposePage = (page: Page) => {
if (page.closed) return
page.closed = true
page.attachmentAbort.abort(new BrowserPaneSupersededError())
page.listeners.clear()
const attachment = page.attachment
page.attachment = undefined
const portDispose = page.portDispose
page.portDispose = undefined
const contents = page.view.webContents
page.cleanups.splice(0).forEach((cleanup) => {
try {
cleanup()
} catch {}
})
try {
portDispose?.()
} catch {}
try {
page.session.setPermissionRequestHandler(null)
page.session.setPermissionCheckHandler(null)
page.session.setDevicePermissionHandler(null)
page.session.setDisplayMediaRequestHandler(null)
} finally {
if (!contents.isDestroyed()) contents.stop()
if (!page.win.isDestroyed()) page.win.contentView.removeChildView(page.view)
if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false })
void attachment?.close().catch(() => undefined)
}
}
const createPage = (win: BrowserWindow, masks: View[], identity: BrowserPaneIdentity) => {
const client = clients.client(identity)
const view = new WebContentsView({
webPreferences: {
partition: browserContextPartition(identity.serverKey, identity.sessionID, identity.bindingID, randomUUID()),
nodeIntegration: false,
nodeIntegrationInWorker: false,
nodeIntegrationInSubFrames: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
webviewTag: false,
plugins: false,
experimentalFeatures: false,
safeDialogs: true,
disableDialogs: true,
navigateOnDragDrop: false,
autoplayPolicy: "document-user-activation-required",
focusOnNavigation: false,
devTools: false,
},
})
view.setVisible(false)
view.setBorderRadius(0)
view.setBackgroundColor("#ffffff")
win.contentView.addChildView(view)
masks.forEach((mask) => win.contentView.addChildView(mask))
const page: Page = {
win,
identity,
view,
session: view.webContents.session,
approvedOrigin: "about:blank",
attachmentAbort: new AbortController(),
listeners: new Set(),
closed: false,
cleanups: [],
state: emptyBrowserPaneState(),
}
const contents = view.webContents
page.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
page.session.setPermissionCheckHandler(() => false)
page.session.setDevicePermissionHandler(() => false)
page.session.setDisplayMediaRequestHandler((_request, callback) => callback({}))
const onDownload = (event: Electron.Event) => event.preventDefault()
page.session.on("will-download", onDownload)
page.cleanups.push(() => page.session.off("will-download", onDownload))
const preventUnsafeNavigation = (
event: Electron.Event<Electron.WebContentsWillNavigateEventParams | Electron.WebContentsWillRedirectEventParams>,
) => {
if (allowedBrowserDestination(event.url, page.approvedOrigin)) return
event.preventDefault()
const entry = entries.get(win.id)
if (entry) publish(entry, page, { error: "Navigation was blocked by the browser security policy" })
}
contents.on("will-navigate", preventUnsafeNavigation)
page.cleanups.push(() => contents.off("will-navigate", preventUnsafeNavigation))
contents.on("will-redirect", preventUnsafeNavigation)
page.cleanups.push(() => contents.off("will-redirect", preventUnsafeNavigation))
contents.setWindowOpenHandler(() => ({ action: "deny" }))
const onBounds = (event: Electron.Event) => event.preventDefault()
contents.on("content-bounds-updated", onBounds)
page.cleanups.push(() => contents.off("content-bounds-updated", onBounds))
const onPublish = () => {
const entry = entries.get(win.id)
if (entry) publish(entry, page)
}
contents.on("did-start-loading", onPublish)
page.cleanups.push(() => contents.off("did-start-loading", onPublish))
contents.on("did-stop-loading", onPublish)
page.cleanups.push(() => contents.off("did-stop-loading", onPublish))
contents.on("did-navigate", onPublish)
page.cleanups.push(() => contents.off("did-navigate", onPublish))
contents.on("did-navigate-in-page", onPublish)
page.cleanups.push(() => contents.off("did-navigate-in-page", onPublish))
contents.on("page-title-updated", onPublish)
page.cleanups.push(() => contents.off("page-title-updated", onPublish))
const onStartNavigation = (event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>) => {
if (!event.isMainFrame) return
const entry = entries.get(win.id)
if (!entry?.lifecycle.contains(page)) return
publish(entry, page, {
state: { ...readViewState(page), url: event.url, loading: true },
mainDocumentChanged: !event.isSameDocument,
})
}
contents.on("did-start-navigation", onStartNavigation)
page.cleanups.push(() => contents.off("did-start-navigation", onStartNavigation))
const onFailLoad = (
_event: Electron.Event,
code: number,
description: string,
_url: string,
isMainFrame: boolean,
) => {
if (!isMainFrame || code === -3) return
const entry = entries.get(win.id)
if (entry) publish(entry, page, { error: description })
}
contents.on("did-fail-load", onFailLoad)
page.cleanups.push(() => contents.off("did-fail-load", onFailLoad))
const replace = (message: string) => {
const entry = entries.get(win.id)
if (entry) replacePage(entry, page, message)
}
const onRenderGone = () => replace("The browser page crashed")
contents.on("render-process-gone", onRenderGone)
page.cleanups.push(() => contents.off("render-process-gone", onRenderGone))
const browserDebugger = contents.debugger
const onDebuggerDetach = () => replace("The browser debugger detached")
browserDebugger.on("detach", onDebuggerDetach)
page.cleanups.push(() => browserDebugger.off("detach", onDebuggerDetach))
const driver = BrowserDriver.chromium<Page>(async ({ proxy, signal }) => {
const cleanup = await installBrowserNetwork({ proxy, session: page.session, webContents: contents })
let disposed = false
let commands = Promise.resolve()
const dispose = () => {
if (disposed) return
disposed = true
if (page.portDispose === dispose) page.portDispose = undefined
cleanup()
if (page.closed) return
const entry = entries.get(win.id)
if (signal.aborted) {
const identity = entry?.lifecycle.crash(page)
if (entry && identity) {
entry.desired = undefined
entry.failed = identity
publish(entry, undefined, {
identity,
error: signal.reason instanceof Error ? signal.reason.message : "The browser attachment closed",
})
return
}
disposePage(page)
return
}
if (entry?.lifecycle.contains(page)) {
replacePage(entry, page, "The browser context restarted after its attachment closed")
return
}
disposePage(page)
}
page.portDispose = dispose
const assertCurrent = () => {
if (signal.aborted) throw signal.reason ?? new BrowserPaneSupersededError()
const entry = entries.get(win.id)
if (!entry || !ownsDesiredPage(entry, page, identity)) throw new BrowserPaneSupersededError()
}
const port = {
resource: page,
state: () => readViewState(page),
subscribe(listener: (event: ChromiumViewEvent) => void) {
page.listeners.add(listener)
return () => page.listeners.delete(listener)
},
navigate(url: string) {
const origin = browserDestinationOrigin(url)
if (origin === undefined) throw new Error("The browser navigation destination is not 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(method: string, params?: Record<string, unknown>) {
const result = commands.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(method, params)
})
commands = result.then(
() => undefined,
() => undefined,
)
return result
},
viewport: () => {
const bounds = view.getBounds()
return { width: bounds.width, height: bounds.height }
},
async screenshot(options: { readonly maxDimension: number }) {
if (!Number.isFinite(options.maxDimension) || options.maxDimension < 1) {
throw new TypeError("Browser screenshot dimension must be positive")
}
const source = await contents.capturePage()
const size = source.getSize()
const scale = Math.min(1, Math.floor(options.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
const dimensions = image.getSize()
return { data: new Uint8Array(image.toPNG()), width: dimensions.width, height: dimensions.height }
},
dispose,
} satisfies ChromiumPort<Page>
return Promise.resolve()
.then(assertCurrent)
.then(() => contents.loadURL("about:blank"))
.then(() => {
assertCurrent()
return port
})
.catch((error) => {
dispose()
throw error
})
})
const ready = client.browser
.attach({ sessionID: identity.sessionID, driver, signal: page.attachmentAbort.signal })
.then(async (attachment) => {
page.attachment = attachment
if (!page.closed) return
await attachment.close()
throw new BrowserPaneSupersededError()
})
return { context: page, ready }
}
const createEntry = (win: BrowserWindow) => {
const masks = Array.from({ length: 8 }, () => new View())
masks.forEach((mask) => {
mask.setVisible(false)
win.contentView.addChildView(mask)
})
const entry: Entry = {
win,
masks,
lifecycle: createBrowserPaneLifecycle<Page>({
create: (identity) => createPage(win, masks, identity),
close: disposePage,
}),
disposed: false,
cleanups: [],
state: emptyBrowserPaneState(),
}
entries.set(win.id, entry)
const contents = win.webContents
const onParentNavigation = () => detachEntry(entry)
contents.on("did-start-navigation", onParentNavigation)
entry.cleanups.push(() => {
if (!contents.isDestroyed()) contents.off("did-start-navigation", onParentNavigation)
})
const onParentGone = () => detachEntry(entry)
contents.on("render-process-gone", onParentGone)
entry.cleanups.push(() => {
if (!contents.isDestroyed()) contents.off("render-process-gone", onParentGone)
})
const onClosed = () => disposeEntry(entry)
win.once("closed", onClosed)
entry.cleanups.push(() => {
if (!win.isDestroyed()) win.off("closed", onClosed)
})
return entry
}
const setLayout = (win: BrowserWindow, binding: BrowserPaneIdentity, layout: BrowserPaneLayout) => {
const entry = entries.get(win.id)
const identity = requireIdentity(binding)
if (layout.destroy) {
if (entry && ownsIdentity(entry, identity)) disposeEntry(entry)
return
}
if (!layout.attached) {
if (entry) detachEntry(entry, identity)
return
}
const next = entry ?? createEntry(win)
const owner = [...entries.values()].find((item) => {
if (item === next) return false
const state = item.lifecycle.state()
return state ? sameBrowserPaneSession(state, identity) : false
})
if (owner) {
detachEntry(next)
next.state = emptyBrowserPaneState()
next.desired = { identity, layout }
publish(next, undefined, {
identity,
error: "Browser tools for this server Session are attached in another window",
})
return
}
if (next.failed && sameBrowserPaneIdentity(next.failed, identity)) {
next.desired = { identity, layout }
return
}
next.failed = undefined
next.desired = { identity, layout }
const state = next.lifecycle.state()
if (!state || !sameBrowserPaneIdentity(state, identity)) {
beginAttachment(next, identity, layout)
return
}
const claim = next.lifecycle.current()
if (!claim) {
hideEntry(next)
return
}
applyLayout(next, claim, layout)
}
const applyLayout = (entry: Entry, claim: BrowserPaneClaim<Page>, layout: AttachedLayout) => {
if (!layout.visible || !layout.bounds || !entry.lifecycle.isCurrent(claim)) {
hideEntry(entry)
return
}
const bounds = normalizeBrowserBounds(layout.bounds, entry.win.contentView.getBounds())
if (!bounds) {
hideEntry(entry)
return
}
claim.context.view.setBounds(bounds)
claim.context.view.setVisible(true)
const maskBounds = browserBottomMasks(bounds)
entry.masks.forEach((mask, index) => {
const next = maskBounds[index]
if (!next) {
mask.setVisible(false)
return
}
mask.setBackgroundColor(layout.background ?? "#000000")
mask.setBounds(next)
entry.win.contentView.addChildView(mask)
mask.setVisible(true)
})
}
const beginAttachment = (entry: Entry, identity: BrowserPaneIdentity, layout: AttachedLayout) => {
hideEntry(entry)
entry.failed = undefined
entry.state = emptyBrowserPaneState()
entry.desired = { identity, layout }
publish(entry, undefined, { identity })
const pending = entry.lifecycle.claim(identity)
void pending.then(
(claim) => {
if (!entry.lifecycle.isCurrent(claim)) return
const desired = entry.desired
if (!desired || !sameBrowserPaneIdentity(desired.identity, claim)) {
entry.lifecycle.release(claim)
return
}
publish(entry, claim.context)
applyLayout(entry, claim, desired.layout)
},
(error) => {
if (error instanceof BrowserPaneSupersededError) return
const desired = entry.desired
if (!desired || !sameBrowserPaneIdentity(desired.identity, identity)) return
publish(entry, undefined, { error: error instanceof Error ? error.message : String(error) })
},
)
}
const replacePage = (entry: Entry, page: Page, message: string) => {
if (!entry.lifecycle.contains(page)) return
hideEntry(entry)
const identity = entry.lifecycle.crash(page)
if (!identity) return
publish(entry, undefined, { error: message })
const desired = entry.desired
if (!desired || !sameBrowserPaneIdentity(desired.identity, identity)) return
beginAttachment(entry, desired.identity, desired.layout)
}
const command = async (win: BrowserWindow, binding: BrowserPaneIdentity, input: BrowserPaneCommand) => {
const identity = requireIdentity(binding)
const entry = entries.get(win.id)
const claim = entry?.lifecycle.current()
if (!entry || !claim || !sameBrowserPaneIdentity(claim, identity)) {
throw new Error("The browser pane binding is no longer attached.")
}
const controller = claim.context.attachment?.resource
if (!controller || controller.resource !== claim.context) {
throw new Error("The browser pane binding is no longer attached.")
}
switch (input.type) {
case "navigate":
return controller.navigate(input.url)
case "back":
return controller.back()
case "forward":
return controller.forward()
case "reload":
return controller.reload()
case "stop":
controller.stop()
}
}
const state = (win: BrowserWindow, binding: BrowserPaneIdentity) => {
const identity = requireIdentity(binding)
const entry = entries.get(win.id)
return entry && ownsIdentity(entry, identity) ? entry.state : emptyBrowserPaneState()
}
const dispose = () => {
for (const entry of entries.values()) disposeEntry(entry, false)
}
const detachEntry = (entry: Entry, identity?: BrowserPaneIdentity) => {
const owner = entry.lifecycle.state()
if (!detach(entry, identity)) return
if (owner) retryBlocked(owner)
}
const invalidate = (serverKey: string) => {
for (const entry of entries.values()) {
const owner = entry.lifecycle.state()
if (owner?.serverKey !== serverKey && entry.desired?.identity.serverKey !== serverKey) continue
disposeEntry(entry, false)
}
}
function disposeEntry(entry: Entry, retry = true) {
if (entry.disposed) return
const owner = entry.lifecycle.state()
entry.disposed = true
if (entries.get(entry.win.id) === entry) entries.delete(entry.win.id)
entry.desired = undefined
entry.failed = undefined
entry.lifecycle.dispose()
entry.cleanups.splice(0).forEach((cleanup) => cleanup())
if (!entry.win.isDestroyed()) entry.masks.forEach((mask) => entry.win.contentView.removeChildView(mask))
if (retry && owner) retryBlocked(owner)
}
function retryBlocked(identity: BrowserPaneIdentity) {
const id = browserPaneRetryCandidate(
[...entries.values()].map((entry) => ({
id: entry.win.id,
owner: entry.lifecycle.state(),
desired: entry.desired?.identity,
})),
identity,
)
if (id === undefined) return
const entry = entries.get(id)
const desired = entry?.desired
if (!entry || !desired) return
beginAttachment(entry, desired.identity, desired.layout)
}
return { setLayout, command, state, invalidate, dispose }
}
export type BrowserPaneController = ReturnType<typeof createBrowserPaneController>
function readViewState(page: Page): ChromiumViewState {
const contents = page.view.webContents
if (contents.isDestroyed()) {
return {
url: page.state.url,
title: page.state.title,
loading: false,
canGoBack: page.state.canGoBack,
canGoForward: page.state.canGoForward,
}
}
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 origin = browserHistoryDestinationOrigin(history, offset)
if (origin === undefined) throw new Error("The browser history destination is not allowed")
page.approvedOrigin = origin
history.goToOffset(offset)
}
function detach(entry: Entry, identity?: BrowserPaneIdentity) {
const owner = entry.lifecycle.state() ?? entry.desired?.identity
if (!owner || (identity && !sameBrowserPaneIdentity(owner, identity))) return false
hideEntry(entry)
entry.desired = undefined
entry.failed = undefined
entry.lifecycle.release(identity)
return true
}
function ownsIdentity(entry: Entry, identity: BrowserPaneIdentity) {
const owner = entry.lifecycle.state() ?? entry.desired?.identity
return owner ? sameBrowserPaneIdentity(owner, identity) : false
}
function ownsDesiredPage(entry: Entry, page: Page, identity: BrowserPaneIdentity) {
const owner = entry.lifecycle.state()
return (
!entry.disposed &&
!page.closed &&
owner?.context === page &&
sameBrowserPaneIdentity(owner, identity) &&
!!entry.desired &&
sameBrowserPaneIdentity(entry.desired.identity, identity)
)
}
function hideEntry(entry: Entry) {
const page = entry.lifecycle.state()?.context
if (page && !page.view.webContents.isDestroyed()) page.view.setVisible(false)
entry.masks.forEach((mask) => mask.setVisible(false))
}
function readIdentity(input: {
readonly serverKey?: string
readonly sessionID?: string
readonly bindingID?: string
readonly endpointRevision?: number
}) {
const endpointRevision = input.endpointRevision
if (
!input.serverKey ||
!input.sessionID ||
!input.bindingID ||
typeof endpointRevision !== "number" ||
!Number.isSafeInteger(endpointRevision) ||
endpointRevision < 0
)
return undefined
return {
serverKey: input.serverKey,
sessionID: input.sessionID,
bindingID: input.bindingID,
endpointRevision,
}
}
function requireIdentity(input: {
readonly serverKey?: string
readonly sessionID?: string
readonly bindingID?: string
readonly endpointRevision?: number
}): BrowserPaneIdentity {
const identity = readIdentity(input)
if (!identity) throw new TypeError("Browser pane attachment identity is incomplete.")
return identity
}
+6
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 { createBrowserDesktop } from "./browser-desktop"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@@ -97,6 +98,7 @@ function ensureLoopbackNoProxy() {
}
const main = Effect.gen(function* () {
const browser = createBrowserDesktop()
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()
+34 -1
View File
@@ -10,9 +10,17 @@ 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 { BrowserDesktop } from "./browser-desktop"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
@@ -41,6 +49,7 @@ type Deps = {
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
browser: BrowserDesktop.Controller
}
export function registerIpcHandlers(deps: Deps) {
@@ -88,6 +97,19 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)
ipcMain.on("browser-pane-layout", (event, binding: unknown, layout: unknown) => {
const win = trustedWindow(event)
if (!win) return
try {
deps.browser.setLayout(win, binding, layout)
} catch {}
})
ipcMain.handle("browser-pane-command", (event, binding: unknown, command: unknown) => {
return deps.browser.command(requireTrustedWindow(event), binding, command)
})
ipcMain.handle("browser-pane-state", (event, binding: unknown) => {
return deps.browser.state(requireTrustedWindow(event), binding)
})
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
try {
const store = getStore(name)
@@ -270,6 +292,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)
}
+1 -1
View File
@@ -438,7 +438,7 @@ function allowRendererPermissions(win: BrowserWindow) {
})
}
function isTrustedRendererUrl(value?: string) {
export function isTrustedRendererUrl(value?: string) {
return isRendererUrl(value)
}
+11 -1
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import type { ElectronAPI, WslServersEvent } from "./types"
import type { BrowserPaneStateUpdate, ElectronAPI, WslServersEvent } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater"
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
@@ -56,6 +56,16 @@ const api: ElectronAPI = {
check: () => ipcRenderer.invoke("updater-check"),
install: () => ipcRenderer.invoke("updater-install"),
},
browserPane: {
setLayout: (binding, layout) => ipcRenderer.send("browser-pane-layout", binding, layout),
command: (binding, command) => ipcRenderer.invoke("browser-pane-command", binding, command),
state: (binding) => ipcRenderer.invoke("browser-pane-state", binding),
onState: (callback) => {
const handler = (_event: unknown, update: BrowserPaneStateUpdate) => callback(update)
ipcRenderer.on("browser-pane-state", handler)
return () => ipcRenderer.removeListener("browser-pane-state", handler)
},
},
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
+17
View File
@@ -1,6 +1,12 @@
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,
BrowserPaneCommand,
BrowserPaneLayout,
BrowserPaneState,
} from "@opencode-ai/app/browser-pane"
export type {
WslDistroProbe,
WslInstalledDistro,
@@ -27,6 +33,16 @@ export type UpdaterAPI = {
check: () => Promise<UpdaterState>
install: () => Promise<void>
}
export type BrowserPaneStateUpdate = Pick<BrowserPaneBinding, "serverKey" | "sessionID" | "bindingID"> & {
readonly endpointRevision: number
readonly state: BrowserPaneState
}
export type BrowserPaneAPI = {
setLayout: (binding: BrowserPaneBinding, layout: BrowserPaneLayout) => void
command: (binding: BrowserPaneBinding, command: BrowserPaneCommand) => Promise<void>
state: (binding: BrowserPaneBinding) => Promise<BrowserPaneStateUpdate>
onState: (callback: (update: BrowserPaneStateUpdate) => void) => () => void
}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
@@ -47,6 +63,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>
+4
View File
@@ -28,6 +28,10 @@
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/desktop#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/ui#test": {
"dependsOn": ["^build"],
"outputs": []