diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 6bbc4510980..46c5cff3629 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -27,7 +27,7 @@ describe('restart-survival E2E routing', () => { // Every file below carries behavior the restart spec is the only test that exercises end to end. it.each([ 'src/main/runtime/orca-runtime.ts', - 'src/main/runtime/orca-runtime-browser.ts', + 'src/main/runtime/browser/browser-commands.ts', 'src/main/runtime/client-hosted-page-reconciliation-window.ts', 'src/main/runtime/runtime-browser-client-page-adoption.ts', 'src/main/runtime/runtime-browser-client-page-recovery.ts', @@ -44,6 +44,9 @@ describe('restart-survival E2E routing', () => { 'src/shared/browser-client-host-protocol.ts' ])('routes %s', (path) => { expect(restartSurvivalRoute.matches(path)).toBe(true) + if (path === 'src/main/runtime/browser/browser-commands.ts') { + expect(existsSync(join(projectDir, path))).toBe(true) + } }) // The pattern is deliberately not "anything under src": routing every PR at a two-Electron spec @@ -53,7 +56,12 @@ describe('restart-survival E2E routing', () => { 'src/renderer/src/components/tab-bar/BrowserTab.tsx', 'src/main/terminal/pty-manager.ts', // The status/types entries name whole files, not a suffix any longer name may end with. - 'src/shared/computer-use-runtime-types.ts' + 'src/shared/computer-use-runtime-types.ts', + 'src/main/runtime/browser/browser-commands.test.ts', + 'src/main/runtime/browser/browser-commands.test-fixture.ts', + 'src/main/runtime/browser/browser-commands.fixture.ts', + 'src/main/runtime/browser/browser-commands-extra.ts', + 'src/main/runtime/browser/browser-navigation.ts' ])('does not route %s', (path) => { expect(restartSurvivalRoute.matches(path)).toBe(false) }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 706aa041988..530abb1da06 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -393,9 +393,10 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts'], matches: (file) => isProductSource(file) && - /^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test( - file - ) + (file === 'src/main/runtime/browser/browser-commands.ts' || + /^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime\.ts|\/runtime-(?:status|types)\.ts)/.test( + file + )) } ] diff --git a/config/ts-nocheck-baseline.txt b/config/ts-nocheck-baseline.txt index f53d3a8afeb..ee15231ec33 100644 --- a/config/ts-nocheck-baseline.txt +++ b/config/ts-nocheck-baseline.txt @@ -135,20 +135,6 @@ src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts -src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts -src/main/runtime/runtime-browser-commands-browser-clear.ts -src/main/runtime/runtime-browser-commands-browser-click.ts -src/main/runtime/runtime-browser-commands-browser-command-target-params.ts -src/main/runtime/runtime-browser-commands-browser-network-log.ts -src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts -src/main/runtime/runtime-browser-commands-browser-screencast.ts -src/main/runtime/runtime-browser-commands-browser-set-headers.ts -src/main/runtime/runtime-browser-commands-browser-tab-close.ts -src/main/runtime/runtime-browser-commands-browser-tab-create.ts -src/main/runtime/runtime-browser-commands-browser-tab-list.ts -src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts -src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts -src/main/runtime/runtime-browser-commands-state.ts src/main/runtime/runtime-file-command-host.ts src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts diff --git a/mobile/src/transport/mobile-relay-browser-cancel-budget.test.ts b/mobile/src/transport/mobile-relay-browser-cancel-budget.test.ts index 59426fc42da..cc2541278ae 100644 --- a/mobile/src/transport/mobile-relay-browser-cancel-budget.test.ts +++ b/mobile/src/transport/mobile-relay-browser-cancel-budget.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { RuntimeBrowserScreencastController } from '../../../src/main/runtime/runtime-browser-screencast-controller' -import type { RuntimeBrowserCommands } from '../../../src/main/runtime/orca-runtime-browser' import type { BrowserScreencastResult } from '../../../src/shared/runtime-types' +import type { RpcBinarySender } from '../../../src/main/runtime/rpc/rpc-binary-sender' import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' import type { RpcResponse } from './types' @@ -14,7 +14,7 @@ describe('relay browser cancellation resource budget', () => { let stopped = false let frameSends = 0 let frameBytes = 0 - let sendBinary: (bytes: Uint8Array) => boolean | void = () => false + let sendBinary: RpcBinarySender = () => false let hostRun: Promise | undefined const methods: string[] = [] const cleanup = (id: string): void => { @@ -23,24 +23,36 @@ describe('relay browser cancellation resource budget', () => { void release?.() } const host = new RuntimeBrowserScreencastController({ - getCommands: () => - ({ - browserScreencast: async (_params, stream) => { - sendBinary = stream.sendBinary - return { + getCommands: () => ({ + browserScreencast: async (_params, stream) => { + sendBinary = stream.sendBinary + return { + subscriptionId: 'server-stream', + ready: { + type: 'ready', subscriptionId: 'server-stream', - ready: { type: 'ready', subscriptionId: 'server-stream', browserPageId: 'page' }, - session: { - done: done.promise, - stop: () => { - stopped = true - done.resolve() - } + browserPageId: 'page', + format: 'jpeg', + tab: { + browserPageId: 'page', + index: 0, + url: 'about:blank', + title: 'Browser', + active: true + } + }, + session: { + done: done.promise, + stop: () => { + stopped = true + done.resolve() }, - flushPendingFrame: () => {} - } + updateViewport: async () => {} + }, + flushPendingFrame: () => {} } - }) as RuntimeBrowserCommands, + } + }), registerSubscriptionCleanup: (id, release) => subscriptions.set(id, release), cleanupSubscription: cleanup, getDriver: () => ({ kind: 'idle' }), diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index f8514cf027b..06455cdd3d0 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync } from 'node:fs' -import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' +import { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/host/electron-browser-commands.ts b/src/main/host/electron-browser-commands.ts index 2035547ebd1..27ba3ae0c8a 100644 --- a/src/main/host/electron-browser-commands.ts +++ b/src/main/host/electron-browser-commands.ts @@ -1,4 +1,4 @@ -import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' +import { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' /** The desktop factory. Importing this file is what pulls in the Chromium browser cluster. */ diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 10f0f413236..fe727755373 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RunProcessModule from '@orca/process-host' -import type { RuntimeBrowserCommandHost } from '../runtime/orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' const { spawnProcessMock } = vi.hoisted(() => ({ spawnProcessMock: vi.fn<(spec: ProcessSpec) => ReturnType>() diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index 5b9196439d4..4200b7224ad 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -5,10 +5,8 @@ import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { z } from 'zod' import { BrowserError } from '../browser/browser-error' -import type { - RuntimeBrowserCommandHost, - RuntimeBrowserCommands -} from '../runtime/orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' import { readRuntimeMetadata } from '../runtime/runtime-metadata' diff --git a/src/main/orcad/external-chromium-browser-process.ts b/src/main/orcad/external-chromium-browser-process.ts index 112278547cd..0a0c47de74f 100644 --- a/src/main/orcad/external-chromium-browser-process.ts +++ b/src/main/orcad/external-chromium-browser-process.ts @@ -1,8 +1,6 @@ import { z } from 'zod' -import type { - RuntimeBrowserCommandHost, - RuntimeBrowserCommands -} from '../runtime/orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' import { BrowserError } from '../browser/browser-error' import { ExternalChromiumBrowserSession, diff --git a/src/main/orcad/external-chromium-tab-registry.ts b/src/main/orcad/external-chromium-tab-registry.ts index 12c49a400cd..4a0765f140f 100644 --- a/src/main/orcad/external-chromium-tab-registry.ts +++ b/src/main/orcad/external-chromium-tab-registry.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { z } from 'zod' -import type { RuntimeBrowserCommandHost } from '../runtime/orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' import { BrowserError } from '../browser/browser-error' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import type { ExternalChromiumBrowserSession } from './external-chromium-browser-session' diff --git a/src/main/orcad/orcad-browser-startup.test.ts b/src/main/orcad/orcad-browser-startup.test.ts index 9f2f5812ac7..bbf0d2668c7 100644 --- a/src/main/orcad/orcad-browser-startup.test.ts +++ b/src/main/orcad/orcad-browser-startup.test.ts @@ -8,10 +8,8 @@ import { setRuntimeBrowserCommandsFactory, setRuntimeBrowserUnavailableCause } from '../runtime/runtime-browser-commands-factory' -import type { - RuntimeBrowserCommandHost, - RuntimeBrowserCommands -} from '../runtime/orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() })) afterEach(() => { diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts index fb7dc03a0ab..0e2797b9477 100644 --- a/src/main/orcad/orcad-browser-startup.ts +++ b/src/main/orcad/orcad-browser-startup.ts @@ -1,6 +1,6 @@ import { BrowserError } from '../browser/browser-error' import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' -import type { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' +import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { resolveOrcadBrowserProvider, diff --git a/src/main/runtime/browser-dialog-settlement.test.ts b/src/main/runtime/browser-dialog-settlement.test.ts index 7da2022a3e2..be79af3aa16 100644 --- a/src/main/runtime/browser-dialog-settlement.test.ts +++ b/src/main/runtime/browser-dialog-settlement.test.ts @@ -12,18 +12,13 @@ vi.mock('electron', () => ({ webContents: { fromId: () => ({ isDestroyed: () => false }) } })) -import { RuntimeBrowserCommandsWithBrowserSetHeaders } from './runtime-browser-commands-browser-set-headers' -import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params' +import { RuntimeBrowserDialogs } from './browser/browser-dialogs' +import { settleBrowserDialogOnLiveScreencast } from './browser-dialog-settlement' +import type { ActiveBrowserScreencastPage } from './browser/browser-screencast-contracts' import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' const PAGE_ID = 'page-1' -class DialogCommandsUnderTest extends RuntimeBrowserCommandsWithBrowserSetHeaders { - registerLiveScreencast(browserPageId: string, record: ActiveBrowserScreencastPage): void { - this.activeScreencastsByPageId.set(browserPageId, record) - } -} - function streamingPage(session: BrowserScreencastSession): ActiveBrowserScreencastPage { return { format: 'jpeg', @@ -48,11 +43,19 @@ function createCommands(settleDialog: (accept: boolean, promptText?: string) => dialogAccept: vi.fn(async () => ({ via: 'bridge' })), dialogDismiss: vi.fn(async () => ({ via: 'bridge' })) } - const host = { getAgentBrowserBridge: () => bridge } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dialog path reads only getAgentBrowserBridge off the host. - const commands = new DialogCommandsUnderTest(host as never) + const activeScreencastsByPageId = new Map() + const commands = new RuntimeBrowserDialogs( + { + resolveBrowserCommandTarget: async (params) => ({ browserPageId: params.page }), + requireAgentBrowserBridge: () => bridge + }, + { + settleDialog: (pageId, accept, promptText) => + settleBrowserDialogOnLiveScreencast(activeScreencastsByPageId, pageId, accept, promptText) + } + ) const registerStream = (): void => { - commands.registerLiveScreencast( + activeScreencastsByPageId.set( PAGE_ID, streamingPage({ stop: () => {}, diff --git a/src/main/runtime/browser-dialog-settlement.ts b/src/main/runtime/browser-dialog-settlement.ts index fc642cd5ce3..7162b58f53f 100644 --- a/src/main/runtime/browser-dialog-settlement.ts +++ b/src/main/runtime/browser-dialog-settlement.ts @@ -1,4 +1,4 @@ -import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params' +import type { ActiveBrowserScreencastPage } from './browser/browser-screencast-contracts' /** * What `browser.dialogAccept` and `browser.dialogDismiss` answer, on either path. diff --git a/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts b/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts index 4755268263a..59b103542ad 100644 --- a/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts +++ b/src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserScreencastOptions } from '../browser/browser-screencast-stream-types' import { createScreencastHarness } from './browser-screencast-subscriber-test-harness' -import { RuntimeBrowserCommands } from './orca-runtime-browser' +import { RuntimeBrowserCommands } from './browser/browser-commands' import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double' const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({ diff --git a/src/main/runtime/browser-screencast-subscriber-frame-delivery.ts b/src/main/runtime/browser-screencast-subscriber-frame-delivery.ts index 1e6621acc20..02f8bbae4ad 100644 --- a/src/main/runtime/browser-screencast-subscriber-frame-delivery.ts +++ b/src/main/runtime/browser-screencast-subscriber-frame-delivery.ts @@ -1,4 +1,4 @@ -import type { ActiveBrowserScreencastSubscriber } from './runtime-browser-commands-browser-command-target-params' +import type { ActiveBrowserScreencastSubscriber } from './browser/browser-screencast-contracts' import { recordScreencastSubscriberSend } from './browser-screencast-ghost-subscriber-eviction' import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' diff --git a/src/main/runtime/browser-tab-create-caller-navigation.test.ts b/src/main/runtime/browser-tab-create-caller-navigation.test.ts index 73df9ee4f07..3593ab6f85a 100644 --- a/src/main/runtime/browser-tab-create-caller-navigation.test.ts +++ b/src/main/runtime/browser-tab-create-caller-navigation.test.ts @@ -194,7 +194,7 @@ describe('browser.tabCreate caller navigation', () => { beforeAll(async () => { // Why: constructing the browser commands is what pulls the Chromium cluster in, so production // installs this at the Electron entry and a Node host leaves the RPCs rejecting. - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) return () => setRuntimeBrowserCommandsFactory(null) }) diff --git a/src/main/runtime/browser-tab-create-folder-workspace.test.ts b/src/main/runtime/browser-tab-create-folder-workspace.test.ts index 23166d34976..c1ec19301c4 100644 --- a/src/main/runtime/browser-tab-create-folder-workspace.test.ts +++ b/src/main/runtime/browser-tab-create-folder-workspace.test.ts @@ -3,7 +3,7 @@ * the folder-aware resolver, so `id:folder:…` failed with selector_not_found on the server path. */ import { describe, expect, it, vi } from 'vitest' -import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() }, @@ -40,7 +40,7 @@ function createHost(overrides: Record): RuntimeBrowserCommandHo describe('browser.tabCreate on a folder workspace', () => { it('creates a server-hosted page for the folder workspace', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const createTab = vi.fn(async () => ({ browserPageId: 'page-folder' })) const host = createHost({ getOffscreenBrowserBackend: vi.fn(() => ({ createTab })) diff --git a/src/main/runtime/browser-tab-create-publication.test.ts b/src/main/runtime/browser-tab-create-publication.test.ts index ffaeab41d55..eb1da5b4f56 100644 --- a/src/main/runtime/browser-tab-create-publication.test.ts +++ b/src/main/runtime/browser-tab-create-publication.test.ts @@ -2,7 +2,7 @@ // hand-rolls its own bookkeeping is how a client-placed page once lost its targetGroupId. import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { BROWSER_TAB_CREATE_PLACEMENT_KINDS, @@ -350,7 +350,7 @@ describe('browser tab-create placement census', () => { }) it('moves a user-created offscreen tab into the clicked split group', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const markHeadlessBrowserSessionTabActive = vi.fn() const commands = new RuntimeBrowserCommands( createCommandHost({ @@ -387,7 +387,7 @@ describe('browser tab-create placement census', () => { }) it('leaves renderer tab focus to the create IPC instead of the session-tab marker', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const webContents = { send: vi.fn() } webContents.send = vi.fn((_channel: string, data: { requestId: string }) => { const handler = ipcMainOnMock.mock.calls.find( @@ -424,7 +424,7 @@ describe('browser tab-create placement census', () => { }) it('does not focus the host renderer for a create addressed to the caller', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const webContents = { send: vi.fn() } webContents.send = vi.fn((_channel: string, data: { requestId: string }) => { const handler = ipcMainOnMock.mock.calls.find( @@ -457,7 +457,7 @@ describe('browser tab-create placement census', () => { }) it('still places a caller-addressed client page in the clicked split group', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const markHeadlessBrowserSessionTabActive = vi.fn() const commands = new RuntimeBrowserCommands( createCommandHost({ @@ -623,7 +623,7 @@ describe('browser tab-switch placement census', () => { } it('marks a focused client switch active and leaves an unfocused one alone', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const markHeadlessBrowserSessionTabActive = vi.fn() const notifyHeadlessBrowserSessionTabsChanged = vi.fn() const { host, registry } = createClientSwitchHost({ @@ -651,7 +651,7 @@ describe('browser tab-switch placement census', () => { }) it('marks a focused bridge switch active and leaves an unfocused one alone', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const markHeadlessBrowserSessionTabActive = vi.fn() const bridge = { getRegisteredTabs: vi.fn(() => new Map([['page-server', 100]])), diff --git a/src/main/runtime/browser/browser-capture.ts b/src/main/runtime/browser/browser-capture.ts new file mode 100644 index 00000000000..6b93a994d1b --- /dev/null +++ b/src/main/runtime/browser/browser-capture.ts @@ -0,0 +1,80 @@ +import type { + BrowserScreenshotResult, + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserConsoleResult, + BrowserPdfResult, + BrowserEvalResult +} from '../../../shared/runtime-types' +import type { BrowserCommandTargetParams } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' + +export class RuntimeBrowserCapture { + constructor( + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget' + > + ) {} + + async browserScreenshot( + params: { + format?: 'png' | 'jpeg' + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .screenshot(params.format, target.worktreeId, target.browserPageId) + } + + async browserPdf(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) + } + + async browserFullScreenshot( + params: { + format?: 'png' | 'jpeg' + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .fullPageScreenshot(params.format, target.worktreeId, target.browserPageId) + } + + async browserCaptureStart( + params: BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .captureStart(target.worktreeId, target.browserPageId) + } + + async browserCaptureStop(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .captureStop(target.worktreeId, target.browserPageId) + } + + async browserConsoleLog( + params: { limit?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .consoleLog(params.limit, target.worktreeId, target.browserPageId) + } + + async browserEval( + params: { expression: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .evaluate(params.expression, target.worktreeId, target.browserPageId) + } +} diff --git a/src/main/runtime/browser/browser-command-contracts.ts b/src/main/runtime/browser/browser-command-contracts.ts new file mode 100644 index 00000000000..a958b9d39fd --- /dev/null +++ b/src/main/runtime/browser/browser-command-contracts.ts @@ -0,0 +1,57 @@ +import type { AgentBrowserBridge } from '../../browser/agent-browser-bridge' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { BrowserNetworkExecutionHost } from '../../../shared/browser-client-host-protocol' +import type { BrowserHostLeaseRegistry } from '../browser-host-lease-registry' +import type { RuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' +import type { BrowserWindow } from 'electron' +import type { BrowserBackend } from '../../browser/browser-backend' +import type { BrowserSessionTabSelectionOptions } from '../browser-tab-create-publication' + +export type BrowserCommandTargetParams = { + worktree?: string + page?: string +} + +export type ResolvedBrowserCommandTarget = { + worktreeId?: string + browserPageId?: string +} + +export type ResolvedBrowserPageWebContents = { + browserPageId: string + webContents: Electron.WebContents +} + +export type RuntimeBrowserCommandHost = { + getAgentBrowserBridge(): AgentBrowserBridge | null + resolveWorktreeSelector(selector: string): Promise<{ + id: string + repoId?: string + hostId?: ExecutionHostId + }> + resolveBrowserWorkspace(selector: string): Promise<{ + id: string + repoId?: string + hostId?: ExecutionHostId + }> + resolveBrowserNetworkExecutionHost(worktree?: { + id: string + repoId?: string + hostId?: ExecutionHostId + }): BrowserNetworkExecutionHost | Promise + getBrowserHostLeaseRegistry(): BrowserHostLeaseRegistry + getRuntimeBrowserPageRegistry(): RuntimeBrowserPageRegistry + getAuthoritativeWindow(): BrowserWindow + getAvailableAuthoritativeWindow(): BrowserWindow | null + // Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing. + getOffscreenBrowserBackend(): BrowserBackend | null + // Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal. + markHeadlessBrowserSessionTabActive?( + worktreeId: string | undefined, + browserPageId: string, + options?: BrowserSessionTabSelectionOptions + ): void + notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void + /** True when a runtime-owned session row for that page existed and was retired. */ + retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void +} diff --git a/src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts b/src/main/runtime/browser/browser-command-targets.ts similarity index 75% rename from src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts rename to src/main/runtime/browser/browser-command-targets.ts index 11264ea3035..f5e700ac343 100644 --- a/src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts +++ b/src/main/runtime/browser/browser-command-targets.ts @@ -1,26 +1,30 @@ -// @ts-nocheck -- mechanically split class members. -import type { - BrowserCommandTargetParams, - ResolvedBrowserCommandTarget, - ResolvedBrowserPageWebContents, - RuntimeBrowserCommandHost -} from './runtime-browser-commands-browser-command-target-params' -import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import { BrowserError } from '../browser/browser-error' +import type { AgentBrowserBridge } from '../../browser/agent-browser-bridge' +import { BrowserError } from '../../browser/browser-error' import { webContents } from 'electron' import { waitForTabRegistration, waitForWorktreeTabRegistration -} from '../ipc/browser-tab-registration-wait' -import type { BrowserSnapshotResult } from '../../shared/runtime-types' -import { RuntimeBrowserCommandsState } from './runtime-browser-commands-state' +} from '../../ipc/browser-tab-registration-wait' +import type { BrowserSnapshotResult } from '../../../shared/runtime-types' +import type { + BrowserCommandTargetParams, + RuntimeBrowserCommandHost, + ResolvedBrowserCommandTarget, + ResolvedBrowserPageWebContents +} from './browser-command-contracts' -export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends RuntimeBrowserCommandsState { - constructor(private readonly host: RuntimeBrowserCommandHost) { - super() - } +export class RuntimeBrowserCommandTargets { + constructor( + private readonly host: Pick< + RuntimeBrowserCommandHost, + | 'getAgentBrowserBridge' + | 'resolveWorktreeSelector' + | 'getAuthoritativeWindow' + | 'retireRuntimeOwnedBrowserSessionTab' + > + ) {} - protected requireAgentBrowserBridge(): AgentBrowserBridge { + requireAgentBrowserBridge(): AgentBrowserBridge { const bridge = this.host.getAgentBrowserBridge() if (!bridge) { throw new BrowserError('browser_no_tab', 'No browser session is active') @@ -28,25 +32,15 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime return bridge } - /** - * Retires a session row for a page nothing here can close any more. - * - * A client-hosted page whose runtime record is gone -- released as unrecoverable, or created by a - * build that kept its records in memory only -- still leaves a row every paired device can see, - * and every close path below is keyed on a live guest this runtime does not have. Without this - * the row's X fails closed and the ghost outlives the browser it named. - */ - private retireGhostBrowserSessionRow( - worktreeId: string | undefined, - browserPageId: string - ): boolean { + // A retained session row can outlive the page its host was able to close. + retireGhostBrowserSessionRow(worktreeId: string | undefined, browserPageId: string): boolean { return ( worktreeId !== undefined && this.host.retireRuntimeOwnedBrowserSessionTab?.(worktreeId, browserPageId) === true ) } - protected hasLiveRegisteredBrowserTab( + private hasLiveRegisteredBrowserTab( bridge: AgentBrowserBridge, worktreeId: string | undefined ): boolean { @@ -59,7 +53,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime return false } - protected hasLiveRegisteredBrowserPage( + private hasLiveRegisteredBrowserPage( bridge: AgentBrowserBridge, worktreeId: string | undefined, browserPageId: string @@ -73,7 +67,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime } // Why: the CLI sends selectors (e.g. "path:/...") but the bridge keys tabs by "repoId::path"; resolve to that store-compatible id. - private async resolveBrowserWorktreeId(selector?: string): Promise { + async resolveBrowserWorktreeId(selector?: string): Promise { if (!selector) { // Why: after restart, webviews mount only when the pane is visible; activate the view so persisted tabs become operable via registerGuest. const bridge = this.host.getAgentBrowserBridge() @@ -100,7 +94,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime return worktreeId } - protected async resolveBrowserCommandTarget( + async resolveBrowserCommandTarget( params: BrowserCommandTargetParams ): Promise { const browserPageId = @@ -129,7 +123,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime } } - protected resolveBrowserPageWebContents( + resolveBrowserPageWebContents( worktreeId: string | undefined, browserPageId: string | undefined ): ResolvedBrowserPageWebContents { @@ -164,7 +158,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime await waitForWorktreeTabRegistration(worktreeId) } - protected async ensureBrowserPageActive( + async ensureBrowserPageActive( worktreeId: string | undefined, browserPageId: string ): Promise { @@ -176,8 +170,8 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime await waitForTabRegistration(browserPageId) } - // Why: helper-driven clicks can bypass Electron navigation events; push authoritative URL/title updates after automation. - private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { + // Why: automation clicks can bypass Electron navigation events; push authoritative URL/title updates after automation. + notifyRendererNavigation(browserPageId: string, url: string, title: string): void { try { const win = this.host.getAuthoritativeWindow() win.webContents.send('browser:navigation-update', { browserPageId, url, title }) @@ -187,10 +181,7 @@ export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends Runtime } // Why: carry worktreeId (not a global setActiveWorktree) so one agent's --focus can't steal the screen from another agent's parallel worktree. - private notifyRendererBrowserPaneFocus( - worktreeId: string | undefined, - browserPageId: string - ): void { + notifyRendererBrowserPaneFocus(worktreeId: string | undefined, browserPageId: string): void { try { const win = this.host.getAuthoritativeWindow() win.webContents.send('browser:pane-focus', { diff --git a/src/main/runtime/browser/browser-commands.ts b/src/main/runtime/browser/browser-commands.ts new file mode 100644 index 00000000000..5fbcd73f5f0 --- /dev/null +++ b/src/main/runtime/browser/browser-commands.ts @@ -0,0 +1,202 @@ +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' +import { RuntimeBrowserCommandTargets } from './browser-command-targets' +import { RuntimeBrowserTabCatalog } from './browser-tab-catalog' +import { RuntimeBrowserNavigation } from './browser-navigation' +import { RuntimeBrowserElementActions } from './browser-element-actions' +import { RuntimeBrowserPointerInput } from './browser-pointer-input' +import { RuntimeBrowserCapture } from './browser-capture' +import { RuntimeBrowserNetwork } from './browser-network' +import { RuntimeBrowserStorage } from './browser-storage' +import { RuntimeBrowserTabQueries } from './browser-tab-queries' +import { RuntimeBrowserTabCreation } from './browser-tab-creation' +import { RuntimeBrowserTabClose } from './browser-tab-close' +import { RuntimeBrowserProfiles } from './browser-profiles' +import { RuntimeBrowserScreencasts } from './browser-screencasts' +import { RuntimeBrowserDialogs } from './browser-dialogs' + +export class RuntimeBrowserCommands { + readonly browserSnapshot: RuntimeBrowserCommandTargets['browserSnapshot'] + readonly browserClick: RuntimeBrowserNavigation['browserClick'] + readonly browserGoto: RuntimeBrowserNavigation['browserGoto'] + readonly browserBack: RuntimeBrowserNavigation['browserBack'] + readonly browserReload: RuntimeBrowserNavigation['browserReload'] + readonly browserFill: RuntimeBrowserElementActions['browserFill'] + readonly browserType: RuntimeBrowserElementActions['browserType'] + readonly browserSelect: RuntimeBrowserElementActions['browserSelect'] + readonly browserScroll: RuntimeBrowserElementActions['browserScroll'] + readonly browserHover: RuntimeBrowserElementActions['browserHover'] + readonly browserDrag: RuntimeBrowserElementActions['browserDrag'] + readonly browserUpload: RuntimeBrowserElementActions['browserUpload'] + readonly browserWait: RuntimeBrowserElementActions['browserWait'] + readonly browserCheck: RuntimeBrowserElementActions['browserCheck'] + readonly browserFocus: RuntimeBrowserElementActions['browserFocus'] + readonly browserClear: RuntimeBrowserElementActions['browserClear'] + readonly browserSelectAll: RuntimeBrowserElementActions['browserSelectAll'] + readonly browserKeypress: RuntimeBrowserElementActions['browserKeypress'] + readonly browserDblclick: RuntimeBrowserElementActions['browserDblclick'] + readonly browserScrollIntoView: RuntimeBrowserElementActions['browserScrollIntoView'] + readonly browserGet: RuntimeBrowserElementActions['browserGet'] + readonly browserIs: RuntimeBrowserElementActions['browserIs'] + readonly browserFind: RuntimeBrowserElementActions['browserFind'] + readonly browserHighlight: RuntimeBrowserElementActions['browserHighlight'] + readonly browserExec: RuntimeBrowserElementActions['browserExec'] + readonly browserKeyboardInsertText: RuntimeBrowserPointerInput['browserKeyboardInsertText'] + readonly browserMouseMove: RuntimeBrowserPointerInput['browserMouseMove'] + readonly browserMouseDown: RuntimeBrowserPointerInput['browserMouseDown'] + readonly browserMouseClick: RuntimeBrowserPointerInput['browserMouseClick'] + readonly browserMouseUp: RuntimeBrowserPointerInput['browserMouseUp'] + readonly browserMouseWheel: RuntimeBrowserPointerInput['browserMouseWheel'] + readonly browserScreenshot: RuntimeBrowserCapture['browserScreenshot'] + readonly browserPdf: RuntimeBrowserCapture['browserPdf'] + readonly browserFullScreenshot: RuntimeBrowserCapture['browserFullScreenshot'] + readonly browserCaptureStart: RuntimeBrowserCapture['browserCaptureStart'] + readonly browserCaptureStop: RuntimeBrowserCapture['browserCaptureStop'] + readonly browserConsoleLog: RuntimeBrowserCapture['browserConsoleLog'] + readonly browserEval: RuntimeBrowserCapture['browserEval'] + readonly browserNetworkLog: RuntimeBrowserNetwork['browserNetworkLog'] + readonly browserForward: RuntimeBrowserNavigation['browserForward'] + readonly browserCookieGet: RuntimeBrowserNetwork['browserCookieGet'] + readonly browserCookieSet: RuntimeBrowserNetwork['browserCookieSet'] + readonly browserCookieDelete: RuntimeBrowserNetwork['browserCookieDelete'] + readonly browserSetViewport: RuntimeBrowserNetwork['browserSetViewport'] + readonly browserSetGeolocation: RuntimeBrowserNetwork['browserSetGeolocation'] + readonly browserInterceptEnable: RuntimeBrowserNetwork['browserInterceptEnable'] + readonly browserInterceptDisable: RuntimeBrowserNetwork['browserInterceptDisable'] + readonly browserInterceptList: RuntimeBrowserNetwork['browserInterceptList'] + readonly browserSetDevice: RuntimeBrowserNetwork['browserSetDevice'] + readonly browserSetOffline: RuntimeBrowserNetwork['browserSetOffline'] + readonly browserSetHeaders: RuntimeBrowserNetwork['browserSetHeaders'] + readonly browserSetCredentials: RuntimeBrowserNetwork['browserSetCredentials'] + readonly browserSetMedia: RuntimeBrowserNetwork['browserSetMedia'] + readonly browserDownload: RuntimeBrowserNetwork['browserDownload'] + readonly browserClipboardRead: RuntimeBrowserStorage['browserClipboardRead'] + readonly browserClipboardWrite: RuntimeBrowserStorage['browserClipboardWrite'] + readonly browserStorageLocalGet: RuntimeBrowserStorage['browserStorageLocalGet'] + readonly browserStorageLocalSet: RuntimeBrowserStorage['browserStorageLocalSet'] + readonly browserStorageLocalClear: RuntimeBrowserStorage['browserStorageLocalClear'] + readonly browserStorageSessionGet: RuntimeBrowserStorage['browserStorageSessionGet'] + readonly browserStorageSessionSet: RuntimeBrowserStorage['browserStorageSessionSet'] + readonly browserStorageSessionClear: RuntimeBrowserStorage['browserStorageSessionClear'] + readonly browserTabList: RuntimeBrowserTabQueries['browserTabList'] + readonly browserProceedCertificate: RuntimeBrowserTabQueries['browserProceedCertificate'] + readonly browserTabShow: RuntimeBrowserTabQueries['browserTabShow'] + readonly browserTabCurrent: RuntimeBrowserTabQueries['browserTabCurrent'] + readonly browserTabSwitch: RuntimeBrowserTabQueries['browserTabSwitch'] + readonly browserTabCreate: RuntimeBrowserTabCreation['browserTabCreate'] + readonly browserOpenUrlOnClient: RuntimeBrowserTabCreation['browserOpenUrlOnClient'] + readonly browserTabClose: RuntimeBrowserTabClose['browserTabClose'] + readonly browserTabSetProfile: RuntimeBrowserProfiles['browserTabSetProfile'] + readonly browserTabProfileShow: RuntimeBrowserProfiles['browserTabProfileShow'] + readonly browserTabProfileClone: RuntimeBrowserProfiles['browserTabProfileClone'] + readonly browserProfileList: RuntimeBrowserProfiles['browserProfileList'] + readonly browserProfileCreate: RuntimeBrowserProfiles['browserProfileCreate'] + readonly browserProfileDelete: RuntimeBrowserProfiles['browserProfileDelete'] + readonly browserScreencast: RuntimeBrowserScreencasts['browserScreencast'] + readonly browserDialogAccept: RuntimeBrowserDialogs['browserDialogAccept'] + readonly browserDialogDismiss: RuntimeBrowserDialogs['browserDialogDismiss'] + + readonly browserProfileDetectBrowsers: RuntimeBrowserProfiles['browserProfileDetectBrowsers'] + readonly browserProfileImportFromBrowser: RuntimeBrowserProfiles['browserProfileImportFromBrowser'] + readonly browserProfileClearDefaultCookies: RuntimeBrowserProfiles['browserProfileClearDefaultCookies'] + + constructor(host: RuntimeBrowserCommandHost) { + const targets = new RuntimeBrowserCommandTargets(host) + const catalog = new RuntimeBrowserTabCatalog(host, targets) + const navigation = new RuntimeBrowserNavigation(host, targets) + const elements = new RuntimeBrowserElementActions(targets) + const pointer = new RuntimeBrowserPointerInput(targets) + const capture = new RuntimeBrowserCapture(targets) + const network = new RuntimeBrowserNetwork(targets) + const storage = new RuntimeBrowserStorage(targets) + const queries = new RuntimeBrowserTabQueries(host, targets, catalog) + const creation = new RuntimeBrowserTabCreation(host, targets) + const close = new RuntimeBrowserTabClose(queries, host, targets, catalog) + const profiles = new RuntimeBrowserProfiles(host, targets, catalog) + const screencasts = new RuntimeBrowserScreencasts(host, targets, catalog) + const dialogs = new RuntimeBrowserDialogs(targets, screencasts) + this.browserProfileDetectBrowsers = profiles.browserProfileDetectBrowsers.bind(profiles) + this.browserProfileImportFromBrowser = profiles.browserProfileImportFromBrowser.bind(profiles) + this.browserProfileClearDefaultCookies = + profiles.browserProfileClearDefaultCookies.bind(profiles) + + this.browserSnapshot = targets.browserSnapshot.bind(targets) + this.browserClick = navigation.browserClick.bind(navigation) + this.browserGoto = navigation.browserGoto.bind(navigation) + this.browserBack = navigation.browserBack.bind(navigation) + this.browserReload = navigation.browserReload.bind(navigation) + this.browserFill = elements.browserFill.bind(elements) + this.browserType = elements.browserType.bind(elements) + this.browserSelect = elements.browserSelect.bind(elements) + this.browserScroll = elements.browserScroll.bind(elements) + this.browserHover = elements.browserHover.bind(elements) + this.browserDrag = elements.browserDrag.bind(elements) + this.browserUpload = elements.browserUpload.bind(elements) + this.browserWait = elements.browserWait.bind(elements) + this.browserCheck = elements.browserCheck.bind(elements) + this.browserFocus = elements.browserFocus.bind(elements) + this.browserClear = elements.browserClear.bind(elements) + this.browserSelectAll = elements.browserSelectAll.bind(elements) + this.browserKeypress = elements.browserKeypress.bind(elements) + this.browserDblclick = elements.browserDblclick.bind(elements) + this.browserScrollIntoView = elements.browserScrollIntoView.bind(elements) + this.browserGet = elements.browserGet.bind(elements) + this.browserIs = elements.browserIs.bind(elements) + this.browserFind = elements.browserFind.bind(elements) + this.browserHighlight = elements.browserHighlight.bind(elements) + this.browserExec = elements.browserExec.bind(elements) + this.browserKeyboardInsertText = pointer.browserKeyboardInsertText.bind(pointer) + this.browserMouseMove = pointer.browserMouseMove.bind(pointer) + this.browserMouseDown = pointer.browserMouseDown.bind(pointer) + this.browserMouseClick = pointer.browserMouseClick.bind(pointer) + this.browserMouseUp = pointer.browserMouseUp.bind(pointer) + this.browserMouseWheel = pointer.browserMouseWheel.bind(pointer) + this.browserScreenshot = capture.browserScreenshot.bind(capture) + this.browserPdf = capture.browserPdf.bind(capture) + this.browserFullScreenshot = capture.browserFullScreenshot.bind(capture) + this.browserCaptureStart = capture.browserCaptureStart.bind(capture) + this.browserCaptureStop = capture.browserCaptureStop.bind(capture) + this.browserConsoleLog = capture.browserConsoleLog.bind(capture) + this.browserEval = capture.browserEval.bind(capture) + this.browserNetworkLog = network.browserNetworkLog.bind(network) + this.browserForward = navigation.browserForward.bind(navigation) + this.browserCookieGet = network.browserCookieGet.bind(network) + this.browserCookieSet = network.browserCookieSet.bind(network) + this.browserCookieDelete = network.browserCookieDelete.bind(network) + this.browserSetViewport = network.browserSetViewport.bind(network) + this.browserSetGeolocation = network.browserSetGeolocation.bind(network) + this.browserInterceptEnable = network.browserInterceptEnable.bind(network) + this.browserInterceptDisable = network.browserInterceptDisable.bind(network) + this.browserInterceptList = network.browserInterceptList.bind(network) + this.browserSetDevice = network.browserSetDevice.bind(network) + this.browserSetOffline = network.browserSetOffline.bind(network) + this.browserSetHeaders = network.browserSetHeaders.bind(network) + this.browserSetCredentials = network.browserSetCredentials.bind(network) + this.browserSetMedia = network.browserSetMedia.bind(network) + this.browserDownload = network.browserDownload.bind(network) + this.browserClipboardRead = storage.browserClipboardRead.bind(storage) + this.browserClipboardWrite = storage.browserClipboardWrite.bind(storage) + this.browserStorageLocalGet = storage.browserStorageLocalGet.bind(storage) + this.browserStorageLocalSet = storage.browserStorageLocalSet.bind(storage) + this.browserStorageLocalClear = storage.browserStorageLocalClear.bind(storage) + this.browserStorageSessionGet = storage.browserStorageSessionGet.bind(storage) + this.browserStorageSessionSet = storage.browserStorageSessionSet.bind(storage) + this.browserStorageSessionClear = storage.browserStorageSessionClear.bind(storage) + this.browserTabList = queries.browserTabList.bind(queries) + this.browserProceedCertificate = queries.browserProceedCertificate.bind(queries) + this.browserTabShow = queries.browserTabShow.bind(queries) + this.browserTabCurrent = queries.browserTabCurrent.bind(queries) + this.browserTabSwitch = queries.browserTabSwitch.bind(queries) + this.browserTabCreate = creation.browserTabCreate.bind(creation) + this.browserOpenUrlOnClient = creation.browserOpenUrlOnClient.bind(creation) + this.browserTabClose = close.browserTabClose.bind(close) + this.browserTabSetProfile = profiles.browserTabSetProfile.bind(profiles) + this.browserTabProfileShow = profiles.browserTabProfileShow.bind(profiles) + this.browserTabProfileClone = profiles.browserTabProfileClone.bind(profiles) + this.browserProfileList = profiles.browserProfileList.bind(profiles) + this.browserProfileCreate = profiles.browserProfileCreate.bind(profiles) + this.browserProfileDelete = profiles.browserProfileDelete.bind(profiles) + this.browserScreencast = screencasts.browserScreencast.bind(screencasts) + this.browserDialogAccept = dialogs.browserDialogAccept.bind(dialogs) + this.browserDialogDismiss = dialogs.browserDialogDismiss.bind(dialogs) + } +} diff --git a/src/main/runtime/browser/browser-dialogs.ts b/src/main/runtime/browser/browser-dialogs.ts new file mode 100644 index 00000000000..8ab23e2aef7 --- /dev/null +++ b/src/main/runtime/browser/browser-dialogs.ts @@ -0,0 +1,37 @@ +import type { AgentBrowserBridge } from '../../browser/agent-browser-bridge' +import type { RuntimeBrowserScreencasts } from './browser-screencasts' +import { browserDialogSettledResult, type BrowserDialogResult } from '../browser-dialog-settlement' +import type { BrowserCommandTargetParams } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +type BrowserDialogTargets = Pick & { + requireAgentBrowserBridge(): Pick +} + +export class RuntimeBrowserDialogs { + constructor( + private readonly targets: BrowserDialogTargets, + private readonly screencasts: Pick + ) {} + + async browserDialogAccept( + params: { text?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + if (!(await this.screencasts.settleDialog(target.browserPageId, true, params.text))) { + await this.targets + .requireAgentBrowserBridge() + .dialogAccept(params.text, target.worktreeId, target.browserPageId) + } + return browserDialogSettledResult(true) + } + + async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + if (!(await this.screencasts.settleDialog(target.browserPageId, false))) { + await this.targets + .requireAgentBrowserBridge() + .dialogDismiss(target.worktreeId, target.browserPageId) + } + return browserDialogSettledResult(false) + } +} diff --git a/src/main/runtime/browser/browser-element-actions.ts b/src/main/runtime/browser/browser-element-actions.ts new file mode 100644 index 00000000000..ad37e57ad70 --- /dev/null +++ b/src/main/runtime/browser/browser-element-actions.ts @@ -0,0 +1,237 @@ +import type { + BrowserFillResult, + BrowserScrollResult, + BrowserSelectResult, + BrowserTypeResult, + BrowserCheckResult, + BrowserDragResult, + BrowserFocusResult, + BrowserHoverResult, + BrowserUploadResult, + BrowserWaitResult, + BrowserClearResult, + BrowserKeypressResult, + BrowserSelectAllResult +} from '../../../shared/runtime-types' +import type { BrowserCommandTargetParams } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' + +export class RuntimeBrowserElementActions { + constructor( + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget' + > + ) {} + + async browserFill( + params: { + element: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .fill(params.element, params.value, target.worktreeId, target.browserPageId) + } + + async browserType( + params: { input: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .type(params.input, target.worktreeId, target.browserPageId) + } + + async browserSelect( + params: { + element: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .select(params.element, params.value, target.worktreeId, target.browserPageId) + } + + async browserScroll( + params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .scroll(params.direction, params.amount, target.worktreeId, target.browserPageId) + } + + async browserHover( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .hover(params.element, target.worktreeId, target.browserPageId) + } + + async browserDrag( + params: { + from: string + to: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .drag(params.from, params.to, target.worktreeId, target.browserPageId) + } + + async browserUpload( + params: { element: string; files: string[] } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .upload(params.element, params.files, target.worktreeId, target.browserPageId) + } + + async browserWait( + params: { + selector?: string + timeout?: number + text?: string + url?: string + load?: string + fn?: string + state?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + const { worktree: _, page: __, ...options } = params + return this.targets + .requireAgentBrowserBridge() + .wait(options, target.worktreeId, target.browserPageId) + } + + async browserCheck( + params: { element: string; checked: boolean } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .check(params.element, params.checked, target.worktreeId, target.browserPageId) + } + + async browserFocus( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .focus(params.element, target.worktreeId, target.browserPageId) + } + + async browserClear( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .clear(params.element, target.worktreeId, target.browserPageId) + } + + async browserSelectAll( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .selectAll(params.element, target.worktreeId, target.browserPageId) + } + + async browserKeypress( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .keypress(params.key, target.worktreeId, target.browserPageId) + } + + async browserDblclick( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .dblclick(params.element, target.worktreeId, target.browserPageId) + } + + async browserScrollIntoView( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .scrollIntoView(params.element, target.worktreeId, target.browserPageId) + } + + async browserGet( + params: { + what: string + selector?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .get(params.what, params.selector, target.worktreeId, target.browserPageId) + } + + async browserIs( + params: { what: string; selector: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .is(params.what, params.selector, target.worktreeId, target.browserPageId) + } + + async browserFind( + params: { + locator: string + value: string + action: string + text?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .find( + params.locator, + params.value, + params.action, + params.text, + target.worktreeId, + target.browserPageId + ) + } + + async browserHighlight( + params: { selector: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .highlight(params.selector, target.worktreeId, target.browserPageId) + } + + async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .exec(params.command, target.worktreeId, target.browserPageId) + } +} diff --git a/src/main/runtime/browser/browser-navigation.ts b/src/main/runtime/browser/browser-navigation.ts new file mode 100644 index 00000000000..f80787a212c --- /dev/null +++ b/src/main/runtime/browser/browser-navigation.ts @@ -0,0 +1,81 @@ +import type { + BrowserBackResult, + BrowserClickResult, + BrowserGotoResult, + BrowserReloadResult +} from '../../../shared/runtime-types' +import type { + BrowserCommandTargetParams, + RuntimeBrowserCommandHost +} from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' + +export class RuntimeBrowserNavigation { + constructor( + private readonly host: Pick< + RuntimeBrowserCommandHost, + 'getAvailableAuthoritativeWindow' | 'notifyHeadlessBrowserSessionTabsChanged' + >, + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget' | 'notifyRendererNavigation' + > + ) {} + + async browserClick( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + const bridge = this.targets.requireAgentBrowserBridge() + const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) + // Why: clicks can trigger navigation, so push the tab's live URL/title to the renderer even when automation targeted a non-active page. + const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) + if (page) { + this.targets.notifyRendererNavigation(page.browserPageId, page.url, page.title) + } + return result + } + + async browserGoto( + params: { url: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + const bridge = this.targets.requireAgentBrowserBridge() + const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.targets.notifyRendererNavigation(pageId, result.url, result.title) + } + if (!this.host.getAvailableAuthoritativeWindow() && target.worktreeId) { + this.host.notifyHeadlessBrowserSessionTabsChanged?.(target.worktreeId) + } + return result + } + + async browserBack(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + const bridge = this.targets.requireAgentBrowserBridge() + const result = await bridge.back(target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.targets.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserReload(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + const bridge = this.targets.requireAgentBrowserBridge() + const result = await bridge.reload(target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.targets.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserForward(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) + } +} diff --git a/src/main/runtime/browser/browser-network.ts b/src/main/runtime/browser/browser-network.ts new file mode 100644 index 00000000000..6d2ff01f306 --- /dev/null +++ b/src/main/runtime/browser/browser-network.ts @@ -0,0 +1,198 @@ +import type { + BrowserNetworkLogResult, + BrowserCookieDeleteResult, + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserGeolocationResult, + BrowserInterceptDisableResult, + BrowserInterceptEnableResult, + BrowserViewportResult +} from '../../../shared/runtime-types' +import type { BrowserCommandTargetParams } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' + +export class RuntimeBrowserNetwork { + constructor( + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget' + > + ) {} + + async browserNetworkLog( + params: { limit?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .networkLog(params.limit, target.worktreeId, target.browserPageId) + } + + async browserCookieGet( + params: { url?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .cookieGet(params.url, target.worktreeId, target.browserPageId) + } + + async browserCookieSet( + params: { + name: string + value: string + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + sameSite?: string + expires?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .cookieSet(params, target.worktreeId, target.browserPageId) + } + + async browserCookieDelete( + params: { + name: string + domain?: string + url?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .cookieDelete(params.name, params.domain, params.url, target.worktreeId, target.browserPageId) + } + + async browserSetViewport( + params: { + width: number + height: number + deviceScaleFactor?: number + mobile?: boolean + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .setViewport( + params.width, + params.height, + params.deviceScaleFactor, + params.mobile, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetGeolocation( + params: { + latitude: number + longitude: number + accuracy?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .setGeolocation( + params.latitude, + params.longitude, + params.accuracy, + target.worktreeId, + target.browserPageId + ) + } + + async browserInterceptEnable( + params: { + patterns?: string[] + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .interceptEnable(params.patterns, target.worktreeId, target.browserPageId) + } + + async browserInterceptDisable( + params: BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .interceptDisable(target.worktreeId, target.browserPageId) + } + + async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .interceptList(target.worktreeId, target.browserPageId) + } + + async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .setDevice(params.name, target.worktreeId, target.browserPageId) + } + + async browserSetOffline( + params: { state?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .setOffline(params.state, target.worktreeId, target.browserPageId) + } + + async browserSetHeaders( + params: { headers: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .setHeaders(params.headers, target.worktreeId, target.browserPageId) + } + + async browserSetCredentials( + params: { + user: string + pass: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .setCredentials(params.user, params.pass, target.worktreeId, target.browserPageId) + } + + async browserSetMedia( + params: { + colorScheme?: string + reducedMotion?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .setMedia(params.colorScheme, params.reducedMotion, target.worktreeId, target.browserPageId) + } + + async browserDownload( + params: { + selector: string + path: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .download(params.selector, params.path, target.worktreeId, target.browserPageId) + } +} diff --git a/src/main/runtime/browser/browser-pointer-input.ts b/src/main/runtime/browser/browser-pointer-input.ts new file mode 100644 index 00000000000..22a943565e7 --- /dev/null +++ b/src/main/runtime/browser/browser-pointer-input.ts @@ -0,0 +1,81 @@ +import type { BrowserCommandTargetParams } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +import { clampOptionalNumber } from './browser-screencast-budget' + +export class RuntimeBrowserPointerInput { + constructor( + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget' + > + ) {} + + async browserKeyboardInsertText( + params: { text: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .keyboardInsertText(params.text, target.worktreeId, target.browserPageId) + } + + async browserMouseMove( + params: { x: number; y: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .mouseMove(params.x, params.y, target.worktreeId, target.browserPageId) + } + + async browserMouseDown( + params: { button?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .mouseDown(params.button, target.worktreeId, target.browserPageId) + } + + async browserMouseClick( + params: { + x: number + y: number + button?: string + radius?: number + modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[] + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .mouseClick( + params.x, + params.y, + params.button, + target.worktreeId, + target.browserPageId, + clampOptionalNumber(params.radius, 0, 64), + params.modifiers + ) + } + + async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .mouseUp(params.button, target.worktreeId, target.browserPageId) + } + + async browserMouseWheel( + params: { + dy: number + dx?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .mouseWheel(params.dy, params.dx, target.worktreeId, target.browserPageId) + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts b/src/main/runtime/browser/browser-profiles.ts similarity index 57% rename from src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts rename to src/main/runtime/browser/browser-profiles.ts index 482182a0769..f586d259be9 100644 --- a/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts +++ b/src/main/runtime/browser/browser-profiles.ts @@ -1,6 +1,4 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserTabCreate } from './runtime-browser-commands-browser-tab-create' -import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +import { createBrowserTabInRenderer } from './browser-tab-renderer-create' import type { BrowserDetectProfilesResult, BrowserProfileCreateResult, @@ -8,25 +6,47 @@ import type { BrowserProfileListResult, BrowserTabProfileCloneResult, BrowserTabProfileShowResult, - BrowserTabSetProfileResult -} from '../../shared/runtime-types' -import { BrowserError } from '../browser/browser-error' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { browserManager } from '../browser/browser-manager' + BrowserTabSetProfileResult, + BrowserProfileClearDefaultCookiesResult, + BrowserProfileImportFromBrowserResult +} from '../../../shared/runtime-types' +import { BrowserError } from '../../browser/browser-error' +import { browserSessionRegistry } from '../../browser/browser-session-registry' +import { browserManager } from '../../browser/browser-manager' import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' -import { waitForTabRegistration } from '../ipc/browser-tab-registration-wait' -import { detectInstalledBrowsers } from '../browser/browser-cookie-import' +import { waitForTabRegistration } from '../../ipc/browser-tab-registration-wait' +import { + detectInstalledBrowsers, + importCookiesFromBrowser, + selectBrowserProfile +} from '../../browser/browser-cookie-import' +import type { + BrowserCommandTargetParams, + RuntimeBrowserCommandHost +} from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog' + +export class RuntimeBrowserProfiles { + constructor( + private readonly host: Pick, + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget' + >, + private readonly catalog: Pick + ) {} -export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrowserCommandsWithBrowserTabCreate { async browserTabSetProfile( params: { profileId: string } & BrowserCommandTargetParams ): Promise { - const target = await this.resolveBrowserCommandTarget(params) + const target = await this.targets.resolveBrowserCommandTarget(params) const browserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + target.browserPageId ?? + this.targets.requireAgentBrowserBridge().getActivePageId(target.worktreeId) if (!browserPageId) { throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') } @@ -101,8 +121,8 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows page: string worktree?: string }): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const tab = this.describeBrowserTab(params.page, target.worktreeId) + const target = await this.targets.resolveBrowserCommandTarget(params) + const tab = this.catalog.describeBrowserTab(params.page, target.worktreeId) return { browserPageId: tab.browserPageId, worktreeId: tab.worktreeId ?? null, @@ -116,13 +136,14 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows profileId: string } & BrowserCommandTargetParams ): Promise { - const target = await this.resolveBrowserCommandTarget(params) + const target = await this.targets.resolveBrowserCommandTarget(params) const sourceBrowserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + target.browserPageId ?? + this.targets.requireAgentBrowserBridge().getActivePageId(target.worktreeId) if (!sourceBrowserPageId) { throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') } - const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) + const sourceTab = this.catalog.describeBrowserTab(sourceBrowserPageId, target.worktreeId) const profile = browserSessionRegistry.getProfile(params.profileId) if (!profile) { throw new BrowserError( @@ -130,7 +151,8 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows `Browser profile ${params.profileId} was not found` ) } - const created = await this.createBrowserTabInRenderer( + const created = await createBrowserTabInRenderer( + this.host, sourceTab.url, sourceTab.worktreeId ?? target.worktreeId, profile.id, @@ -181,4 +203,60 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows })) } } + + async browserProfileImportFromBrowser(params: { + profileId: string + browserFamily: string + browserProfile?: string + supportsPartitionSkippedCookies?: true + }): Promise { + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + return { ok: false, reason: 'Session profile not found.' } + } + if ( + params.browserProfile && + (/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..')) + ) { + return { ok: false, reason: 'Invalid browser profile name.' } + } + + const browsers = detectInstalledBrowsers() + let browser = browsers.find((candidate) => candidate.family === params.browserFamily) + if (!browser) { + return { ok: false, reason: 'Browser not found on this system.' } + } + + if (params.browserProfile && params.browserProfile !== browser.selectedProfile) { + const reselected = selectBrowserProfile(browser, params.browserProfile) + if (!reselected) { + return { + ok: false, + reason: `No cookies database found for profile "${params.browserProfile}".` + } + } + browser = reselected + } + + const result = await importCookiesFromBrowser(browser, profile.partition, { + canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true + }) + if (!result.ok) { + return result + } + + const profileName = + browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ?? + browser.selectedProfile + browserSessionRegistry.updateProfileSource(params.profileId, { + browserFamily: browser.family, + profileName, + importedAt: Date.now() + }) + return { ...result, profileId: params.profileId } + } + + async browserProfileClearDefaultCookies(): Promise { + return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() } + } } diff --git a/src/main/runtime/browser/browser-screencast-budget.ts b/src/main/runtime/browser/browser-screencast-budget.ts new file mode 100644 index 00000000000..3005d30a117 --- /dev/null +++ b/src/main/runtime/browser/browser-screencast-budget.ts @@ -0,0 +1,88 @@ +import type { + BrowserScreencastFrameBudget, + BrowserScreencastSession, + BrowserScreencastViewport +} from '../../browser/browser-screencast-stream-types' +import { + browserScreencastFrameBudgetsEqual, + mergeBrowserScreencastFrameBudgets +} from '../../browser/browser-screencast-frame-budget' +import type { + BrowserScreencastParams, + ActiveBrowserScreencastPage +} from './browser-screencast-contracts' + +export async function applySharedScreencastFrameBudget( + active: Pick, + session: BrowserScreencastSession +): Promise { + const merged = mergeBrowserScreencastFrameBudgets( + Array.from(active.subscribers.values(), (subscriber) => subscriber.budget) + ) + if (!merged || browserScreencastFrameBudgetsEqual(merged, active.appliedBudget)) { + return + } + active.appliedBudget = merged + await session.updateFrameBudget(merged) +} + +export function normalizeScreencastViewport( + params: BrowserScreencastParams +): BrowserScreencastViewport { + return { + viewportWidth: clampOptionalInteger(params.viewportWidth, 320, 3840), + viewportHeight: clampOptionalInteger(params.viewportHeight, 240, 2160), + deviceScaleFactor: clampOptionalNumber(params.deviceScaleFactor, 1, 4), + mobile: params.mobile === true + } +} + +export function normalizeScreencastFrameBudget( + params: BrowserScreencastParams +): BrowserScreencastFrameBudget { + return { + quality: clampInteger(params.quality, 10, 100, 70), + maxWidth: clampInteger(params.maxWidth, 320, 3840, 1440), + maxHeight: clampInteger(params.maxHeight, 240, 2160, 1200), + everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2), + minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0) + } +} + +export function hasScreencastViewportSize(viewport: BrowserScreencastViewport): boolean { + return viewport.viewportWidth !== undefined && viewport.viewportHeight !== undefined +} + +export function clampInteger( + value: number | undefined, + min: number, + max: number, + fallback: number +): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback + } + return Math.min(max, Math.max(min, Math.round(value))) +} + +export function clampOptionalInteger( + value: number | undefined, + min: number, + max: number +): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.min(max, Math.max(min, Math.round(value))) +} + +export function clampOptionalNumber( + value: number | undefined, + min: number, + max: number +): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.min(max, Math.max(min, value)) +} diff --git a/src/main/runtime/browser/browser-screencast-contracts.ts b/src/main/runtime/browser/browser-screencast-contracts.ts new file mode 100644 index 00000000000..49311f86882 --- /dev/null +++ b/src/main/runtime/browser/browser-screencast-contracts.ts @@ -0,0 +1,57 @@ +import type { BrowserScreencastResult } from '../../../shared/runtime-types' +import type { + BrowserScreencastFrameBudget, + BrowserScreencastSession, + BrowserScreencastViewport +} from '../../browser/browser-screencast-stream-types' +import type { ScreencastSubscriberDeliveryState } from '../browser-screencast-ghost-subscriber-eviction' +import type { RpcBinarySender } from '../rpc/rpc-binary-sender' +import type { BrowserCommandTargetParams } from './browser-command-contracts' + +export type BrowserScreencastParams = { + format: 'jpeg' | 'png' + quality?: number + maxWidth?: number + maxHeight?: number + viewportWidth?: number + viewportHeight?: number + deviceScaleFactor?: number + mobile?: boolean + everyNthFrame?: number + minFrameIntervalMs?: number +} & BrowserCommandTargetParams + +export type BrowserScreencastStartResult = { + subscriptionId: string + ready: Extract + // The frame budget and the page's dialog belong to the shared page, not to one subscriber's handle. + session: Omit + // Why: callers gate frames until they have emitted `ready`, and the snapshot captured + // for a joining subscriber lands inside that window. This replays it once the gate opens. + flushPendingFrame: () => void +} + +export type ActiveBrowserScreencastSubscriber = { + sendBinary: RpcBinarySender + emit?: (event: BrowserScreencastResult) => void + done: Promise + resolveDone: () => void + viewport: BrowserScreencastViewport + budget: BrowserScreencastFrameBudget + pendingFrame: Uint8Array | null + pendingFrameRetry: ReturnType | null + // Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new + // socket never reuses the old id, so a reconnecting device would stack a second subscription. + pairedDeviceId?: string + delivery: ScreencastSubscriberDeliveryState +} + +export type ActiveBrowserScreencastPage = { + format: 'jpeg' | 'png' + session: BrowserScreencastSession | null + started: Promise + stopping: boolean + subscribers: Map + viewportOwnerSubscriptionId: string | null + appliedBudget: BrowserScreencastFrameBudget +} diff --git a/src/main/runtime/browser/browser-screencasts.test.ts b/src/main/runtime/browser/browser-screencasts.test.ts new file mode 100644 index 00000000000..1c0ad762934 --- /dev/null +++ b/src/main/runtime/browser/browser-screencasts.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebContents } from 'electron' +import type { + BrowserScreencastOptions, + BrowserScreencastSession +} from '../../browser/browser-screencast-stream-types' +import { + createSinglePageBrowserCommandsHost, + createStreamedGuest +} from '../single-page-browser-commands-host-test-double' +import { RuntimeBrowserCommands } from './browser-commands' + +const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({ + webContentsFromId: vi.fn(), + startBrowserScreencast: + vi.fn< + (guest: WebContents, options: BrowserScreencastOptions) => Promise + >() +})) + +vi.mock('electron', () => ({ + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: webContentsFromId } +})) +vi.mock('../../browser/browser-screencast-stream', () => ({ startBrowserScreencast })) + +function createSession() { + let close!: () => void + const done = new Promise((resolve) => { + close = resolve + }) + return { + stop: vi.fn(() => close()), + done, + updateViewport: async () => {}, + updateFrameBudget: async () => {}, + settleDialog: async () => false + } +} + +const params = { page: 'page-1', format: 'jpeg' } as const + +describe('screencast ownership', () => { + beforeEach(() => { + webContentsFromId.mockReset() + webContentsFromId.mockReturnValue(createStreamedGuest()) + startBrowserScreencast.mockReset() + }) + + it('keeps same-page streams isolated between runtime instances', async () => { + const firstSession = createSession() + const secondSession = createSession() + startBrowserScreencast.mockResolvedValueOnce(firstSession).mockResolvedValueOnce(secondSession) + const firstCommands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) + const secondCommands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) + const firstSend = vi.fn(() => true) + const secondSend = vi.fn(() => true) + const first = await firstCommands.browserScreencast(params, { sendBinary: firstSend }) + const second = await secondCommands.browserScreencast(params, { sendBinary: secondSend }) + const frame = new Uint8Array([1, 2, 3]) + + expect(startBrowserScreencast).toHaveBeenCalledTimes(2) + startBrowserScreencast.mock.calls[0]?.[1].onFrame(frame) + expect(firstSend).toHaveBeenCalledOnce() + expect(secondSend).not.toHaveBeenCalled() + + first.session.stop() + await first.session.done + expect(firstSession.stop).toHaveBeenCalledOnce() + expect(secondSession.stop).not.toHaveBeenCalled() + startBrowserScreencast.mock.calls[1]?.[1].onFrame(frame) + expect(secondSend).toHaveBeenCalledOnce() + second.session.stop() + await second.session.done + }) + + it('settles dialogs through the stream owned by the command instance', async () => { + const session = createSession() + const settleDialog = vi.spyOn(session, 'settleDialog').mockResolvedValue(true) + startBrowserScreencast.mockResolvedValue(session) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) + const stream = await commands.browserScreencast(params, { sendBinary: () => true }) + + expect(await commands.browserDialogAccept({ page: params.page, text: 'Ada' })).toEqual({ + accepted: true + }) + expect(settleDialog).toHaveBeenCalledWith(true, 'Ada') + expect(await commands.browserDialogDismiss({ page: params.page })).toEqual({ accepted: false }) + expect(settleDialog).toHaveBeenLastCalledWith(false, undefined) + stream.session.stop() + await stream.session.done + }) + + it('accepts startup callbacks before the stream promise is returned', async () => { + const session = createSession() + const startupFrame = new Uint8Array([4, 5, 6]) + const send = vi.fn(() => true) + startBrowserScreencast.mockImplementation((_guest, options) => { + expect(options.onFrame(startupFrame)).toBe(true) + options.onError?.('startup notification') + expect(send).not.toHaveBeenCalled() + return Promise.resolve(session) + }) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) + const stream = await commands.browserScreencast(params, { sendBinary: send }) + + expect(session.stop).not.toHaveBeenCalled() + startBrowserScreencast.mock.calls[0]?.[1].onFrame(startupFrame) + expect(send).toHaveBeenCalledWith(startupFrame, { dropWhenBacklogged: true }) + stream.session.stop() + await stream.session.done + }) +}) diff --git a/src/main/runtime/runtime-browser-commands-browser-screencast.ts b/src/main/runtime/browser/browser-screencasts.ts similarity index 59% rename from src/main/runtime/runtime-browser-commands-browser-screencast.ts rename to src/main/runtime/browser/browser-screencasts.ts index d8875b975aa..6318606b3bb 100644 --- a/src/main/runtime/runtime-browser-commands-browser-screencast.ts +++ b/src/main/runtime/browser/browser-screencasts.ts @@ -1,36 +1,51 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserClick } from './runtime-browser-commands-browser-click' -import type { - ActiveBrowserScreencastPage, - ActiveBrowserScreencastSubscriber, - BrowserCommandTargetParams, - BrowserScreencastParams, - BrowserScreencastStartResult -} from './runtime-browser-commands-browser-command-target-params' -import { - applySharedScreencastFrameBudget, - hasScreencastViewportSize, - normalizeScreencastFrameBudget, - normalizeScreencastViewport -} from './runtime-browser-commands-browser-command-target-params' -import type { BrowserEvalResult, BrowserScreencastResult } from '../../shared/runtime-types' -import { BrowserError } from '../browser/browser-error' +import { settleBrowserDialogOnLiveScreencast } from '../browser-dialog-settlement' +import type { BrowserScreencastResult } from '../../../shared/runtime-types' +import { BrowserError } from '../../browser/browser-error' import { randomUUID } from 'node:crypto' -import { mintBrowserScreencastSubscriptionId } from './browser-screencast-subscription-id' -import { startBrowserScreencast } from '../browser/browser-screencast-stream' -import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' +import { startBrowserScreencast } from '../../browser/browser-screencast-stream' +import { rendererPublicationThrottle } from '../../window/renderer-publication-throttle' +import { mintBrowserScreencastSubscriptionId } from '../browser-screencast-subscription-id' import { cancelScreencastSubscriberFrameRetry, deliverScreencastSubscriberFrame -} from './browser-screencast-subscriber-frame-delivery' +} from '../browser-screencast-subscriber-frame-delivery' import { INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY, screencastSubscriberIsGhost -} from './browser-screencast-ghost-subscriber-eviction' -import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' -import type { RpcBinarySender } from './rpc/rpc-binary-sender' +} from '../browser-screencast-ghost-subscriber-eviction' +import type { BrowserScreencastSession } from '../../browser/browser-screencast-stream-types' +import type { RpcBinarySender } from '../rpc/rpc-binary-sender' +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog' +import { + applySharedScreencastFrameBudget, + normalizeScreencastViewport, + normalizeScreencastFrameBudget, + hasScreencastViewportSize +} from './browser-screencast-budget' +import type { + BrowserScreencastParams, + BrowserScreencastStartResult, + ActiveBrowserScreencastSubscriber, + ActiveBrowserScreencastPage +} from './browser-screencast-contracts' + +export class RuntimeBrowserScreencasts { + private readonly activeScreencastsByPageId = new Map() + + constructor( + private readonly host: Pick, + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'resolveBrowserCommandTarget' | 'resolveBrowserPageWebContents' + >, + private readonly catalog: Pick< + RuntimeBrowserTabCatalog, + 'resolveClientHostedBrowserPage' | 'describeBrowserTab' + > + ) {} -export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserCommandsWithBrowserClick { async browserScreencast( params: BrowserScreencastParams, stream: { @@ -39,14 +54,14 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC pairedDeviceId?: string } ): Promise { - if (await this.resolveClientHostedBrowserPage(params)) { + if (await this.catalog.resolveClientHostedBrowserPage(params)) { throw new BrowserError( 'browser_error', 'Client-hosted browser pages do not support server screencast.' ) } - const target = await this.resolveBrowserCommandTarget(params) - const { browserPageId, webContents: guest } = this.resolveBrowserPageWebContents( + const target = await this.targets.resolveBrowserCommandTarget(params) + const { browserPageId, webContents: guest } = this.targets.resolveBrowserPageWebContents( target.worktreeId, target.browserPageId ) @@ -66,26 +81,26 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC if (!active) { createdPageStream = true const subscribers = new Map() - const record = { + const pendingRecord: Omit = { format: params.format, session: null, stopping: false, subscribers, viewportOwnerSubscriptionId: null, appliedBudget: budget - } as ActiveBrowserScreencastPage + } // Why: guest frames come from the window's compositor, which a throttled hidden window stops. const win = this.host.getAvailableAuthoritativeWindow() const releaseThrottle = win ? rendererPublicationThrottle.acquire(win.webContents) : () => {} // Why: on the active tab the guest is the widget a cover hides, and its attach-time unthrottle can predate it. const releaseGuestThrottle = rendererPublicationThrottle.acquire(guest) - record.started = startBrowserScreencast(guest, { + const started = startBrowserScreencast(guest, { format: params.format, ...budget, ...viewport, onFrame: (bytes) => { const ghosts: string[] = [] - for (const [subscriptionId, subscriber] of record.subscribers) { + for (const [subscriptionId, subscriber] of pendingRecord.subscribers) { // A slow viewer drops this frame without stalling every other viewer. deliverScreencastSubscriberFrame(subscriber, bytes) if (screencastSubscriberIsGhost(subscriber.delivery)) { @@ -95,29 +110,30 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC // Evicting after the fan-out keeps a teardown that stops the session from cutting the // remaining viewers out of this frame. for (const subscriptionId of ghosts) { - if (record.session) { - this.leaveScreencastSubscriber(record, subscriptionId, record.session) + if (pendingRecord.session) { + this.leaveScreencastSubscriber(pendingRecord, subscriptionId, pendingRecord.session) } } return true }, onEvent: (event) => { - for (const subscriber of record.subscribers.values()) { + for (const subscriber of pendingRecord.subscribers.values()) { subscriber.emit?.(event) } }, onError: (message) => { - for (const subscriber of record.subscribers.values()) { + for (const subscriber of pendingRecord.subscribers.values()) { subscriber.emit?.({ type: 'error', message }) } // Why: every stream error is terminal; marking it stopping makes a newcomer start a fresh // stream instead of joining this one and waiting on frames that will never come. - if (record.session) { - record.stopping = true - record.session.stop() + if (pendingRecord.session) { + pendingRecord.stopping = true + pendingRecord.session.stop() } } }) + const record: ActiveBrowserScreencastPage = Object.assign(pendingRecord, { started }) active = record this.activeScreencastsByPageId.set(browserPageId, record) void record.started @@ -131,11 +147,11 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC if (this.activeScreencastsByPageId.get(browserPageId) === record) { this.activeScreencastsByPageId.delete(browserPageId) } - for (const subscriber of record.subscribers.values()) { + for (const subscriber of pendingRecord.subscribers.values()) { cancelScreencastSubscriberFrameRetry(subscriber) subscriber.resolveDone() } - record.subscribers.clear() + pendingRecord.subscribers.clear() }) .catch(() => {}) } @@ -204,19 +220,56 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC subscriptionId, browserPageId, format: active.format, - tab: this.describeBrowserTab(browserPageId, target.worktreeId) + tab: this.catalog.describeBrowserTab(browserPageId, target.worktreeId) } } } - async browserEval( - params: { expression: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().evaluate( - params.expression, - target.worktreeId, - target.browserPageId + // Explicit stops, ghost eviction and reconnect replacement must share the same teardown. + private leaveScreencastSubscriber( + active: Pick< + ActiveBrowserScreencastPage, + 'subscribers' | 'viewportOwnerSubscriptionId' | 'stopping' | 'appliedBudget' + >, + subscriptionId: string, + session: BrowserScreencastSession + ): void { + const subscriber = active.subscribers.get(subscriptionId) + if (!subscriber) { + return + } + active.subscribers.delete(subscriptionId) + cancelScreencastSubscriberFrameRetry(subscriber) + subscriber.resolveDone() + if (active.viewportOwnerSubscriptionId === subscriptionId) { + const fallback = Array.from(active.subscribers.entries()).findLast(([, candidate]) => + hasScreencastViewportSize(candidate.viewport) + ) + active.viewportOwnerSubscriptionId = fallback?.[0] ?? null + if (fallback) { + void session.updateViewport(fallback[1].viewport).catch(() => {}) + } + } + if (active.subscribers.size === 0) { + active.stopping = true + session.stop() + return + } + // Why: a departed subscriber's caps would otherwise pin the shared stream for + // the rest of its life, long after the client that asked for them is gone. + void applySharedScreencastFrameBudget(active, session).catch(() => {}) + } + + settleDialog( + browserPageId: string | undefined, + accept: boolean, + promptText?: string + ): Promise { + return settleBrowserDialogOnLiveScreencast( + this.activeScreencastsByPageId, + browserPageId, + accept, + promptText ) } } diff --git a/src/main/runtime/browser/browser-storage.ts b/src/main/runtime/browser/browser-storage.ts new file mode 100644 index 00000000000..8a4761af781 --- /dev/null +++ b/src/main/runtime/browser/browser-storage.ts @@ -0,0 +1,83 @@ +import type { BrowserCommandTargetParams } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' + +export class RuntimeBrowserStorage { + constructor( + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget' + > + ) {} + + async browserClipboardRead(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .clipboardRead(target.worktreeId, target.browserPageId) + } + + async browserClipboardWrite( + params: { text: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .clipboardWrite(params.text, target.worktreeId, target.browserPageId) + } + + async browserStorageLocalGet( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .storageLocalGet(params.key, target.worktreeId, target.browserPageId) + } + + async browserStorageLocalSet( + params: { + key: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .storageLocalSet(params.key, params.value, target.worktreeId, target.browserPageId) + } + + async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .storageLocalClear(target.worktreeId, target.browserPageId) + } + + async browserStorageSessionGet( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .storageSessionGet(params.key, target.worktreeId, target.browserPageId) + } + + async browserStorageSessionSet( + params: { + key: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .storageSessionSet(params.key, params.value, target.worktreeId, target.browserPageId) + } + + async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { + const target = await this.targets.resolveBrowserCommandTarget(params) + return this.targets + .requireAgentBrowserBridge() + .storageSessionClear(target.worktreeId, target.browserPageId) + } +} diff --git a/src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts b/src/main/runtime/browser/browser-tab-catalog.ts similarity index 51% rename from src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts rename to src/main/runtime/browser/browser-tab-catalog.ts index 8f86fbfa44d..aa173ff4bf4 100644 --- a/src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts +++ b/src/main/runtime/browser/browser-tab-catalog.ts @@ -1,16 +1,24 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserTabClose } from './runtime-browser-commands-browser-tab-close' -import type { RuntimeBrowserClientPage } from './runtime-browser-page-registry' -import type { BrowserTabListResult } from '../../shared/runtime-types' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { BrowserError } from '../browser/browser-error' -import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' -import { browserManager } from '../browser/browser-manager' -import { randomUUID } from 'node:crypto' -import { ipcMain } from 'electron' +import type { RuntimeBrowserClientPage } from '../runtime-browser-page-registry' +import type { BrowserTabListResult } from '../../../shared/runtime-types' +import { browserSessionRegistry } from '../../browser/browser-session-registry' +import { BrowserError } from '../../browser/browser-error' +import { browserManager } from '../../browser/browser-manager' +import type { + BrowserCommandTargetParams, + RuntimeBrowserCommandHost +} from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' -export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBrowserCommandsWithBrowserTabClose { - protected listLogicalBrowserTabs( +export class RuntimeBrowserTabCatalog { + constructor( + private readonly host: Pick< + RuntimeBrowserCommandHost, + 'getAgentBrowserBridge' | 'resolveBrowserWorkspace' | 'getRuntimeBrowserPageRegistry' + >, + private readonly targets: Pick + ) {} + + listLogicalBrowserTabs( worktreeId: string | undefined, clientPages: readonly RuntimeBrowserClientPage[] ): BrowserTabListResult['tabs'] { @@ -47,7 +55,7 @@ export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBro return tabs.map((tab, index) => ({ ...tab, index })) } - protected async assertClientPageWorkspace( + async assertClientPageWorkspace( page: RuntimeBrowserClientPage, selector: string | undefined ): Promise { @@ -63,7 +71,7 @@ export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBro } } - protected async resolveClientHostedBrowserPage( + async resolveClientHostedBrowserPage( params: BrowserCommandTargetParams ): Promise { const pages = this.host.getRuntimeBrowserPageRegistry() @@ -80,12 +88,13 @@ export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBro return pages.listPages(workspaceId).find((page) => page.active) } - protected describeBrowserTab( + describeBrowserTab( browserPageId: string, explicitWorktreeId?: string ): BrowserTabListResult['tabs'][number] { const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) - const tab = this.requireAgentBrowserBridge() + const tab = this.targets + .requireAgentBrowserBridge() .tabList(worktreeId) .tabs.find((entry) => entry.browserPageId === browserPageId) if (!tab) { @@ -98,51 +107,18 @@ export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBro return this.enrichBrowserTabInfo(tab) } - protected async createBrowserTabInRenderer( - url: string, - worktreeId: string | undefined, - profileId: string | undefined, - sessionPartition: string | undefined, - activate?: boolean, - requestedPageId?: string - ): Promise<{ browserPageId: string }> { - const win = this.host.getAuthoritativeWindow() - const requestId = randomUUID() - - const browserPageId = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCreateReply', handler) - reject(new Error('Tab creation timed out')) - }, 10_000) - - const handler = ( - event: Electron.IpcMainEvent, - reply: { requestId: string; browserPageId?: string; error?: string } - ): void => { - if (event.sender !== win.webContents || reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCreateReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve(reply.browserPageId!) - } - } - ipcMain.on('browser:tabCreateReply', handler) - win.webContents.send('browser:requestTabCreate', { - requestId, - url, - worktreeId, - ...(requestedPageId ? { browserPageId: requestedPageId } : {}), - // Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance. - sessionProfileId: profileId, - sessionPartition, - activate - }) - }) - - return { browserPageId } + private enrichBrowserTabInfo( + tab: BrowserTabListResult['tabs'][number] + ): BrowserTabListResult['tabs'][number] { + const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) + const profile = + browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? + browserSessionRegistry.getDefaultProfile() + return { + ...tab, + worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, + profileId: profile.id, + profileLabel: profile.label + } } } diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-close.ts b/src/main/runtime/browser/browser-tab-close.ts similarity index 77% rename from src/main/runtime/runtime-browser-commands-browser-tab-close.ts rename to src/main/runtime/browser/browser-tab-close.ts index 7b6845234f1..bc924ece306 100644 --- a/src/main/runtime/runtime-browser-commands-browser-tab-close.ts +++ b/src/main/runtime/browser/browser-tab-close.ts @@ -1,14 +1,38 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser } from './runtime-browser-commands-browser-profile-import-from-browser' -import { closeRuntimeBrowserClientPage } from './runtime-browser-client-page-creation' -import { BrowserError } from '../browser/browser-error' +import type { RuntimeBrowserTabQueries } from './browser-tab-queries' +import { closeRuntimeBrowserClientPage } from '../runtime-browser-client-page-creation' +import { BrowserError } from '../../browser/browser-error' import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' -import type { BrowserTabListResult } from '../../shared/runtime-types' -import { browserManager } from '../browser/browser-manager' -import { browserSessionRegistry } from '../browser/browser-session-registry' +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog' + +export class RuntimeBrowserTabClose { + constructor( + private readonly queries: Pick< + RuntimeBrowserTabQueries, + 'browserTabList' | 'browserTabCurrent' + >, + private readonly host: Pick< + RuntimeBrowserCommandHost, + | 'getRuntimeBrowserPageRegistry' + | 'resolveBrowserWorkspace' + | 'getBrowserHostLeaseRegistry' + | 'retireRuntimeOwnedBrowserSessionTab' + | 'notifyHeadlessBrowserSessionTabsChanged' + | 'getAgentBrowserBridge' + | 'resolveWorktreeSelector' + | 'getAvailableAuthoritativeWindow' + | 'getOffscreenBrowserBackend' + | 'getAuthoritativeWindow' + >, + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'resolveBrowserWorktreeId' | 'retireGhostBrowserSessionRow' + >, + private readonly catalog: Pick + ) {} -export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser { async browserTabClose(params: { index?: number page?: string @@ -17,7 +41,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom const pages = this.host.getRuntimeBrowserPageRegistry() let clientPage = params.page ? pages.getPage(params.page) : undefined if (clientPage) { - await this.assertClientPageWorkspace(clientPage, params.worktree) + await this.catalog.assertClientPageWorkspace(clientPage, params.worktree) } else if (!params.page) { const workspaceId = params.worktree ? (await this.host.resolveBrowserWorkspace(params.worktree)).id @@ -25,8 +49,8 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom if (pages.listPages(workspaceId).length > 0) { const tab = params.index !== undefined - ? (await this.browserTabList({ worktree: params.worktree })).tabs[params.index] - : (await this.browserTabCurrent({ worktree: params.worktree })).tab + ? (await this.queries.browserTabList({ worktree: params.worktree })).tabs[params.index] + : (await this.queries.browserTabCurrent({ worktree: params.worktree })).tab clientPage = tab ? pages.getPage(tab.browserPageId) : undefined } } @@ -63,7 +87,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom if ( namedPageId && params.worktree && - this.retireGhostBrowserSessionRow( + this.targets.retireGhostBrowserSessionRow( (await this.host.resolveWorktreeSelector(params.worktree)).id, namedPageId ) @@ -76,7 +100,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom ? params.worktree ? (await this.host.resolveWorktreeSelector(params.worktree)).id : undefined - : await this.resolveBrowserWorktreeId(params.worktree) + : await this.targets.resolveBrowserWorktreeId(params.worktree) let tabId: string | null = null if (namedPageId !== null) { @@ -108,7 +132,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom return { closed: false } } if (explicitPage && !bridge.getRegisteredTabs(worktreeId).has(resolvedTabId)) { - if (this.retireGhostBrowserSessionRow(worktreeId, resolvedTabId)) { + if (this.targets.retireGhostBrowserSessionRow(worktreeId, resolvedTabId)) { return { closed: true } } const scope = worktreeId ? ' in this worktree' : '' @@ -131,7 +155,7 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom } if (!authoritativeWindow && tabId && !bridge.getRegisteredTabs(worktreeId).has(tabId)) { - if (this.retireGhostBrowserSessionRow(worktreeId, tabId)) { + if (this.targets.retireGhostBrowserSessionRow(worktreeId, tabId)) { return { closed: true } } const scope = worktreeId ? ' in this worktree' : '' @@ -172,19 +196,4 @@ export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCom return { closed: true } } - - protected enrichBrowserTabInfo( - tab: BrowserTabListResult['tabs'][number] - ): BrowserTabListResult['tabs'][number] { - const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) - const profile = - browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? - browserSessionRegistry.getDefaultProfile() - return { - ...tab, - worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, - profileId: profile.id, - profileLabel: profile.label - } - } } diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts b/src/main/runtime/browser/browser-tab-creation.ts similarity index 79% rename from src/main/runtime/runtime-browser-commands-browser-tab-create.ts rename to src/main/runtime/browser/browser-tab-creation.ts index cab26fb987c..58b025e7fe9 100644 --- a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts +++ b/src/main/runtime/browser/browser-tab-creation.ts @@ -1,24 +1,45 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserSetHeaders } from './runtime-browser-commands-browser-set-headers' -import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' -import type { BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement' +import { createBrowserTabInRenderer } from './browser-tab-renderer-create' +import type { RuntimeNavigationTarget } from '../../../shared/runtime-navigation' +import type { BrowserPageCreationPlacement } from '../../../shared/browser-client-host-placement' import { publishCreatedBrowserSessionTab, resolveBrowserTabCreateFocus -} from './browser-tab-create-publication' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { BrowserError } from '../browser/browser-error' +} from '../browser-tab-create-publication' +import { browserSessionRegistry } from '../../browser/browser-session-registry' +import { BrowserError } from '../../browser/browser-error' import { randomUUID } from 'node:crypto' import { createRuntimeBrowserClientPage, navigateRuntimeBrowserClientPage -} from './runtime-browser-client-page-creation' -import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route' -import { waitForTabRegistration } from '../ipc/browser-tab-registration-wait' -import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../shared/browser-client-automation-protocol' -import { BROWSER_HOST_WEBVIEW_CAPABILITY } from './browser-host-capability-selection' +} from '../runtime-browser-client-page-creation' +import { browserNetworkExecutionHostKey } from '../../browser/browser-network-execution-route' +import { waitForTabRegistration } from '../../ipc/browser-tab-registration-wait' +import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../../shared/browser-client-automation-protocol' +import { BROWSER_HOST_WEBVIEW_CAPABILITY } from '../browser-host-capability-selection' +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' + +export class RuntimeBrowserTabCreation { + constructor( + private readonly host: Pick< + RuntimeBrowserCommandHost, + | 'getAgentBrowserBridge' + | 'resolveBrowserWorkspace' + | 'resolveBrowserNetworkExecutionHost' + | 'getBrowserHostLeaseRegistry' + | 'getRuntimeBrowserPageRegistry' + | 'getAvailableAuthoritativeWindow' + | 'getOffscreenBrowserBackend' + | 'getAuthoritativeWindow' + | 'markHeadlessBrowserSessionTabActive' + | 'notifyHeadlessBrowserSessionTabsChanged' + >, + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + 'requireAgentBrowserBridge' | 'notifyRendererNavigation' + > + ) {} -export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCommandsWithBrowserSetHeaders { async browserTabCreate( params: { url?: string @@ -134,7 +155,8 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo }) return { browserPageId: created.browserPageId } } - const { browserPageId } = await this.createBrowserTabInRenderer( + const { browserPageId } = await createBrowserTabInRenderer( + this.host, url, worktreeId, params.profileId, @@ -152,7 +174,7 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo } } - const bridge = this.requireAgentBrowserBridge() + const bridge = this.targets.requireAgentBrowserBridge() publishCreatedBrowserSessionTab(this.host, { placementKind: 'renderer', browserPageId, @@ -166,7 +188,7 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo if (url && url !== 'about:blank') { const navigate = async (): Promise => { const result = await bridge.goto(url, worktreeId, browserPageId) - this.notifyRendererNavigation(browserPageId, result.url, result.title) + this.targets.notifyRendererNavigation(browserPageId, result.url, result.title) if (!this.host.getAvailableAuthoritativeWindow() && worktreeId) { this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) } diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-list.ts b/src/main/runtime/browser/browser-tab-queries.ts similarity index 50% rename from src/main/runtime/runtime-browser-commands-browser-tab-list.ts rename to src/main/runtime/browser/browser-tab-queries.ts index 7dfa007abf6..dfb2eba95f2 100644 --- a/src/main/runtime/runtime-browser-commands-browser-tab-list.ts +++ b/src/main/runtime/browser/browser-tab-queries.ts @@ -1,24 +1,43 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserScreencast } from './runtime-browser-commands-browser-screencast' import type { - BrowserCheckResult, - BrowserDragResult, - BrowserFocusResult, - BrowserHoverResult, BrowserTabCurrentResult, BrowserTabListResult, BrowserTabShowResult, - BrowserTabSwitchResult, - BrowserUploadResult, - BrowserWaitResult -} from '../../shared/runtime-types' -import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' -import type { BrowserCertificateProceedResult } from '../../shared/browser-workspace-types' -import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' -import { BrowserError } from '../browser/browser-error' -import { publishSwitchedBrowserSessionTab } from './browser-tab-create-publication' + BrowserTabSwitchResult +} from '../../../shared/runtime-types' +import type { BrowserCertificateProceedResult } from '../../../shared/browser-workspace-types' +import { browserCertificateTrustController, browserManager } from '../../browser/browser-manager' +import { BrowserError } from '../../browser/browser-error' +import { publishSwitchedBrowserSessionTab } from '../browser-tab-create-publication' +import type { + BrowserCommandTargetParams, + RuntimeBrowserCommandHost +} from './browser-command-contracts' +import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog' + +export class RuntimeBrowserTabQueries { + constructor( + private readonly host: Pick< + RuntimeBrowserCommandHost, + | 'getAgentBrowserBridge' + | 'resolveBrowserWorkspace' + | 'getRuntimeBrowserPageRegistry' + | 'markHeadlessBrowserSessionTabActive' + | 'notifyHeadlessBrowserSessionTabsChanged' + >, + private readonly targets: Pick< + RuntimeBrowserCommandTargets, + | 'requireAgentBrowserBridge' + | 'resolveBrowserCommandTarget' + | 'resolveBrowserWorktreeId' + | 'notifyRendererBrowserPaneFocus' + >, + private readonly catalog: Pick< + RuntimeBrowserTabCatalog, + 'listLogicalBrowserTabs' | 'assertClientPageWorkspace' | 'describeBrowserTab' + > + ) {} -export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserCommandsWithBrowserScreencast { async browserTabList(params: { worktree?: string }): Promise { const workspaceId = params.worktree ? (await this.host.resolveBrowserWorkspace(params.worktree)).id @@ -27,20 +46,20 @@ export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserComm let bridgeWorktreeId = workspaceId if (this.host.getAgentBrowserBridge()) { try { - bridgeWorktreeId = await this.resolveBrowserWorktreeId(params.worktree) + bridgeWorktreeId = await this.targets.resolveBrowserWorktreeId(params.worktree) } catch (error) { if (clientPages.length === 0) { throw error } } } - return { tabs: this.listLogicalBrowserTabs(bridgeWorktreeId, clientPages) } + return { tabs: this.catalog.listLogicalBrowserTabs(bridgeWorktreeId, clientPages) } } async browserProceedCertificate( params: { challengeId: string } & BrowserCommandTargetParams ): Promise { - const target = await this.resolveBrowserCommandTarget(params) + const target = await this.targets.resolveBrowserCommandTarget(params) if (!target.browserPageId) { return { ok: false, reason: 'missing' } } @@ -50,18 +69,20 @@ export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserComm async browserTabShow(params: { page: string; worktree?: string }): Promise { const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) if (clientPage) { - await this.assertClientPageWorkspace(clientPage, params.worktree) - const tab = this.listLogicalBrowserTabs( - clientPage.workspaceId, - this.host.getRuntimeBrowserPageRegistry().listPages(clientPage.workspaceId) - ).find((candidate) => candidate.browserPageId === clientPage.browserPageId) + await this.catalog.assertClientPageWorkspace(clientPage, params.worktree) + const tab = this.catalog + .listLogicalBrowserTabs( + clientPage.workspaceId, + this.host.getRuntimeBrowserPageRegistry().listPages(clientPage.workspaceId) + ) + .find((candidate) => candidate.browserPageId === clientPage.browserPageId) if (!tab) { throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) } return { tab } } - const target = await this.resolveBrowserCommandTarget(params) - return { tab: this.describeBrowserTab(params.page, target.worktreeId) } + const target = await this.targets.resolveBrowserCommandTarget(params) + return { tab: this.catalog.describeBrowserTab(params.page, target.worktreeId) } } async browserTabCurrent(params: { worktree?: string }): Promise { @@ -103,7 +124,7 @@ export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserComm }) return { switched: switchedIndex, browserPageId: clientPage.browserPageId } } - const bridge = this.requireAgentBrowserBridge() + const bridge = this.targets.requireAgentBrowserBridge() const worktreeId = typeof selected.worktreeId === 'string' ? selected.worktreeId @@ -125,85 +146,8 @@ export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserComm focus: params.focus }) if (params.focus) { - this.notifyRendererBrowserPaneFocus(focusWorktreeId, result.browserPageId) + this.targets.notifyRendererBrowserPaneFocus(focusWorktreeId, result.browserPageId) } return { ...result, switched: switchedIndex } } - - async browserHover( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().hover( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserDrag( - params: { - from: string - to: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().drag( - params.from, - params.to, - target.worktreeId, - target.browserPageId - ) - } - - async browserUpload( - params: { element: string; files: string[] } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().upload( - params.element, - params.files, - target.worktreeId, - target.browserPageId - ) - } - - async browserWait( - params: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const { worktree: _, page: __, ...options } = params - return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId) - } - - async browserCheck( - params: { element: string; checked: boolean } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().check( - params.element, - params.checked, - target.worktreeId, - target.browserPageId - ) - } - - async browserFocus( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().focus( - params.element, - target.worktreeId, - target.browserPageId - ) - } } diff --git a/src/main/runtime/browser/browser-tab-renderer-create.ts b/src/main/runtime/browser/browser-tab-renderer-create.ts new file mode 100644 index 00000000000..3c2e8b09434 --- /dev/null +++ b/src/main/runtime/browser/browser-tab-renderer-create.ts @@ -0,0 +1,52 @@ +import { randomUUID } from 'node:crypto' +import { ipcMain } from 'electron' +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' + +export async function createBrowserTabInRenderer( + host: Pick, + url: string, + worktreeId: string | undefined, + profileId: string | undefined, + sessionPartition: string | undefined, + activate?: boolean, + requestedPageId?: string +): Promise<{ browserPageId: string }> { + const win = host.getAuthoritativeWindow() + const requestId = randomUUID() + + const browserPageId = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCreateReply', handler) + reject(new Error('Tab creation timed out')) + }, 10_000) + + const handler = ( + event: Electron.IpcMainEvent, + reply: { requestId: string; browserPageId?: string; error?: string } + ): void => { + if (event.sender !== win.webContents || reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCreateReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve(reply.browserPageId!) + } + } + ipcMain.on('browser:tabCreateReply', handler) + win.webContents.send('browser:requestTabCreate', { + requestId, + url, + worktreeId, + ...(requestedPageId ? { browserPageId: requestedPageId } : {}), + // Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance. + sessionProfileId: profileId, + sessionPartition, + activate + }) + }) + + return { browserPageId } +} diff --git a/src/main/runtime/orca-runtime-browser-client-hosted.test.ts b/src/main/runtime/orca-runtime-browser-client-hosted.test.ts index f9f69834463..500cb2d6921 100644 --- a/src/main/runtime/orca-runtime-browser-client-hosted.test.ts +++ b/src/main/runtime/orca-runtime-browser-client-hosted.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' const { startBrowserScreencastMock, browserSessionRegistryMock } = vi.hoisted(() => ({ @@ -129,7 +129,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('creates an explicitly client-placed page without a server renderer or offscreen backend', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const createClientPage = vi.fn(async () => ({ kind: 'client' as const, browserHostClientId: 'host-a', @@ -197,7 +197,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('moves a user-created client page into the clicked split group', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const markHeadlessBrowserSessionTabActive = vi.fn() const host = createHost({ resolveBrowserNetworkExecutionHost: vi.fn(async () => ({ @@ -254,7 +254,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('keeps a background client page out of the workspace current-page slot', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const registry = new RuntimeBrowserPageRegistry() const commands = new RuntimeBrowserCommands( createHost({ @@ -298,7 +298,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('publishes the proven client page before navigation and scopes it to the worktree', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const createProof = deferred<{ kind: 'client' browserHostClientId: string @@ -378,7 +378,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('retains a folder-scoped logical client page when navigation fails', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const registry = new RuntimeBrowserPageRegistry() const resolveBrowserWorkspace = vi.fn(async () => ({ id: 'folder:folder-1', @@ -449,7 +449,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('lists, shows, and resolves the current client page on a browserless host', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const registry = new RuntimeBrowserPageRegistry() publishClientPage(registry, { browserPageId: 'page-client', workspaceId: 'folder:folder-1' }) const commands = new RuntimeBrowserCommands( @@ -483,7 +483,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('switches logical client pages without invoking the server bridge', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const registry = new RuntimeBrowserPageRegistry() publishClientPage(registry, { browserPageId: 'page-a', active: true }) publishClientPage(registry, { browserPageId: 'page-b', active: false }) @@ -506,7 +506,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('reports one global active client page across independently active workspaces', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const registry = new RuntimeBrowserPageRegistry() publishClientPage(registry, { browserPageId: 'page-a', workspaceId: 'wt-1' }) publishClientPage(registry, { browserPageId: 'page-b', workspaceId: 'wt-2' }) @@ -532,7 +532,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('makes a switched server tab globally active without clearing other workspace scopes', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const registry = new RuntimeBrowserPageRegistry() publishClientPage(registry, { browserPageId: 'page-client-a', workspaceId: 'wt-1' }) publishClientPage(registry, { browserPageId: 'page-client-b', workspaceId: 'wt-2' }) @@ -571,7 +571,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('closes the exact client page and excludes it from server screencast', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const registry = new RuntimeBrowserPageRegistry() publishClientPage(registry, { browserPageId: 'page-client' }) const issueClientPageCommand = vi.fn(() => ({ @@ -624,7 +624,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('closes a retained client page whose host is gone without commanding it', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const registry = new RuntimeBrowserPageRegistry() publishClientPage(registry, { browserPageId: 'page-client' }) const issueClientPageCommand = vi.fn() @@ -663,7 +663,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { }) it('does not fall back to a server page after explicit client placement fails', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const offscreenCreate = vi.fn() const send = vi.fn() const commands = new RuntimeBrowserCommands( diff --git a/src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts b/src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts index 9f995fc6e50..71a6ceee5ad 100644 --- a/src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts +++ b/src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' import { RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT } from './client-hosted-browser-page-persistence' import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' @@ -19,7 +19,7 @@ vi.mock('electron', () => ({ */ describe('browserTabClose on a session row with no runtime page', () => { it('retires the runtime-owned session row on a registry miss', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const retireRuntimeOwnedBrowserSessionTab = vi.fn(() => true) const commands = new RuntimeBrowserCommands(createHost({ retireRuntimeOwnedBrowserSessionTab })) @@ -30,7 +30,7 @@ describe('browserTabClose on a session row with no runtime page', () => { }) it('still fails closed when no session row names that page either', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const commands = new RuntimeBrowserCommands( createHost({ retireRuntimeOwnedBrowserSessionTab: vi.fn(() => false) }) ) @@ -41,7 +41,7 @@ describe('browserTabClose on a session row with no runtime page', () => { }) it('retires the row even on a runtime with no browser session at all', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const retireRuntimeOwnedBrowserSessionTab = vi.fn(() => true) const commands = new RuntimeBrowserCommands( createHost({ @@ -57,7 +57,7 @@ describe('browserTabClose on a session row with no runtime page', () => { }) it('closes a rehydrated row with no host to ask, and leaves nothing to restore', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const pages = new RuntimeBrowserPageRegistry() pages.publishClientPage({ browserPageId: 'page-restored', @@ -88,7 +88,7 @@ describe('browserTabClose on a session row with no runtime page', () => { }) it('never steals a close from a page the bridge still holds', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const retireRuntimeOwnedBrowserSessionTab = vi.fn(() => true) const closeTab = vi.fn(async () => {}) const commands = new RuntimeBrowserCommands( diff --git a/src/main/runtime/orca-runtime-browser-headless.test.ts b/src/main/runtime/orca-runtime-browser-headless.test.ts index d6bab924625..b837498d265 100644 --- a/src/main/runtime/orca-runtime-browser-headless.test.ts +++ b/src/main/runtime/orca-runtime-browser-headless.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' const { @@ -105,7 +105,7 @@ describe('RuntimeBrowserCommands headless close and forwarding', () => { }) it('closes a headless tab via the offscreen backend without a renderer round-trip', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) const closeTab = vi.fn(async () => {}) const retireRuntimeOwnedBrowserSessionTab = vi.fn() @@ -142,7 +142,7 @@ describe('RuntimeBrowserCommands headless close and forwarding', () => { }) it('closes the active headless tab on an implicit close', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) const closeTab = vi.fn(async () => {}) const bridge = { @@ -165,7 +165,7 @@ describe('RuntimeBrowserCommands headless close and forwarding', () => { }) it('reports not-closed when no headless tab can be resolved', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const closeTab = vi.fn(async () => {}) const bridge = { getRegisteredTabs: vi.fn(() => new Map()), @@ -187,7 +187,7 @@ describe('RuntimeBrowserCommands headless close and forwarding', () => { }) it('forwards an unresolved worktree to the bridge unchanged for keyboard inserttext', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const keyboardInsertText = vi.fn().mockResolvedValue({ inserted: true }) const bridge = { getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])), diff --git a/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts b/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts index 47eb2398929..700a7e28188 100644 --- a/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts +++ b/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts @@ -76,7 +76,7 @@ function connectLegacyChannel() { } async function subscribe(sendBinary: RpcBinarySender) { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') let resolveDone!: () => void const done = new Promise((resolve) => { resolveDone = resolve diff --git a/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts b/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts index abdfacac33c..ef675c967ea 100644 --- a/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts +++ b/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits' import { BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT } from './browser-screencast-ghost-subscriber-eviction' -import type { RuntimeBrowserCommands } from './orca-runtime-browser' +import type { RuntimeBrowserCommands } from './browser/browser-commands' import { createSinglePageBrowserCommandsHost, createStreamedGuest @@ -34,7 +34,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { }) it('restores the surviving subscriber viewport without stopping its shared stream', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const stop = vi.fn(() => done.resolve()) const updateViewport = vi.fn(async () => {}) @@ -83,7 +83,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { }) it('keeps viewport authority with sized subscribers when a sizeless viewer joins', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const stop = vi.fn(() => done.resolve()) const updateViewport = vi.fn(async () => {}) @@ -118,7 +118,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { }) it('replays the joiner snapshot that its pre-ready gate refused', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const snapshot = new Uint8Array([9, 9, 9]) let onFrame: (bytes: Uint8Array) => boolean | void = () => true @@ -177,7 +177,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { }) it('drives the shared stream at the most constrained subscriber and releases it on leave', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const updateFrameBudget = vi.fn(async () => {}) startBrowserScreencast.mockResolvedValue({ @@ -239,7 +239,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => { }) it('admits shared frames through the paired-runtime size guard', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() startBrowserScreencast.mockResolvedValue({ stop: vi.fn(() => done.resolve()), @@ -287,7 +287,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { } it('evicts a departed subscriber and hands its viewport to the survivor', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const stop = vi.fn(() => done.resolve()) const updateViewport = vi.fn(async () => {}) @@ -342,7 +342,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { }) it('stops the shared stream when the evicted subscriber was the last one', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const stop = vi.fn(() => done.resolve()) startBrowserScreencast.mockResolvedValue({ @@ -362,7 +362,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { }) it('keeps a subscriber whose refusals are broken by a delivery', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const stop = vi.fn(() => done.resolve()) startBrowserScreencast.mockResolvedValue({ @@ -390,7 +390,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { }) it('never evicts on the refusals a joiner pre-ready gate produced', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const stop = vi.fn(() => done.resolve()) startBrowserScreencast.mockResolvedValue({ @@ -422,7 +422,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { }) it('replaces the older subscription when the same paired device reattaches', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const stop = vi.fn(() => done.resolve()) const updateViewport = vi.fn(async () => {}) @@ -472,7 +472,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => { }) it('leaves other devices and unidentified callers attached', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const done = deferred() const stop = vi.fn(() => done.resolve()) startBrowserScreencast.mockResolvedValue({ diff --git a/src/main/runtime/orca-runtime-browser-screencast-pending-frame-retry.test.ts b/src/main/runtime/orca-runtime-browser-screencast-pending-frame-retry.test.ts index 2fbf9927e69..17bb79575b1 100644 --- a/src/main/runtime/orca-runtime-browser-screencast-pending-frame-retry.test.ts +++ b/src/main/runtime/orca-runtime-browser-screencast-pending-frame-retry.test.ts @@ -19,7 +19,7 @@ vi.mock('electron', () => ({ vi.mock('../browser/browser-screencast-stream', () => ({ startBrowserScreencast })) async function subscribe(sendBinary: RpcBinarySender) { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') let resolveDone!: () => void const done = new Promise((resolve) => { resolveDone = resolve diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index a2d9569650d..fa928d100db 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits' -import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' const { @@ -139,7 +139,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('creates profiles with the requested scope and label', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const profile = { id: 'profile-google', scope: 'isolated', @@ -157,7 +157,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('waits for explicit worktree browser registration after requesting a hidden mount', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const send = vi.fn() const bridge = { getRegisteredTabs: vi.fn(() => new Map()), @@ -178,7 +178,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('re-wakes an explicit worktree when the only registered browser tab is stale', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true }) const send = vi.fn() const bridge = { @@ -200,7 +200,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('waits for any browser registration after requesting a hidden mount without worktree scope', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const send = vi.fn() const bridge = { getRegisteredTabs: vi.fn(() => new Map()), @@ -221,7 +221,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('creates the first explicit-worktree browser tab without waiting for an existing registration', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const webContents = { send: vi.fn() } const send = vi.fn((channel: string, data: { requestId: string }) => { expect(channel).toBe('browser:requestTabCreate') @@ -280,7 +280,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('sends the resolved isolated profile partition when creating a renderer tab', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const webContents = { send: vi.fn() } const send = vi.fn((channel: string, data: { requestId: string }) => { expect(channel).toBe('browser:requestTabCreate') @@ -327,7 +327,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('returns the renderer page identity before navigation settles', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const navigation = deferred<{ title: string; url: string }>() const webContents = { send: vi.fn() } webContents.send = vi.fn((_channel: string, data: { requestId: string }) => { @@ -377,7 +377,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('rejects unknown explicit profile ids before requesting a renderer tab', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const send = vi.fn() const commands = new RuntimeBrowserCommands( createHost({ @@ -398,7 +398,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('sends the resolved partition when switching a renderer tab profile', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const webContents = { send: vi.fn() } const send = vi.fn((channel: string, data: { requestId: string }) => { expect(channel).toBe('browser:requestTabSetProfile') @@ -440,7 +440,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('wakes the requested page instead of the first worktree tab for page-scoped commands', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true }) const send = vi.fn() const snapshot = vi.fn(() => ({ @@ -480,7 +480,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('wakes the requested page before showing page-scoped tab metadata', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true }) const send = vi.fn() const bridge = { @@ -517,7 +517,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('wakes the requested page before showing page-scoped profile metadata', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true }) const send = vi.fn() const bridge = { @@ -557,7 +557,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('closes the requested renderer page without waiting for guest registration', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true }) const send = vi.fn((channel: string, data: { requestId?: string }) => { if (channel !== 'browser:requestTabClose') { @@ -594,7 +594,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('lets the renderer close an acknowledged page whose guest never registered', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const send = vi.fn((channel: string, data: { requestId?: string }) => { if (channel !== 'browser:requestTabClose') { return @@ -630,7 +630,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }) it('fans one page screencast out to multiple subscribers', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false, setBackgroundThrottling: vi.fn(), @@ -683,7 +683,7 @@ describe('RuntimeBrowserCommands browser screencast', () => { }, 10_000) it('admits screencast frames through the paired-runtime size guard', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false, setBackgroundThrottling: vi.fn(), @@ -725,7 +725,7 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => { }) it('routes tab creation to the offscreen backend when no renderer window exists', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const createTab = vi.fn(async () => ({ browserPageId: 'page-offscreen' })) const setActiveTab = vi.fn() const bridge = { @@ -755,7 +755,7 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => { }) it('publishes a headless session snapshot after explicit navigation', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) const notifyHeadlessBrowserSessionTabsChanged = vi.fn() const bridge = { @@ -783,7 +783,7 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => { }) it('rejects tab creation when neither a renderer nor an offscreen backend is available', async () => { - const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') const commands = new RuntimeBrowserCommands( createHost({ getAvailableAuthoritativeWindow: vi.fn(() => null), diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts deleted file mode 100644 index ab0868b1345..00000000000 --- a/src/main/runtime/orca-runtime-browser.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { RuntimeBrowserCommandsWithListLogicalBrowserTabs } from './runtime-browser-commands-list-logical-browser-tabs' - -export class RuntimeBrowserCommands extends RuntimeBrowserCommandsWithListLogicalBrowserTabs {} -export type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' -export type { RuntimeBrowserCommandHost } from './runtime-browser-commands-browser-command-target-params' diff --git a/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts b/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts index 3acab50ffd4..bebd42202b8 100644 --- a/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts @@ -2,7 +2,7 @@ import './setup.spec' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' -import { RuntimeBrowserCommands } from '../orca-runtime-browser' +import { RuntimeBrowserCommands } from '../browser/browser-commands' import { setRuntimeBrowserCommandsFactory, setRuntimeBrowserUnavailableCause diff --git a/src/main/runtime/runtime-browser-commands-browser-clear.ts b/src/main/runtime/runtime-browser-commands-browser-clear.ts deleted file mode 100644 index e7d2c36b543..00000000000 --- a/src/main/runtime/runtime-browser-commands-browser-clear.ts +++ /dev/null @@ -1,218 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserTabList } from './runtime-browser-commands-browser-tab-list' -import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' -import type { - BrowserCaptureStartResult, - BrowserCaptureStopResult, - BrowserClearResult, - BrowserConsoleResult, - BrowserCookieDeleteResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserGeolocationResult, - BrowserInterceptDisableResult, - BrowserInterceptEnableResult, - BrowserKeypressResult, - BrowserPdfResult, - BrowserScreenshotResult, - BrowserSelectAllResult, - BrowserViewportResult -} from '../../shared/runtime-types' - -export class RuntimeBrowserCommandsWithBrowserClear extends RuntimeBrowserCommandsWithBrowserTabList { - async browserClear( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clear( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelectAll( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().selectAll( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserKeypress( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keypress( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserPdf(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) - } - - async browserFullScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fullPageScreenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - // ── Cookie management ── - - async browserCookieGet( - params: { url?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieGet( - params.url, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieSet( - params: { - name: string - value: string - domain?: string - path?: string - secure?: boolean - httpOnly?: boolean - sameSite?: string - expires?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieSet( - params, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieDelete( - params: { - name: string - domain?: string - url?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieDelete( - params.name, - params.domain, - params.url, - target.worktreeId, - target.browserPageId - ) - } - - // ── Viewport ── - - async browserSetViewport( - params: { - width: number - height: number - deviceScaleFactor?: number - mobile?: boolean - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setViewport( - params.width, - params.height, - params.deviceScaleFactor, - params.mobile, - target.worktreeId, - target.browserPageId - ) - } - - // ── Geolocation ── - - async browserSetGeolocation( - params: { - latitude: number - longitude: number - accuracy?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setGeolocation( - params.latitude, - params.longitude, - params.accuracy, - target.worktreeId, - target.browserPageId - ) - } - - // ── Request interception ── - - async browserInterceptEnable( - params: { - patterns?: string[] - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptEnable( - params.patterns, - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptDisable( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptDisable( - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId) - } - - // ── Console/network capture ── - - async browserCaptureStart( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId) - } - - async browserCaptureStop(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId) - } - - async browserConsoleLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().consoleLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } -} diff --git a/src/main/runtime/runtime-browser-commands-browser-click.ts b/src/main/runtime/runtime-browser-commands-browser-click.ts deleted file mode 100644 index 619fce3aed0..00000000000 --- a/src/main/runtime/runtime-browser-commands-browser-click.ts +++ /dev/null @@ -1,176 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithActiveScreencastsByPageId } from './runtime-browser-commands-active-screencasts-by-page-id' -import type { - ActiveBrowserScreencastPage, - BrowserCommandTargetParams -} from './runtime-browser-commands-browser-command-target-params' -import { - applySharedScreencastFrameBudget, - hasScreencastViewportSize -} from './runtime-browser-commands-browser-command-target-params' -import type { - BrowserBackResult, - BrowserClickResult, - BrowserFillResult, - BrowserGotoResult, - BrowserReloadResult, - BrowserScreenshotResult, - BrowserScrollResult, - BrowserSelectResult, - BrowserTypeResult -} from '../../shared/runtime-types' -import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' -import { cancelScreencastSubscriberFrameRetry } from './browser-screencast-subscriber-frame-delivery' - -export class RuntimeBrowserCommandsWithBrowserClick extends RuntimeBrowserCommandsWithActiveScreencastsByPageId { - async browserClick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) - // Why: clicks can trigger navigation, so push the tab's live URL/title to the renderer even when automation targeted a non-active page. - const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) - if (page) { - this.notifyRendererNavigation(page.browserPageId, page.url, page.title) - } - return result - } - - async browserGoto( - params: { url: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - if (!this.host.getAvailableAuthoritativeWindow() && target.worktreeId) { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(target.worktreeId) - } - return result - } - - async browserFill( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fill( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserType( - params: { input: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().type( - params.input, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelect( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().select( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserScroll( - params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scroll( - params.direction, - params.amount, - target.worktreeId, - target.browserPageId - ) - } - - async browserBack(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.back(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserReload(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.reload(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().screenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - // The single leave path: an explicit stop, a ghost eviction and a same-device replacement all - // unwind through here, so viewport hand-off, budget release and stream teardown cannot drift. - private leaveScreencastSubscriber( - active: ActiveBrowserScreencastPage, - subscriptionId: string, - session: BrowserScreencastSession - ): void { - const subscriber = active.subscribers.get(subscriptionId) - if (!subscriber) { - return - } - active.subscribers.delete(subscriptionId) - cancelScreencastSubscriberFrameRetry(subscriber) - subscriber.resolveDone() - if (active.viewportOwnerSubscriptionId === subscriptionId) { - const fallback = Array.from(active.subscribers.entries()).findLast(([, candidate]) => - hasScreencastViewportSize(candidate.viewport) - ) - active.viewportOwnerSubscriptionId = fallback?.[0] ?? null - if (fallback) { - void session.updateViewport(fallback[1].viewport).catch(() => {}) - } - } - if (active.subscribers.size === 0) { - active.stopping = true - session.stop() - return - } - // Why: a departed subscriber's caps would otherwise pin the shared stream for - // the rest of its life, long after the client that asked for them is gone. - void applySharedScreencastFrameBudget(active, session).catch(() => {}) - } -} diff --git a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts deleted file mode 100644 index c85f6ddc176..00000000000 --- a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts +++ /dev/null @@ -1,193 +0,0 @@ -// @ts-nocheck -- mechanically split declarations. -import type { BrowserScreencastResult } from '../../shared/runtime-types' -import type { - BrowserScreencastFrameBudget, - BrowserScreencastSession, - BrowserScreencastViewport -} from '../browser/browser-screencast-stream-types' -import type { ScreencastSubscriberDeliveryState } from './browser-screencast-ghost-subscriber-eviction' -import { - browserScreencastFrameBudgetsEqual, - mergeBrowserScreencastFrameBudgets -} from '../browser/browser-screencast-frame-budget' -import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { ExecutionHostId } from '../../shared/execution-host' -import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol' -import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry' -import type { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' -import type { BrowserWindow } from 'electron' -import type { BrowserBackend } from '../browser/browser-backend' -import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication' -import type { RpcBinarySender } from './rpc/rpc-binary-sender' - -export type BrowserCommandTargetParams = { - worktree?: string - page?: string -} - -export type ResolvedBrowserCommandTarget = { - worktreeId?: string - browserPageId?: string -} - -export type ResolvedBrowserPageWebContents = { - browserPageId: string - webContents: Electron.WebContents -} - -export type BrowserScreencastParams = { - format: 'jpeg' | 'png' - quality?: number - maxWidth?: number - maxHeight?: number - viewportWidth?: number - viewportHeight?: number - deviceScaleFactor?: number - mobile?: boolean - everyNthFrame?: number - minFrameIntervalMs?: number -} & BrowserCommandTargetParams - -export type BrowserScreencastStartResult = { - subscriptionId: string - ready: Extract - // The frame budget and the page's dialog belong to the shared page, not to one subscriber's handle. - session: Omit - // Why: callers gate frames until they have emitted `ready`, and the snapshot captured - // for a joining subscriber lands inside that window. This replays it once the gate opens. - flushPendingFrame: () => void -} - -export type ActiveBrowserScreencastSubscriber = { - sendBinary: RpcBinarySender - emit?: (event: BrowserScreencastResult) => void - done: Promise - resolveDone: () => void - viewport: BrowserScreencastViewport - budget: BrowserScreencastFrameBudget - pendingFrame: Uint8Array | null - pendingFrameRetry: ReturnType | null - // Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new - // socket never reuses the old id, so a reconnecting device would stack a second subscription. - pairedDeviceId?: string - delivery: ScreencastSubscriberDeliveryState -} - -export type ActiveBrowserScreencastPage = { - format: 'jpeg' | 'png' - session: BrowserScreencastSession | null - started: Promise - stopping: boolean - subscribers: Map - viewportOwnerSubscriptionId: string | null - appliedBudget: BrowserScreencastFrameBudget -} - -export async function applySharedScreencastFrameBudget( - active: ActiveBrowserScreencastPage, - session: BrowserScreencastSession -): Promise { - const merged = mergeBrowserScreencastFrameBudgets( - Array.from(active.subscribers.values(), (subscriber) => subscriber.budget) - ) - if (!merged || browserScreencastFrameBudgetsEqual(merged, active.appliedBudget)) { - return - } - active.appliedBudget = merged - await session.updateFrameBudget(merged) -} - -export function normalizeScreencastViewport( - params: BrowserScreencastParams -): BrowserScreencastViewport { - return { - viewportWidth: clampOptionalInteger(params.viewportWidth, 320, 3840), - viewportHeight: clampOptionalInteger(params.viewportHeight, 240, 2160), - deviceScaleFactor: clampOptionalNumber(params.deviceScaleFactor, 1, 4), - mobile: params.mobile === true - } -} - -export function normalizeScreencastFrameBudget( - params: BrowserScreencastParams -): BrowserScreencastFrameBudget { - return { - quality: clampInteger(params.quality, 10, 100, 70), - maxWidth: clampInteger(params.maxWidth, 320, 3840, 1440), - maxHeight: clampInteger(params.maxHeight, 240, 2160, 1200), - everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2), - minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0) - } -} - -export function hasScreencastViewportSize(viewport: BrowserScreencastViewport): boolean { - return viewport.viewportWidth !== undefined && viewport.viewportHeight !== undefined -} - -export function clampInteger( - value: number | undefined, - min: number, - max: number, - fallback: number -): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return fallback - } - return Math.min(max, Math.max(min, Math.round(value))) -} - -export function clampOptionalInteger( - value: number | undefined, - min: number, - max: number -): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.min(max, Math.max(min, Math.round(value))) -} - -export function clampOptionalNumber( - value: number | undefined, - min: number, - max: number -): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.min(max, Math.max(min, value)) -} - -export type RuntimeBrowserCommandHost = { - getAgentBrowserBridge(): AgentBrowserBridge | null - resolveWorktreeSelector(selector: string): Promise<{ - id: string - repoId?: string - hostId?: ExecutionHostId - }> - resolveBrowserWorkspace(selector: string): Promise<{ - id: string - repoId?: string - hostId?: ExecutionHostId - }> - resolveBrowserNetworkExecutionHost(worktree?: { - id: string - repoId?: string - hostId?: ExecutionHostId - }): BrowserNetworkExecutionHost | Promise - getBrowserHostLeaseRegistry(): BrowserHostLeaseRegistry - getRuntimeBrowserPageRegistry(): RuntimeBrowserPageRegistry - getAuthoritativeWindow(): BrowserWindow - getAvailableAuthoritativeWindow(): BrowserWindow | null - // Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing. - getOffscreenBrowserBackend(): BrowserBackend | null - // Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal. - markHeadlessBrowserSessionTabActive?( - worktreeId: string | undefined, - browserPageId: string, - options?: BrowserSessionTabSelectionOptions - ): void - notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void - /** True when a runtime-owned session row for that page existed and was retired. */ - retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void -} diff --git a/src/main/runtime/runtime-browser-commands-browser-network-log.ts b/src/main/runtime/runtime-browser-commands-browser-network-log.ts deleted file mode 100644 index b89dfc6b99c..00000000000 --- a/src/main/runtime/runtime-browser-commands-browser-network-log.ts +++ /dev/null @@ -1,200 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserClear } from './runtime-browser-commands-browser-clear' -import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' -import { clampOptionalNumber } from './runtime-browser-commands-browser-command-target-params' -import type { BrowserNetworkLogResult } from '../../shared/runtime-types' - -export class RuntimeBrowserCommandsWithBrowserNetworkLog extends RuntimeBrowserCommandsWithBrowserClear { - async browserNetworkLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().networkLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - // ── Additional core commands ── - - async browserDblclick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dblclick( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserForward(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) - } - - async browserScrollIntoView( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scrollIntoView( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserGet( - params: { - what: string - selector?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().get( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - async browserIs( - params: { what: string; selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().is( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── Keyboard insert text ── - - async browserKeyboardInsertText( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keyboardInsertText( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Mouse commands ── - - async browserMouseMove( - params: { x: number; y: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseMove( - params.x, - params.y, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseDown( - params: { button?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseDown( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseClick( - params: { - x: number - y: number - button?: string - radius?: number - modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[] - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseClick( - params.x, - params.y, - params.button, - target.worktreeId, - target.browserPageId, - clampOptionalNumber(params.radius, 0, 64), - params.modifiers - ) - } - - async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseUp( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseWheel( - params: { - dy: number - dx?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseWheel( - params.dy, - params.dx, - target.worktreeId, - target.browserPageId - ) - } - - // ── Find (semantic locators) ── - - async browserFind( - params: { - locator: string - value: string - action: string - text?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().find( - params.locator, - params.value, - params.action, - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Set commands ── - - async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setDevice( - params.name, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetOffline( - params: { state?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setOffline( - params.state, - target.worktreeId, - target.browserPageId - ) - } -} diff --git a/src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts b/src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts deleted file mode 100644 index 585af8cd756..00000000000 --- a/src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts +++ /dev/null @@ -1,70 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserTabSetProfile } from './runtime-browser-commands-browser-tab-set-profile' -import type { - BrowserProfileClearDefaultCookiesResult, - BrowserProfileImportFromBrowserResult -} from '../../shared/runtime-types' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { - detectInstalledBrowsers, - importCookiesFromBrowser, - selectBrowserProfile -} from '../browser/browser-cookie-import' - -export class RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser extends RuntimeBrowserCommandsWithBrowserTabSetProfile { - async browserProfileImportFromBrowser(params: { - profileId: string - browserFamily: string - browserProfile?: string - supportsPartitionSkippedCookies?: true - }): Promise { - const profile = browserSessionRegistry.getProfile(params.profileId) - if (!profile) { - return { ok: false, reason: 'Session profile not found.' } - } - if ( - params.browserProfile && - (/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..')) - ) { - return { ok: false, reason: 'Invalid browser profile name.' } - } - - const browsers = detectInstalledBrowsers() - let browser = browsers.find((candidate) => candidate.family === params.browserFamily) - if (!browser) { - return { ok: false, reason: 'Browser not found on this system.' } - } - - if (params.browserProfile && params.browserProfile !== browser.selectedProfile) { - const reselected = selectBrowserProfile(browser, params.browserProfile) - if (!reselected) { - return { - ok: false, - reason: `No cookies database found for profile "${params.browserProfile}".` - } - } - browser = reselected - } - - const result = await importCookiesFromBrowser(browser, profile.partition, { - canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true - }) - if (!result.ok) { - return result - } - - const profileName = - browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ?? - browser.selectedProfile - browserSessionRegistry.updateProfileSource(params.profileId, { - browserFamily: browser.family, - profileName, - importedAt: Date.now() - }) - return { ...result, profileId: params.profileId } - } - - async browserProfileClearDefaultCookies(): Promise { - return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() } - } -} diff --git a/src/main/runtime/runtime-browser-commands-browser-set-headers.ts b/src/main/runtime/runtime-browser-commands-browser-set-headers.ts deleted file mode 100644 index 251d29e900c..00000000000 --- a/src/main/runtime/runtime-browser-commands-browser-set-headers.ts +++ /dev/null @@ -1,217 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeBrowserCommandsWithBrowserNetworkLog } from './runtime-browser-commands-browser-network-log' -import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' -import { - browserDialogSettledResult, - settleBrowserDialogOnLiveScreencast, - type BrowserDialogResult -} from './browser-dialog-settlement' - -export class RuntimeBrowserCommandsWithBrowserSetHeaders extends RuntimeBrowserCommandsWithBrowserNetworkLog { - async browserSetHeaders( - params: { headers: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setHeaders( - params.headers, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetCredentials( - params: { - user: string - pass: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setCredentials( - params.user, - params.pass, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetMedia( - params: { - colorScheme?: string - reducedMotion?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setMedia( - params.colorScheme, - params.reducedMotion, - target.worktreeId, - target.browserPageId - ) - } - - // ── Clipboard commands ── - - async browserClipboardRead(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId) - } - - async browserClipboardWrite( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardWrite( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Dialog commands ── - - async browserDialogAccept( - params: { text?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - if ( - !(await settleBrowserDialogOnLiveScreencast( - this.activeScreencastsByPageId, - target.browserPageId, - true, - params.text - )) - ) { - await this.requireAgentBrowserBridge().dialogAccept( - params.text, - target.worktreeId, - target.browserPageId - ) - } - return browserDialogSettledResult(true) - } - - async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - if ( - !(await settleBrowserDialogOnLiveScreencast( - this.activeScreencastsByPageId, - target.browserPageId, - false - )) - ) { - await this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) - } - return browserDialogSettledResult(false) - } - - // ── Storage commands ── - - async browserStorageLocalGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalClear( - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionClear( - target.worktreeId, - target.browserPageId - ) - } - - // ── Download command ── - - async browserDownload( - params: { - selector: string - path: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().download( - params.selector, - params.path, - target.worktreeId, - target.browserPageId - ) - } - - // ── Highlight command ── - - async browserHighlight( - params: { selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().highlight( - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── New: exec passthrough + tab lifecycle ── - - async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().exec( - params.command, - target.worktreeId, - target.browserPageId - ) - } -} diff --git a/src/main/runtime/runtime-browser-commands-factory.ts b/src/main/runtime/runtime-browser-commands-factory.ts index 158cb1bab0e..482336d6cdc 100644 --- a/src/main/runtime/runtime-browser-commands-factory.ts +++ b/src/main/runtime/runtime-browser-commands-factory.ts @@ -3,12 +3,13 @@ import { BROWSER_UNAVAILABLE_ERROR_CODE, type RuntimeBrowserUnavailableReason } from '../../shared/runtime-types' -import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' +import type { RuntimeBrowserCommands } from './browser/browser-commands' /** * How `OrcaRuntimeService` obtains its browser-automation commands. * - * Why a factory rather than a direct import: `orca-runtime-browser.ts` reaches the + * Why a factory rather than a direct import: `browser/browser-commands.ts` reaches the * whole Chromium cluster — `BrowserWindow`, `session`, `webContents`, cookie jars — * 15 modules that a Node host cannot load at all. Importing the class for its *type* * is free; constructing it is what drags the cluster in. diff --git a/src/main/runtime/runtime-browser-commands-state.ts b/src/main/runtime/runtime-browser-commands-state.ts deleted file mode 100644 index dde716c6f5e..00000000000 --- a/src/main/runtime/runtime-browser-commands-state.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params' - -export class RuntimeBrowserCommandsState { - protected readonly activeScreencastsByPageId = new Map() -} diff --git a/src/main/runtime/runtime-browser-screencast-controller.ts b/src/main/runtime/runtime-browser-screencast-controller.ts index 05c265cf9dd..28c43b3aeb5 100644 --- a/src/main/runtime/runtime-browser-screencast-controller.ts +++ b/src/main/runtime/runtime-browser-screencast-controller.ts @@ -5,11 +5,11 @@ import { screencastSubscriberDrivesAsMobile, type BrowserScreencastSubscriber } from './browser-screencast-driver-scope' -import type { RuntimeBrowserCommands } from './orca-runtime-browser' +import type { RuntimeBrowserCommands } from './browser/browser-commands' import type { RpcBinarySender } from './rpc/rpc-binary-sender' type RuntimeBrowserScreencastControllerDeps = { - getCommands: () => RuntimeBrowserCommands + getCommands: () => Pick registerSubscriptionCleanup: ( subscriptionId: string, cleanup: () => void | Promise, diff --git a/src/main/runtime/runtime-edge-command-controller.ts b/src/main/runtime/runtime-edge-command-controller.ts index 031af2452af..5baa3ddf3a6 100644 --- a/src/main/runtime/runtime-edge-command-controller.ts +++ b/src/main/runtime/runtime-edge-command-controller.ts @@ -1,5 +1,6 @@ import type { BrowserScreencastResult } from '../../shared/runtime-types' -import type { RuntimeBrowserCommands, RuntimeBrowserCommandHost } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' +import type { RuntimeBrowserCommands } from './browser/browser-commands' import { RuntimeEmulatorCommands } from './orca-runtime-emulator' import { RuntimeBrowserScreencastController } from './runtime-browser-screencast-controller' import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory' diff --git a/src/main/runtime/single-page-browser-commands-host-test-double.ts b/src/main/runtime/single-page-browser-commands-host-test-double.ts index b9a63cf7f77..59325cda959 100644 --- a/src/main/runtime/single-page-browser-commands-host-test-double.ts +++ b/src/main/runtime/single-page-browser-commands-host-test-double.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' +import type { RuntimeBrowserCommandHost } from './browser/browser-command-contracts' import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { RendererPublicationThrottleTarget } from '../window/renderer-publication-throttle'