mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 14:11:49 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bfe2d7d58 | |||
| 8484a99635 | |||
| a3085440bd | |||
| fb266e61a2 | |||
| 3d11fa9138 | |||
| e8c398712d |
@@ -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:",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:*")
|
||||
})
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -438,7 +438,7 @@ function allowRendererPermissions(win: BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
function isTrustedRendererUrl(value?: string) {
|
||||
export function isTrustedRendererUrl(value?: string) {
|
||||
return isRendererUrl(value)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/desktop#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/ui#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
|
||||
Reference in New Issue
Block a user