mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 14:36:20 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b264265f7c | ||
|
|
c2c49758fa | ||
|
|
714aa408b7 |
@@ -423,6 +423,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -4,6 +4,16 @@ export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export type {
|
||||
BrowserPaneCommand,
|
||||
BrowserPaneEndpoint,
|
||||
BrowserPaneEvent,
|
||||
BrowserPaneLayout,
|
||||
BrowserPanePlatform,
|
||||
BrowserPaneRegistration,
|
||||
BrowserPaneState,
|
||||
BrowserPaneTarget,
|
||||
} from "./runtime/platform/browser-pane"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
|
||||
@@ -66,6 +66,7 @@ export const dict = {
|
||||
"command.terminal.toggle": "Toggle terminal",
|
||||
"command.fileTree.toggle": "Toggle file tree",
|
||||
"command.review.toggle": "Toggle review",
|
||||
"command.browser.toggle": "Toggle browser",
|
||||
"command.terminal.new": "New terminal",
|
||||
"command.terminal.new.description": "Create a new terminal tab",
|
||||
"command.steps.toggle": "Toggle steps",
|
||||
@@ -804,6 +805,10 @@ export const dict = {
|
||||
"PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.",
|
||||
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
|
||||
|
||||
"session.browser.address": "Browser address",
|
||||
"session.browser.address.placeholder": "Enter a URL",
|
||||
"session.browser.close": "Close browser",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.updateVersion": "Update {{version}}",
|
||||
@@ -971,6 +976,8 @@ export const dict = {
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.browserPane.title": "Browser pane",
|
||||
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
@@ -1161,6 +1168,9 @@ export const dict = {
|
||||
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
"settings.permissions.tool.websearch.description": "Search the web",
|
||||
"settings.permissions.tool.browser_read.description": "Read pages and capture screenshots in the browser",
|
||||
"settings.permissions.tool.browser_navigate.description": "Navigate the browser to a URL",
|
||||
"settings.permissions.tool.browser_interact.description": "Click, type, and interact with pages in the browser",
|
||||
"settings.permissions.tool.external_directory.title": "External Directory",
|
||||
"settings.permissions.tool.external_directory.description": "Access files outside the project directory",
|
||||
"settings.permissions.tool.doom_loop.title": "Doom Loop",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Browser } from "@opencode-ai/schema/browser"
|
||||
|
||||
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
|
||||
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
|
||||
export type BrowserPaneLayout = { visible: boolean; bounds?: { x: number; y: number; width: number; height: number } }
|
||||
|
||||
export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent = { type: "open" } | { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type BrowserPanePlatform = {
|
||||
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { BrowserPanePlatform } from "./browser-pane"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -115,6 +116,9 @@ type PlatformBase = {
|
||||
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import type { SessionModel } from "../model"
|
||||
|
||||
export function createSessionBrowser(session: SessionModel) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const layout = useLayout()
|
||||
const [state, setState] = createStore({
|
||||
opened: false,
|
||||
registration: undefined as BrowserPaneRegistration | undefined,
|
||||
browser: null as BrowserPaneState,
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
const available = createMemo(
|
||||
() =>
|
||||
!!platform.browserPane &&
|
||||
settings.ready() &&
|
||||
settings.general.experimentalBrowser() &&
|
||||
session.isDesktop() &&
|
||||
!!session.identity.sessionID() &&
|
||||
!server.health?.incompatible,
|
||||
)
|
||||
const open = () => {
|
||||
session.layout.view().reviewPanel.close()
|
||||
layout.fileTree.close()
|
||||
setState("opened", true)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
const pane = platform.browserPane
|
||||
setState({ opened: false, registration: undefined, browser: null, error: undefined })
|
||||
if (!available() || !sessionID || !pane) return
|
||||
const owner = session.ownership.capture()
|
||||
const target = { sessionID, endpoint: server.conn.http }
|
||||
let registration: BrowserPaneRegistration | undefined
|
||||
const register = () => {
|
||||
if (registration) return
|
||||
registration = pane.register(target, (event) =>
|
||||
owner.run(() => (event.type === "open" ? open() : setState({ browser: event.state, error: event.error }))),
|
||||
)
|
||||
setState({ registration, browser: null, error: undefined })
|
||||
}
|
||||
// A new session appears in the UI before its server-side creation finishes.
|
||||
const unsubscribe = session.shared.data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === sessionID) register()
|
||||
})
|
||||
if (!session.shared.data.session.creating(sessionID)) register()
|
||||
onCleanup(() => {
|
||||
unsubscribe()
|
||||
registration?.close()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (state.opened && (session.layout.view().reviewPanel.opened() || layout.fileTree.opened())) {
|
||||
setState("opened", false)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
available,
|
||||
opened: () => state.opened,
|
||||
state: () => state.browser,
|
||||
error: () => state.error,
|
||||
registration: () => (state.opened ? state.registration : undefined),
|
||||
close: () => setState("opened", false),
|
||||
toggle: () => (state.opened ? setState("opened", false) : open()),
|
||||
command(command: BrowserPaneCommand) {
|
||||
setState("error", undefined)
|
||||
const owner = session.ownership.capture()
|
||||
void state.registration?.command(command).catch((error: unknown) => {
|
||||
if (!owner.current()) return
|
||||
setState("error", error instanceof Error ? error.message : language.t("common.requestFailed"))
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Loader } from "@opencode-ai/ui/loader"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: {
|
||||
registration: BrowserPaneRegistration
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const state = props.browser.state
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
let surface: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
|
||||
const measure = () => {
|
||||
frame = undefined
|
||||
if (!surface) return
|
||||
const rect = surface.getBoundingClientRect()
|
||||
const zoom = platform.webviewZoom?.() ?? 1
|
||||
const left = Math.round(rect.left * zoom)
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = store.visible && !dialog.active
|
||||
const next = `${visible}:${left}:${top}:${right}:${bottom}`
|
||||
if (next !== layout) {
|
||||
layout = next
|
||||
props.registration.setLayout({
|
||||
visible,
|
||||
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
||||
})
|
||||
}
|
||||
if (performance.now() < until) frame = requestAnimationFrame(measure)
|
||||
}
|
||||
const schedule = (duration = 0) => {
|
||||
until = Math.max(until, performance.now() + duration)
|
||||
if (frame === undefined) frame = requestAnimationFrame(measure)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
|
||||
createEffect(on([() => platform.webviewZoom?.(), () => dialog.active, () => store.visible], () => schedule(300)))
|
||||
createResizeObserver(() => surface, schedule.bind(null, 0))
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
props.registration.setLayout()
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="browser-panel"
|
||||
class="relative size-full min-w-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] flex flex-col"
|
||||
>
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => props.browser.command({ type: direction })}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => props.browser.command(state()?.loading ? { type: "stop" } : { type: "reload" })}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<form
|
||||
class="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (store.address.trim()) props.browser.command({ type: "navigate", url: store.address })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
|
||||
value={store.address}
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
/>
|
||||
</form>
|
||||
<IconButton
|
||||
{...button}
|
||||
aria-label={language.t("session.browser.close")}
|
||||
onClick={props.browser.close}
|
||||
icon={<Icon name="close-small" size="small" />}
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type SessionHeaderActionsState = {
|
||||
reviewVisible: boolean
|
||||
reviewOpened: boolean
|
||||
onReviewToggle: () => void
|
||||
browser?: { label: string; opened: boolean; onToggle: () => void }
|
||||
}
|
||||
|
||||
export function SessionHeaderActions(props: { state: SessionHeaderActionsState }) {
|
||||
@@ -50,6 +51,24 @@ export function SessionHeaderActions(props: { state: SessionHeaderActionsState }
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.state.browser}>
|
||||
{(browser) => (
|
||||
<Tooltip class="shrink-0" placement="bottom" value={browser().label}>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
state={browser().opened ? "pressed" : undefined}
|
||||
onClick={browser().onToggle}
|
||||
aria-label={browser().label}
|
||||
aria-expanded={browser().opened}
|
||||
aria-controls="browser-panel"
|
||||
icon={<Icon name="window-cursor" size="small" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import { useSessionLayout } from "@/session/session-layout"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
|
||||
|
||||
export function SessionHeader() {
|
||||
export function SessionHeader(props: { browser: ReturnType<typeof createSessionBrowser> }) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -28,6 +29,9 @@ export function SessionHeader() {
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
browser: props.browser.available()
|
||||
? { label: language.t("command.browser.toggle"), opened: props.browser.opened(), onToggle: props.browser.toggle }
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SessionModel } from "./model"
|
||||
import { sessionPanelLayout } from "./session-panel-layout"
|
||||
import { clampSessionPanelWidth, sessionPanelWidthMax } from "./session-panel-width"
|
||||
|
||||
export function createSessionScreenLayout(session: SessionModel) {
|
||||
export function createSessionScreenLayout(session: SessionModel, browserOpen: () => boolean) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
const size = createSizing()
|
||||
@@ -26,7 +26,7 @@ export function createSessionScreenLayout(session: SessionModel) {
|
||||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
|
||||
const resizable = createMemo(() => reviewPanelOpen() || browserOpen() || sideTerminalOpen())
|
||||
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
|
||||
const [rowSize, setRowSize] = createStore<{ width?: number; height?: number }>({})
|
||||
let row: HTMLDivElement | undefined
|
||||
@@ -60,6 +60,7 @@ export function createSessionScreenLayout(session: SessionModel) {
|
||||
const panelLayout = createMemo(() =>
|
||||
sessionPanelLayout({
|
||||
review: reviewPanelOpen(),
|
||||
browser: browserOpen(),
|
||||
terminal: sideTerminalOpen(),
|
||||
files: fileTreeOpen(),
|
||||
}),
|
||||
@@ -70,7 +71,7 @@ export function createSessionScreenLayout(session: SessionModel) {
|
||||
if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked })
|
||||
return stacked
|
||||
}, panelLayout().stacked)
|
||||
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
|
||||
const sideRegionOpen = createMemo(() => reviewPanelOpen() || browserOpen() || fileTreeOpen())
|
||||
const terminalPane = createMemo(() =>
|
||||
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
|
||||
)
|
||||
|
||||
@@ -31,6 +31,8 @@ import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { SessionBrowserPane } from "./browser/pane"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -46,7 +48,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const browser = createSessionBrowser(session)
|
||||
const screen = createSessionScreenLayout(session, browser.opened)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const messagesReady = timeline.ready
|
||||
const [store, setStore] = createStore({
|
||||
@@ -249,7 +252,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionHeader />
|
||||
<SessionHeader browser={browser} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
@@ -330,7 +333,13 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
|
||||
<Show
|
||||
when={browser.registration()}
|
||||
keyed
|
||||
fallback={<SessionDesktopReview review={review} present={store.sideReviewPresent} />}
|
||||
>
|
||||
{(registration) => <SessionBrowserPane registration={registration} browser={browser} />}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -3,15 +3,23 @@ import { sessionPanelLayout } from "./session-panel-layout"
|
||||
|
||||
describe("sessionPanelLayout", () => {
|
||||
test("keeps one owner while changing panel geometry", () => {
|
||||
expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({
|
||||
expect(sessionPanelLayout({ review: false, browser: false, terminal: false, files: false })).toEqual({
|
||||
visible: false,
|
||||
stacked: false,
|
||||
})
|
||||
expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({
|
||||
expect(sessionPanelLayout({ review: false, browser: false, terminal: true, files: false })).toEqual({
|
||||
visible: true,
|
||||
stacked: false,
|
||||
})
|
||||
expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({
|
||||
expect(sessionPanelLayout({ review: true, browser: false, terminal: true, files: false })).toEqual({
|
||||
visible: true,
|
||||
stacked: true,
|
||||
})
|
||||
expect(sessionPanelLayout({ review: false, browser: true, terminal: false, files: false })).toEqual({
|
||||
visible: true,
|
||||
stacked: false,
|
||||
})
|
||||
expect(sessionPanelLayout({ review: false, browser: true, terminal: true, files: false })).toEqual({
|
||||
visible: true,
|
||||
stacked: true,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) {
|
||||
export function sessionPanelLayout(input: { review: boolean; browser: boolean; terminal: boolean; files: boolean }) {
|
||||
return {
|
||||
visible: input.review || input.terminal || input.files,
|
||||
stacked: input.review && input.terminal,
|
||||
visible: input.review || input.browser || input.terminal || input.files,
|
||||
stacked: (input.review || input.browser) && input.terminal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +438,20 @@ export const SettingsGeneral: Component<{
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={(checked) => settings.general.setExperimentalBrowser(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface Settings {
|
||||
mobileDiffWrap: boolean
|
||||
terminalPlacement: TerminalPlacement
|
||||
followUpBehavior: FollowUpBehavior
|
||||
experimentalBrowser: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -134,6 +135,7 @@ const defaultSettings: Settings = {
|
||||
mobileDiffWrap: true,
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: true,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -285,6 +287,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setFollowUpBehavior(value: FollowUpBehavior) {
|
||||
setStore("general", "followUpBehavior", value)
|
||||
},
|
||||
experimentalBrowser: withFallback(
|
||||
() => store.general?.experimentalBrowser,
|
||||
defaultSettings.general.experimentalBrowser,
|
||||
),
|
||||
setExperimentalBrowser(value: boolean) {
|
||||
setStore("general", "experimentalBrowser", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { Browser } from "@opencode-ai/schema/browser"
|
||||
import electron, { type BrowserWindow } from "electron"
|
||||
|
||||
type AXNode = {
|
||||
nodeId: string
|
||||
backendDOMNodeId?: number
|
||||
childIds?: string[]
|
||||
ignored?: boolean
|
||||
role?: { value?: string }
|
||||
name?: { value?: unknown }
|
||||
properties?: Array<{ name: string; value?: { value?: unknown } }>
|
||||
}
|
||||
|
||||
export type BrowserPage = ReturnType<typeof createBrowserPage>
|
||||
|
||||
export function createBrowserPage(win: BrowserWindow, publish: (error?: string) => void, fail: () => void) {
|
||||
const view = new electron.WebContentsView({
|
||||
webPreferences: {
|
||||
partition: `opencode-browser-${crypto.randomUUID()}`,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: false,
|
||||
devTools: false,
|
||||
disableDialogs: true,
|
||||
},
|
||||
})
|
||||
const contents = view.webContents
|
||||
const refs = new Map<string, { id: number; editable: boolean }>()
|
||||
let generation = 0
|
||||
let nextRef = 0
|
||||
let closed = false
|
||||
const state = (): Browser.State => ({
|
||||
url: contents.getURL().slice(0, 16_384),
|
||||
title: contents.getTitle().slice(0, 1_024),
|
||||
loading: contents.isLoading(),
|
||||
canGoBack: contents.navigationHistory.canGoBack(),
|
||||
canGoForward: contents.navigationHistory.canGoForward(),
|
||||
generation,
|
||||
})
|
||||
const update = () => {
|
||||
if (!closed) publish()
|
||||
}
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
if (input.type !== "keyDown" || input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
|
||||
const step =
|
||||
input.key === "=" || input.key === "+" || input.code === "NumpadAdd"
|
||||
? 0.5
|
||||
: input.key === "-" || input.code === "NumpadSubtract"
|
||||
? -0.5
|
||||
: 0
|
||||
if (!step && input.key !== "0") return
|
||||
event.preventDefault()
|
||||
contents.setZoomLevel(input.key === "0" ? 0 : contents.getZoomLevel() + step)
|
||||
})
|
||||
const session = contents.session
|
||||
session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
|
||||
session.setPermissionCheckHandler(() => false)
|
||||
session.setDevicePermissionHandler(() => false)
|
||||
session.setDisplayMediaRequestHandler((_request, callback) => callback({}))
|
||||
session.on("will-download", (event) => event.preventDefault())
|
||||
contents.setWindowOpenHandler(() => ({ action: "deny" }))
|
||||
contents.on("content-bounds-updated", (event) => event.preventDefault())
|
||||
const guard = (event: Electron.Event<{ url: string }>) => {
|
||||
if (event.url === "about:blank" || destinationOrigin(event.url)) return
|
||||
event.preventDefault()
|
||||
publish("ERR_BLOCKED_BY_CLIENT")
|
||||
}
|
||||
contents.on("will-frame-navigate", guard)
|
||||
contents.on("will-redirect", guard)
|
||||
contents.on("did-stop-loading", update)
|
||||
contents.on("did-navigate-in-page", update)
|
||||
contents.on("page-title-updated", update)
|
||||
contents.on("did-start-navigation", (event) => {
|
||||
if (!event.isMainFrame) return
|
||||
generation++
|
||||
refs.clear()
|
||||
update()
|
||||
})
|
||||
contents.on("did-fail-load", (_event, code, error, _url, mainFrame) => {
|
||||
if (!closed && mainFrame && code !== -3) publish(error)
|
||||
})
|
||||
contents.on("render-process-gone", () => {
|
||||
if (!closed) fail()
|
||||
})
|
||||
contents.debugger.on("detach", () => {
|
||||
if (!closed) fail()
|
||||
})
|
||||
view.setVisible(false)
|
||||
win.contentView.addChildView(view)
|
||||
return {
|
||||
view,
|
||||
state,
|
||||
execute,
|
||||
ready: Promise.resolve().then(() => contents.loadURL("about:blank")),
|
||||
dispose() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
refs.clear()
|
||||
if (!win.isDestroyed()) win.contentView.removeChildView(view)
|
||||
if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false })
|
||||
},
|
||||
}
|
||||
|
||||
async function execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
|
||||
const action = command.action
|
||||
check()
|
||||
if (action.type === "navigate") {
|
||||
await navigate(action.url, signal)
|
||||
return { type: "state", state: state() }
|
||||
}
|
||||
if (["open", "back", "forward", "reload", "stop"].includes(action.type)) {
|
||||
if (action.type === "stop") contents.stop()
|
||||
if (action.type === "reload") contents.reload()
|
||||
if (action.type === "back" && contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack()
|
||||
if (action.type === "forward" && contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward()
|
||||
return { type: "state", state: state() }
|
||||
}
|
||||
if (action.type === "evaluate") {
|
||||
const value: unknown = await contents.executeJavaScript(action.script)
|
||||
check()
|
||||
return { type: "evaluate", state: state(), content: (JSON.stringify(value) ?? "null").slice(0, 100_000) }
|
||||
}
|
||||
if (action.type === "snapshot") {
|
||||
const tree = (await send("Accessibility.getFullAXTree", { depth: 6 })) as { nodes: AXNode[] }
|
||||
refs.clear()
|
||||
const nodes = new Map(tree.nodes.map((node) => [node.nodeId, node]))
|
||||
const lines = [`Page: ${clean(state().title)}`, `URL: ${state().url}`, ""]
|
||||
if (tree.nodes[0]) walk(tree.nodes[0], 0)
|
||||
return {
|
||||
type: "snapshot",
|
||||
state: state(),
|
||||
content: lines.join("\n").slice(0, 40_960),
|
||||
}
|
||||
|
||||
function walk(node: AXNode, depth: number) {
|
||||
if (depth > 6 || lines.length >= 503) return
|
||||
const role = (node.role?.value ?? "node").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40)
|
||||
const properties = new Map((node.properties ?? []).map((item) => [item.name, item.value?.value]))
|
||||
const editable =
|
||||
["textbox", "searchbox", "combobox", "spinbutton"].includes(role) || !!properties.get("editable")
|
||||
if (!node.ignored) {
|
||||
const actionable = properties.get("focusable") || /^(button|link|textbox|combobox)$/.test(role)
|
||||
const ref = actionable && node.backendDOMNodeId ? `e${++nextRef}` : ""
|
||||
if (ref && node.backendDOMNodeId)
|
||||
refs.set(ref, {
|
||||
id: node.backendDOMNodeId,
|
||||
editable: editable && !properties.get("disabled") && !properties.get("readonly"),
|
||||
})
|
||||
const flags = ["checked", "disabled", "expanded", "selected"].flatMap((flag) =>
|
||||
properties.has(flag) ? [`${flag}=${properties.get(flag)}`] : [],
|
||||
)
|
||||
lines.push(
|
||||
`${" ".repeat(depth)}${ref ? `@${ref}` : ""} [${role}] ${JSON.stringify(clean(node.name?.value))} ${flags.join(" ")}`,
|
||||
)
|
||||
}
|
||||
// Editable descendants can repeat the field's value as static text.
|
||||
if (!editable)
|
||||
node.childIds?.forEach((id) => {
|
||||
const child = nodes.get(id)
|
||||
if (child) walk(child, depth + 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (action.type === "screenshot") {
|
||||
const source = await contents.capturePage()
|
||||
check()
|
||||
const size = source.getSize()
|
||||
if (!size.width || !size.height) throw new Error("internal")
|
||||
const scale = Math.min(1, 2_000 / Math.max(size.width, size.height))
|
||||
const image = source.resize({
|
||||
width: Math.max(1, Math.round(size.width * scale)),
|
||||
height: Math.max(1, Math.round(size.height * scale)),
|
||||
})
|
||||
const data = new Uint8Array(image.toPNG())
|
||||
if (data.byteLength > 5 * 1_024 * 1_024) throw new Error("result_too_large")
|
||||
return { type: "screenshot", state: state(), data }
|
||||
}
|
||||
if (action.type === "click" || action.type === "fill") {
|
||||
const target = refs.get(action.ref.replace(/^@/, ""))
|
||||
if (!target || (action.type === "fill" && !target.editable)) throw new Error("stale_ref")
|
||||
if (action.type === "fill") {
|
||||
await send("DOM.focus", { backendNodeId: target.id })
|
||||
await key({ key: "a", code: "KeyA", modifiers: process.platform === "darwin" ? 4 : 2 })
|
||||
await key({ key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 })
|
||||
await send("Input.insertText", { text: action.text })
|
||||
}
|
||||
if (action.type === "click") {
|
||||
await send("DOM.scrollIntoViewIfNeeded", { backendNodeId: target.id })
|
||||
const result = (await send("DOM.getBoxModel", { backendNodeId: target.id })) as {
|
||||
model: { content: number[] }
|
||||
}
|
||||
const box = result.model.content
|
||||
const point = { x: (box[0] + box[4]) / 2, y: (box[1] + box[5]) / 2 }
|
||||
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
|
||||
await send("Input.dispatchMouseEvent", { type, ...point, button: "left", clickCount: 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action.type === "press") {
|
||||
const codes: Record<Browser.Key, number> = {
|
||||
Enter: 13,
|
||||
Tab: 9,
|
||||
Escape: 27,
|
||||
Backspace: 8,
|
||||
Delete: 46,
|
||||
ArrowUp: 38,
|
||||
ArrowDown: 40,
|
||||
ArrowLeft: 37,
|
||||
ArrowRight: 39,
|
||||
PageUp: 33,
|
||||
PageDown: 34,
|
||||
Home: 36,
|
||||
End: 35,
|
||||
Space: 32,
|
||||
}
|
||||
await key({
|
||||
key: action.key === "Space" ? " " : action.key,
|
||||
code: action.key,
|
||||
windowsVirtualKeyCode: codes[action.key],
|
||||
})
|
||||
}
|
||||
if (action.type === "scroll") {
|
||||
const bounds = view.getBounds()
|
||||
await send("Input.dispatchMouseEvent", {
|
||||
type: "mouseWheel",
|
||||
x: bounds.width / 2,
|
||||
y: bounds.height / 2,
|
||||
deltaX: action.direction === "left" ? -action.pixels : action.direction === "right" ? action.pixels : 0,
|
||||
deltaY: action.direction === "up" ? -action.pixels : action.direction === "down" ? action.pixels : 0,
|
||||
})
|
||||
}
|
||||
check()
|
||||
return { type: "state", state: state() }
|
||||
|
||||
function check() {
|
||||
if (closed) throw new Error("not_attached")
|
||||
if (signal.aborted) throw new Error("aborted")
|
||||
// Opening may create a new document before the command runs.
|
||||
if (action.type !== "open" && generation !== command.generation) throw new Error("stale_ref")
|
||||
}
|
||||
|
||||
function key(params: Record<string, unknown>) {
|
||||
return send("Input.dispatchKeyEvent", { type: "keyDown", ...params }).finally(() =>
|
||||
contents.debugger.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", ...params }),
|
||||
)
|
||||
}
|
||||
|
||||
async function send(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
check()
|
||||
if (!contents.debugger.isAttached()) contents.debugger.attach("1.3")
|
||||
const result: unknown = await contents.debugger.sendCommand(method, params)
|
||||
check()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
async function navigate(input: string, signal: AbortSignal) {
|
||||
const value = input.trim() || "about:blank"
|
||||
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
|
||||
const url =
|
||||
value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value)
|
||||
? value
|
||||
: `${local ? "http" : "https"}://${value}`
|
||||
if (url.length > 16_384 || (url !== "about:blank" && !destinationOrigin(url))) throw new Error("invalid_url")
|
||||
const cancel = () => {
|
||||
if (!closed) contents.stop()
|
||||
}
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
await contents.loadURL(url).finally(() => signal.removeEventListener("abort", cancel))
|
||||
if (signal.aborted) throw new Error("aborted")
|
||||
}
|
||||
}
|
||||
|
||||
function clean(value: unknown) {
|
||||
return typeof value === "string" ? value.replaceAll(/\s+/g, " ").trim().slice(0, 300) : ""
|
||||
}
|
||||
|
||||
export function destinationOrigin(input: string) {
|
||||
if (!URL.canParse(input)) return
|
||||
const url = new URL(input)
|
||||
return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url.origin : undefined
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { destinationOrigin } from "./browser-chromium"
|
||||
|
||||
test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => {
|
||||
expect(destinationOrigin("https://other.example/path")).toBe("https://other.example")
|
||||
expect(destinationOrigin("http://localhost:3000/")).toBe("http://localhost:3000")
|
||||
for (const url of [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,test",
|
||||
"https://user:pass@example.com",
|
||||
]) {
|
||||
expect(destinationOrigin(url)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { BrowserPaneCommand, BrowserPaneLayout, BrowserPaneTarget } from "@opencode-ai/app/desktop"
|
||||
import { NodeHttpClient } from "@effect/platform-node"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { Deferred, Effect, ManagedRuntime, Queue, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { BrowserPaneEvent } from "../shared/ipc-rpc/events"
|
||||
import { createBrowserPage, destinationOrigin, type BrowserPage } from "./browser-chromium"
|
||||
import { emitIpcEvent } from "./ipc-events"
|
||||
|
||||
type Entry = {
|
||||
bindingID: string
|
||||
win: BrowserWindow
|
||||
abort: AbortController
|
||||
registered: PromiseWithResolvers<void>
|
||||
requests: Map<string, AbortController>
|
||||
report?: (event: Extract<BrowserPaneEvent["event"], { type: "state" }>) => void
|
||||
cleanup?: () => void
|
||||
page?: BrowserPage
|
||||
}
|
||||
|
||||
export function createBrowserPane() {
|
||||
const entries = new Map<string, Entry>()
|
||||
// Keep long-lived RPC requests off Chromium's shared HTTP connection pool.
|
||||
const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp)
|
||||
let disposed = false
|
||||
return {
|
||||
async register(win: BrowserWindow, bindingID: string, target: BrowserPaneTarget) {
|
||||
if (disposed || !destinationOrigin(target.endpoint.url)) throw new Error("browser.pane.registration.invalid")
|
||||
if (target.endpoint.username && !target.endpoint.password) throw new Error("browser.pane.endpoint.invalid")
|
||||
if (entries.has(bindingID)) throw new Error("browser.pane.owner.invalid")
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) throw new Error("browser.pane.owner.unavailable")
|
||||
const sessionID = SessionID.make(target.sessionID)
|
||||
const entry: Entry = {
|
||||
bindingID,
|
||||
win,
|
||||
abort: new AbortController(),
|
||||
registered: Promise.withResolvers(),
|
||||
requests: new Map(),
|
||||
}
|
||||
const stop = () => close(entry)
|
||||
const navigate = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
|
||||
if (event.isMainFrame && !event.isSameDocument) stop()
|
||||
}
|
||||
win.webContents.once("destroyed", stop)
|
||||
win.webContents.on("did-start-navigation", navigate)
|
||||
entry.cleanup = () => {
|
||||
win.webContents.off("destroyed", stop)
|
||||
win.webContents.off("did-start-navigation", navigate)
|
||||
}
|
||||
entries.set(bindingID, entry)
|
||||
void runtime
|
||||
.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const client = yield* OpenCode.make({ baseUrl: target.endpoint.url }).pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
target.endpoint.password
|
||||
? HttpClient.mapRequest(
|
||||
http,
|
||||
HttpClientRequest.basicAuth(target.endpoint.username ?? "opencode", target.endpoint.password),
|
||||
)
|
||||
: http,
|
||||
),
|
||||
)
|
||||
const session = yield* client.session.get({ sessionID })
|
||||
const options = {
|
||||
location: { directory: session.location.directory, workspace: session.location.workspaceID },
|
||||
}
|
||||
const attachment = { sessionID, connectionID: crypto.randomUUID() }
|
||||
const rpc = client.rpc(Browser.Definition)
|
||||
const connected = yield* Deferred.make<void>()
|
||||
const outbound = yield* Queue.unbounded<Effect.Effect<void, unknown>>()
|
||||
// Report state before publishing it locally or completing a command.
|
||||
entry.report = (event) => {
|
||||
Queue.offerUnsafe(
|
||||
outbound,
|
||||
rpc
|
||||
.state({ ...attachment, state: event.state }, options)
|
||||
.pipe(Effect.tap(() => Effect.sync(() => publish(entry, event)))),
|
||||
)
|
||||
}
|
||||
const receive = client.event.subscribe().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type === "server.connected") {
|
||||
yield* Deferred.succeed(connected, undefined)
|
||||
return
|
||||
}
|
||||
if (event.type !== "rpc.experimental.browser.control") return
|
||||
const message = yield* Schema.decodeUnknownEffect(Browser.Control)(event.data)
|
||||
if (message.connectionID !== attachment.connectionID) return
|
||||
if (message.type === "attached") return entry.registered.resolve()
|
||||
if (message.type === "cancel") return entry.requests.get(message.requestID)?.abort()
|
||||
const abort = new AbortController()
|
||||
entry.requests.set(message.requestID, abort)
|
||||
yield* Effect.promise(async () => {
|
||||
const outcome: Browser.Outcome = await execute(entry, message.command, abort.signal).then(
|
||||
(result) => ({ type: "success" as const, result }),
|
||||
(error: unknown) => ({
|
||||
type: "failure" as const,
|
||||
message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024),
|
||||
}),
|
||||
)
|
||||
Queue.offerUnsafe(
|
||||
outbound,
|
||||
rpc.result(
|
||||
{
|
||||
...attachment,
|
||||
requestID: message.requestID,
|
||||
outcome: Schema.encodeSync(Browser.Outcome)(outcome),
|
||||
},
|
||||
options,
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
abort.abort()
|
||||
entry.requests.delete(message.requestID)
|
||||
}),
|
||||
),
|
||||
Effect.catchCause(() => Effect.sync(stop)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.raceAllFirst([
|
||||
receive,
|
||||
Stream.fromQueue(outbound).pipe(Stream.runForEach((send) => send)),
|
||||
Deferred.await(connected).pipe(Effect.andThen(rpc.attach(attachment, options))),
|
||||
])
|
||||
}).pipe(Effect.scoped, Effect.ensuring(Effect.sync(stop))),
|
||||
{ signal: entry.abort.signal },
|
||||
)
|
||||
.catch(stop)
|
||||
await entry.registered.promise
|
||||
if (entries.get(bindingID) !== entry) throw new Error("browser.pane.registration.closed")
|
||||
publishState(entry)
|
||||
},
|
||||
layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) {
|
||||
const entry = owned(win, bindingID)
|
||||
if (!value) return closePage(entry)
|
||||
const bounds = value.bounds
|
||||
if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
entry.page?.view.setVisible(false)
|
||||
return
|
||||
}
|
||||
const page = create(entry)
|
||||
page.view.setBounds(bounds)
|
||||
page.view.setVisible(true)
|
||||
},
|
||||
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
|
||||
const entry = owned(win, bindingID)
|
||||
await execute(
|
||||
entry,
|
||||
{ action: command, generation: entry.page?.state().generation ?? 0 },
|
||||
new AbortController().signal,
|
||||
)
|
||||
},
|
||||
async close(win: BrowserWindow, bindingID: string) {
|
||||
close(owned(win, bindingID))
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true
|
||||
entries.forEach(close)
|
||||
await runtime.dispose()
|
||||
},
|
||||
}
|
||||
|
||||
function owned(win: BrowserWindow, bindingID: string) {
|
||||
const entry = entries.get(bindingID)
|
||||
if (!entry || entry.win !== win) throw new Error("browser.pane.unavailable")
|
||||
return entry
|
||||
}
|
||||
|
||||
function publish(entry: Entry, event: BrowserPaneEvent["event"]) {
|
||||
if (!entries.has(entry.bindingID) || entry.win.isDestroyed() || entry.win.webContents.isDestroyed()) return
|
||||
emitIpcEvent(entry.win.webContents, new BrowserPaneEvent({ bindingID: entry.bindingID, event }))
|
||||
}
|
||||
|
||||
function close(entry: Entry) {
|
||||
if (entries.get(entry.bindingID) !== entry) return
|
||||
entry.report = undefined
|
||||
closePage(entry, "browser.pane.registration.closed")
|
||||
entries.delete(entry.bindingID)
|
||||
entry.registered.reject(new Error("browser.pane.registration.closed"))
|
||||
entry.cleanup?.()
|
||||
entry.abort.abort()
|
||||
}
|
||||
|
||||
function closePage(entry: Entry, error?: string) {
|
||||
entry.requests.forEach((request) => request.abort())
|
||||
entry.requests.clear()
|
||||
entry.page?.dispose()
|
||||
entry.page = undefined
|
||||
publishState(entry, error)
|
||||
}
|
||||
|
||||
function publishState(entry: Entry, error?: string) {
|
||||
const event = {
|
||||
type: "state" as const,
|
||||
state: entry.page?.state() ?? null,
|
||||
...(error === undefined ? {} : { error }),
|
||||
}
|
||||
if (entry.report) return entry.report(event)
|
||||
publish(entry, event)
|
||||
}
|
||||
|
||||
function create(entry: Entry) {
|
||||
if (entry.page) return entry.page
|
||||
const fail = () => {
|
||||
if (entry.page === page) closePage(entry, "page_crashed")
|
||||
}
|
||||
const page = createBrowserPage(
|
||||
entry.win,
|
||||
(error) => {
|
||||
if (entry.page === page) publishState(entry, error)
|
||||
},
|
||||
fail,
|
||||
)
|
||||
entry.page = page
|
||||
void page.ready
|
||||
.then(() => {
|
||||
if (entry.page === page) publishState(entry)
|
||||
})
|
||||
.catch(fail)
|
||||
return page
|
||||
}
|
||||
|
||||
async function execute(entry: Entry, command: Browser.Command, signal: AbortSignal) {
|
||||
if (command.action.type === "open") publish(entry, { type: "open" })
|
||||
const page = command.action.type === "open" ? create(entry) : entry.page
|
||||
if (!page) throw new Error("not_attached")
|
||||
await page.ready
|
||||
return page.execute(command, signal)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,35 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { createBrowserPane } from "../browser-pane"
|
||||
import { ipcEventStream } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
import { isRendererUrl } from "../windows/protocol"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const eventHandlers = EventRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const shutdown = yield* Shutdown.Service
|
||||
const browser = createBrowserPane()
|
||||
const stop = Effect.promise(() => browser.dispose())
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
return EventRpcs.of({
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
BrowserPane: ({ request }, context) =>
|
||||
Effect.tryPromise(async () => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
|
||||
throw new Error("browser.pane.owner.invalid")
|
||||
}
|
||||
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
|
||||
return browser.close(win, request.bindingID)
|
||||
}).pipe(Effect.orDie),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { BrowserPaneEvent } from "@opencode-ai/app/desktop"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
|
||||
import type {
|
||||
ClipboardImage,
|
||||
DirectoryPickerOptions,
|
||||
@@ -23,6 +25,11 @@ export type UpdaterAPI = {
|
||||
export type ElectronAPI = {
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
reconnectService(): Promise<ServerReadyData>
|
||||
browserPane: {
|
||||
request(request: BrowserPaneRequest): Promise<void>
|
||||
send(request: BrowserPaneRequest): void
|
||||
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
|
||||
}
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
|
||||
@@ -25,6 +25,11 @@ const updaterHandler = (state: UpdaterState) => {
|
||||
export const api: ElectronAPI = {
|
||||
awaitInitialization: () => invoke("AppAwaitInitialization"),
|
||||
reconnectService: () => invoke("AppReconnectService"),
|
||||
browserPane: {
|
||||
request: (request) => invoke("BrowserPane", { request }),
|
||||
send: (request) => send("BrowserPane", { request }),
|
||||
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(mutable(value))),
|
||||
},
|
||||
wslServers: {
|
||||
getState: () => invoke("WslGetState").then(mutable),
|
||||
subscribe: (cb) => {
|
||||
|
||||
@@ -30,6 +30,33 @@ export function createDesktopPlatform(
|
||||
windowID: windowState.id,
|
||||
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
|
||||
...createDesktopStorage(api),
|
||||
browserPane: {
|
||||
register(target, onEvent) {
|
||||
const bindingID = crypto.randomUUID()
|
||||
let closed = false
|
||||
const dispose = api.browserPane.onEvent((value) => {
|
||||
if (!closed && value.bindingID === bindingID) onEvent(value.event)
|
||||
})
|
||||
const ready = api.browserPane.request({ type: "register", bindingID, target })
|
||||
return {
|
||||
setLayout(layout) {
|
||||
if (!closed)
|
||||
void ready
|
||||
.then(() =>
|
||||
api.browserPane.send({ type: "layout", bindingID, ...(layout === undefined ? {} : { layout }) }),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
|
||||
close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
dispose()
|
||||
void ready.then(() => api.browserPane.request({ type: "close", bindingID })).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
updater,
|
||||
exportDebugLogs: () => api.exportDebugLogs(),
|
||||
setForceFocus: (enabled) => api.setForceFocus(enabled),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { Schema } from "effect"
|
||||
import { Rpc } from "effect/unstable/rpc"
|
||||
|
||||
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
|
||||
const bindingID = text(128)
|
||||
const endpoint = Schema.Struct({
|
||||
url: text(16_384),
|
||||
username: Schema.optionalKey(text(1_024)),
|
||||
password: Schema.optionalKey(text(4_096)),
|
||||
})
|
||||
const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint })
|
||||
const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite })
|
||||
const layout = Schema.Struct({ visible: Schema.Boolean, bounds: Schema.optionalKey(bounds) })
|
||||
export const BrowserPaneRequestSchema = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("register"), bindingID, target }),
|
||||
Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }),
|
||||
Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }),
|
||||
Schema.Struct({ type: Schema.Literal("close"), bindingID }),
|
||||
])
|
||||
export type BrowserPaneRequest = Schema.Schema.Type<typeof BrowserPaneRequestSchema>
|
||||
|
||||
export const BrowserPaneEventSchema = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("open") }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("state"),
|
||||
state: Schema.NullOr(Browser.State),
|
||||
error: Schema.optionalKey(Schema.String),
|
||||
}),
|
||||
])
|
||||
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
|
||||
import { UpdaterStateSchema } from "./updater"
|
||||
import { WslServersEventSchema } from "./wsl"
|
||||
|
||||
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
|
||||
bindingID: Schema.String,
|
||||
event: BrowserPaneEventSchema,
|
||||
}) {}
|
||||
|
||||
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
|
||||
urls: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
@@ -32,6 +38,7 @@ export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("
|
||||
}) {}
|
||||
|
||||
export const DesktopEvent = Schema.Union([
|
||||
BrowserPaneEvent,
|
||||
DeepLinksOpened,
|
||||
MenuCommandTriggered,
|
||||
UpdaterStateChanged,
|
||||
@@ -43,4 +50,4 @@ export const DesktopEvent = Schema.Union([
|
||||
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
|
||||
|
||||
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents)
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
|
||||
|
||||
Reference in New Issue
Block a user