diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 9a217768bca2..aff72d372300 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -15,7 +15,8 @@ import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' it.each([ 'src/main/runtime/runtime-browser-commands-factory.ts', 'src/main/runtime/orca-runtime-get-status.ts', - 'src/main/orcad/orcad-browser-startup.ts' + 'src/main/orcad/orcad-browser-startup.ts', + 'src/main/runtime/browser/browser-client-page-opening.ts' ])('routes the managed browser capability oracle from %s', (path) => { expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-browser-capabilities.spec.ts') }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index f6ef58c1f8d6..56177c370867 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -151,7 +151,7 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/ssh-orcad-browser-capabilities.spec.ts'], matches: (file) => isProductSource(file) && - /^src\/main\/(?:runtime\/(?:runtime-browser-commands-factory|orca-runtime-get-status)|host\/electron-browser-commands|orcad\/orcad-browser-)/.test( + /^src\/main\/(?:runtime\/(?:runtime-browser-commands-factory|orca-runtime-get-status|browser\/browser-client-page-opening)|host\/electron-browser-commands|orcad\/orcad-browser-)/.test( file ) }, diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index e4449dc48649..218d886fab60 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import type * as RunProcessModule from '@orca/process-host' import type * as FsPromisesModule from 'node:fs/promises' import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import { RuntimeBrowserPageRegistry } from '../runtime/runtime-browser-page-registry' const { spawnProcessMock, lockedRemovalPaths } = vi.hoisted(() => ({ spawnProcessMock: @@ -496,7 +497,11 @@ it('forwards tab commands through startup to the Electron sidecar', async () => const startup = startOrcadBrowserProvider({ userDataPath: harnessRoot }) await startup.ready try { - const commands = createRuntimeBrowserCommands(host) + // The runtime that composes the provider owns a page registry, which startup consults. + const commands = createRuntimeBrowserCommands({ + ...host, + getRuntimeBrowserPageRegistry: () => new RuntimeBrowserPageRegistry() + }) await commands.browserTabCreate({ worktree: 'wt-a' }) await commands.browserTabList({ worktree: 'wt-a' }) expect((await browserRequests()).map((request) => request.method)).toEqual([ diff --git a/src/main/orcad/external-chromium-browser-process.test.ts b/src/main/orcad/external-chromium-browser-process.test.ts index 9c618217f3d9..08bd93be4b83 100644 --- a/src/main/orcad/external-chromium-browser-process.test.ts +++ b/src/main/orcad/external-chromium-browser-process.test.ts @@ -31,6 +31,7 @@ import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import { RuntimeBrowserPageRegistry } from '../runtime/runtime-browser-page-registry' vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() })) afterEach(() => setRuntimeBrowserCommandsFactory(null)) @@ -75,7 +76,10 @@ it('forwards tab commands through startup to the Chromium provider', async () => const startup = startOrcadBrowserProvider({ userDataPath: '/state' }) await startup.ready try { - const host: RuntimeBrowserCommandHost = Object.create(null) + // The runtime that composes the provider owns a page registry, which startup consults. + const host: RuntimeBrowserCommandHost = Object.assign(Object.create(null), { + getRuntimeBrowserPageRegistry: () => new RuntimeBrowserPageRegistry() + }) const commands = createRuntimeBrowserCommands(host) const params = { worktree: 'wt-a' } createTab.mockResolvedValueOnce({ browserPageId: 'page-a' }) diff --git a/src/main/orcad/orcad-browser-startup.test.ts b/src/main/orcad/orcad-browser-startup.test.ts index beef84722a33..91d9d49fbd39 100644 --- a/src/main/orcad/orcad-browser-startup.test.ts +++ b/src/main/orcad/orcad-browser-startup.test.ts @@ -20,7 +20,15 @@ afterEach(() => { setRuntimeBrowserUnavailableCause(null) }) -const host: RuntimeBrowserCommandHost = Object.create(null) +// No desktop hosts pages for this runtime, so only the engine can answer. +const host: RuntimeBrowserCommandHost = Object.assign(Object.create(null), { + getRuntimeBrowserPageRegistry: () => new RuntimeBrowserPageRegistry(), + getBrowserHostLeaseRegistry: () => ({ + select: () => { + throw new Error('browser_host_unavailable') + } + }) +}) function delayedProvider() { const pending = Promise.withResolvers() vi.mocked(resolveOrcadBrowserProvider).mockReturnValueOnce(pending.promise) diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts index 8e846be714f7..5e2c13ded645 100644 --- a/src/main/orcad/orcad-browser-startup.ts +++ b/src/main/orcad/orcad-browser-startup.ts @@ -5,15 +5,12 @@ import { browserUnavailableError, setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { - closeBrowserPageWithoutEngine, - closeRegisteredBrowserClientPage -} from '../runtime/browser/browser-client-page-close' import { resolveOrcadBrowserProvider, type OrcadBrowserProvider, type OrcadBrowserProviderOptions } from './orcad-browser-provider' +import { orcadClientPageCommand } from './orcad-client-page-commands' /** Browser discovery must not hold core RPC readiness hostage to a desktop authorization UI. */ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): { @@ -24,6 +21,7 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): let provider: OrcadBrowserProvider | null = null let startupError: unknown let stopping: Promise | undefined + const engineAvailable = (): boolean => !controller.signal.aborted && !!provider?.isAvailable() setRuntimeBrowserCommandsFactory( (host) => { let commands: RuntimeBrowserCommands | undefined @@ -34,42 +32,36 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): if (property === 'then' || typeof property !== 'string') { return undefined } - const forward = (args: unknown[]): unknown => { - if (controller.signal.aborted || !provider?.isAvailable()) { + const forward = (args: unknown[], name: string = property): unknown => { + if (!engineAvailable() || !provider) { throw browserUnavailableError() } commands ??= provider.factory(host) - return callBrowserCommand(commands, property, args) - } - if (property === 'browserTabClose') { - // Why: client-hosted pages live in this runtime's registry, never in the provider. - return async (...args: Parameters) => { - const params = args[0] ?? {} - if (controller.signal.aborted || !provider?.isAvailable()) { - return closeBrowserPageWithoutEngine(host, params, browserUnavailableError) - } - return (await closeRegisteredBrowserClientPage(host, params)) ?? forward(args) - } + return callBrowserCommand(commands, name, args) } - return (...args: unknown[]) => forward(args) + return ( + orcadClientPageCommand(host, property, forward, engineAvailable) ?? + ((...args: unknown[]) => forward(args)) + ) } }) }, - { headless: true, isAvailable: () => !controller.signal.aborted && !!provider?.isAvailable() } + { + headless: true, + clientHostingWithoutEngine: true, + isAvailable: engineAvailable + } ) const ready = Promise.resolve() .then(() => resolveOrcadBrowserProvider({ ...options, signal: controller.signal })) .then( + // Why the factory stays installed without a provider: it still serves desktop-hosted pages. (resolved) => { provider = resolved - if (!resolved && !controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) - } }, (error: unknown) => { startupError = error if (!controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) console.warn('[orcad] Browser startup failed:', error) } } diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts new file mode 100644 index 000000000000..03e0b3b64baa --- /dev/null +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -0,0 +1,336 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { startOrcadBrowserProvider } from './orcad-browser-startup' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' +import { + createRuntimeBrowserCommands, + runtimeBrowserCommandsFactoryIsAvailable, + runtimeBrowserCommandsFactorySupportsClientHosting, + runtimeBrowserUnavailableCause, + setRuntimeBrowserCommandsFactory, + setRuntimeBrowserUnavailableCause +} from '../runtime/runtime-browser-commands-factory' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import { RuntimeBrowserPageRegistry } from '../runtime/runtime-browser-page-registry' + +vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() })) +afterEach(() => { + vi.restoreAllMocks() + setRuntimeBrowserCommandsFactory(null) + setRuntimeBrowserUnavailableCause(null) +}) + +const PLACEMENT = { + kind: 'client' as const, + browserHostClientId: 'desktop-a', + browserHostGeneration: 2, + pageHostGeneration: 5 +} + +/** orcad as `orca serve` runs it with no browser engine: discovery declines, unconfigured. */ +async function startWithoutEngine() { + vi.mocked(resolveOrcadBrowserProvider).mockImplementationOnce(async () => { + setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' }) + return null + }) + const startup = startOrcadBrowserProvider({ userDataPath: '/private-fixture' }) + await startup.ready + return startup +} + +function pairedHost(options: { desktopAttached: boolean }) { + const pages = new RuntimeBrowserPageRegistry() + const select = vi.fn(() => { + if (!options.desktopAttached) { + throw new Error('browser_host_unavailable') + } + return { browserHostClientId: 'desktop-a', pairedDeviceId: 'device-a' } + }) + const createClientPage = vi.fn(async () => PLACEMENT) + const issueClientPageCommand = vi.fn(() => ({ + result: Promise.resolve({ status: 'completed' as const }) + })) + const notifyHeadlessBrowserSessionTabsChanged = vi.fn() + const host = { + getAgentBrowserBridge: () => null, + resolveBrowserWorkspace: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), + resolveBrowserNetworkExecutionHost: async () => ({ + kind: 'native' as const, + runtimeId: 'orcad-a', + revision: 1 + }), + getRuntimeBrowserPageRegistry: () => pages, + getBrowserHostLeaseRegistry: () => ({ + authorityRuntimeId: 'orcad-a', + authorityEpoch: 'epoch-a', + select, + createClientPage, + issueClientPageCommand, + // The desktop already let go of the page, so retiring it is only the runtime's record. + getPlacement: () => undefined + }), + markHeadlessBrowserSessionTabActive: vi.fn(), + notifyHeadlessBrowserSessionTabsChanged + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every member the client-page path reads; the rest of the host is engine-only. + const commandHost = host as unknown as RuntimeBrowserCommandHost + return { host: commandHost, pages, select, createClientPage, issueClientPageCommand } +} + +it('opens open-url in the paired desktop when orcad has no browser engine', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + expect(paired.select).toHaveBeenCalledWith(undefined, ['webview', 'automation-v1']) + expect(paired.createClientPage).toHaveBeenCalledWith( + expect.objectContaining({ + browserPageId: opened.browserPageId, + browserHostClientId: 'desktop-a', + pairedDeviceId: 'device-a', + browserProfileId: 'default', + workspaceId: 'wt-1' + }) + ) + expect(paired.issueClientPageCommand).toHaveBeenCalledWith( + expect.objectContaining({ browserPageId: opened.browserPageId }), + { type: 'navigate', url: 'https://example.org/' } + ) + expect(paired.pages.getPage(opened.browserPageId)).toMatchObject({ + workspaceId: 'wt-1', + url: 'https://example.org/', + active: true, + placement: PLACEMENT + }) + await startup.stop() +}) + +it('refuses open-url with what to do when no paired desktop hosts this server’s pages', async () => { + const startup = await startWithoutEngine() + const commands = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) + + await expect( + commands.browserOpenUrlOnClient({ url: 'https://example.org/', worktree: 'id:wt-1' }) + ).rejects.toMatchObject({ + code: 'browser_host_unavailable', + message: expect.stringContaining("open a browser tab in one of this server's workspaces") + }) + await startup.stop() +}) + +it('creates a client-placed tab without an engine, and only in the default profile', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const placement = { kind: 'client' as const, browserHostClientId: 'desktop-a' } + + await expect( + commands.browserTabCreate( + { worktree: 'id:wt-1', page: 'page-a', url: 'https://example.org/', placement }, + { pairedDeviceId: 'device-a', clientKind: 'runtime' } + ) + ).resolves.toEqual({ browserPageId: 'page-a' }) + expect(paired.pages.getPage('page-a')?.workspaceId).toBe('wt-1') + + await expect( + commands.browserTabCreate( + { worktree: 'id:wt-1', profileId: 'work', placement }, + { pairedDeviceId: 'device-a', clientKind: 'runtime' } + ) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + await startup.stop() +}) + +it('sends the CLI’s unplaced tab to the paired desktop, and refuses as before when none is attached', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const created = await commands.browserTabCreate({ + worktree: 'id:wt-1', + url: 'https://example.org/' + }) + expect(paired.pages.getPage(created.browserPageId)?.placement).toEqual(PLACEMENT) + // A paired client that left placement out asked for a server page, which this host cannot make. + expect(() => + commands.browserTabCreate({ worktree: 'id:wt-1' }, { pairedDeviceId: 'device-a' }) + ).toThrow(/unavailable/) + + const unattached = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) + expect(() => unattached.browserTabCreate({ worktree: 'id:wt-1' })).toThrow(/unavailable/) + await startup.stop() +}) + +it('lists, shows and switches desktop-hosted tabs with no engine', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const first = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/a', + worktree: 'id:wt-1' + }) + const second = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/b', + worktree: 'id:wt-1' + }) + + const listed = await commands.browserTabList({ worktree: 'id:wt-1' }) + expect(listed.tabs.map((tab) => [tab.browserPageId, tab.url, tab.profileId])).toEqual([ + [first.browserPageId, 'https://example.org/a', 'default'], + [second.browserPageId, 'https://example.org/b', 'default'] + ]) + await expect(commands.browserTabSwitch({ page: first.browserPageId })).resolves.toEqual({ + switched: 0, + browserPageId: first.browserPageId + }) + await expect(commands.browserTabCurrent({ worktree: 'id:wt-1' })).resolves.toMatchObject({ + tab: { browserPageId: first.browserPageId } + }) + // Unscoped automation never routes to a client page, so neither does an unscoped current. + await expect(commands.browserTabCurrent({})).rejects.toMatchObject({ code: 'browser_no_tab' }) + await expect(commands.browserTabShow({ page: second.browserPageId })).resolves.toMatchObject({ + tab: { url: 'https://example.org/b' } + }) + // With the current page closed none is selected, exactly as workspace automation sees it. + await commands.browserTabClose({ worktree: 'id:wt-1' }) + expect(paired.pages.getPage(first.browserPageId)).toBeUndefined() + await expect(commands.browserTabCurrent({ worktree: 'id:wt-1' })).rejects.toMatchObject({ + code: 'browser_no_tab' + }) + await startup.stop() +}) + +it('still refuses tab queries when neither an engine nor a desktop can serve them', async () => { + const startup = await startWithoutEngine() + const commands = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) + expect(() => commands.browserTabList({})).toThrow(/unavailable/) + await startup.stop() +}) + +it('advertises client hosting with no engine and keeps the engine’s own cause', async () => { + const startup = await startWithoutEngine() + + expect(runtimeBrowserCommandsFactoryIsAvailable()).toBe(false) + expect(runtimeBrowserCommandsFactorySupportsClientHosting()).toBe(true) + expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'unconfigured' }) + await startup.stop() +}) + +/** orcad with a working engine that holds one tab of its own. */ +async function startWithEngine() { + const engineTab = { browserPageId: 'engine-page', index: 0, url: '', title: '', active: true } + const engine = { + browserTabList: vi.fn(async () => ({ tabs: [engineTab] })), + browserTabCurrent: vi.fn(async () => ({ tab: engineTab })), + browserTabCreate: vi.fn(async () => ({ browserPageId: 'engine-page' })), + browserTabSwitch: vi.fn(async () => ({ switched: 0, browserPageId: 'engine-page' })), + browserTabClose: vi.fn(async () => ({ closed: true })) + } + vi.mocked(resolveOrcadBrowserProvider).mockResolvedValueOnce({ + kind: 'chromium', + factory: () => Object.assign(Object.create(null), engine), + isAvailable: () => true, + stop: async () => {} + }) + const startup = startOrcadBrowserProvider({ userDataPath: '/private-fixture' }) + await startup.ready + return { startup, engine } +} + +it('closes the current desktop-hosted page itself instead of the engine’s current tab', async () => { + const { startup, engine } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + await expect(commands.browserTabClose({ worktree: 'id:wt-1' })).resolves.toEqual({ closed: true }) + expect(paired.pages.getPage(opened.browserPageId)).toBeUndefined() + expect(engine.browserTabClose).not.toHaveBeenCalled() + // With the page gone, the engine's own current tab is the target again. + await commands.browserTabClose({ worktree: 'id:wt-1' }) + expect(engine.browserTabClose).toHaveBeenCalledWith({ worktree: 'id:wt-1' }) + await startup.stop() +}) + +it('lists desktop-hosted pages after the engine’s tabs, as automation would pick them', async () => { + const { startup } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + const listed = await commands.browserTabList({ worktree: 'id:wt-1' }) + expect(listed.tabs.map((tab) => [tab.browserPageId, tab.index, tab.active])).toEqual([ + ['engine-page', 0, false], + [opened.browserPageId, 1, true] + ]) + await expect(commands.browserTabShow({ page: opened.browserPageId })).resolves.toMatchObject({ + tab: { browserPageId: opened.browserPageId, index: 1 } + }) + await startup.stop() +}) + +it('keeps the engine’s own per-workspace current tab where no desktop page is selected', async () => { + const { startup, engine } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + await commands.browserOpenUrlOnClient({ url: 'https://example.org/', worktree: 'id:wt-c' }) + + await expect(commands.browserTabCurrent({ worktree: 'id:wt-a' })).resolves.toMatchObject({ + tab: { browserPageId: 'engine-page' } + }) + expect(engine.browserTabCurrent).toHaveBeenCalledWith({ worktree: 'id:wt-a' }) + await startup.stop() +}) + +it('hands workspace commands back to the engine once it creates or selects a tab', async () => { + const { startup, engine } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const isActive = (browserPageId: string): boolean | undefined => + paired.pages.getPage(browserPageId)?.active + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + await commands.browserTabCreate({ worktree: 'id:wt-1', url: 'https://engine.test/' }) + expect(engine.browserTabCreate).toHaveBeenCalled() + expect(isActive(opened.browserPageId)).toBe(false) + + await commands.browserTabSwitch({ page: opened.browserPageId, worktree: 'id:wt-1' }) + expect(isActive(opened.browserPageId)).toBe(true) + // A switch by page alone: the engine names no workspace, so every client page steps back. + await expect(commands.browserTabSwitch({ page: 'engine-page' })).resolves.toMatchObject({ + browserPageId: 'engine-page' + }) + expect(engine.browserTabSwitch).toHaveBeenCalledWith({ page: 'engine-page' }) + expect(isActive(opened.browserPageId)).toBe(false) + await startup.stop() +}) + +it('closes a desktop-hosted page by index with no engine', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + await commands.browserOpenUrlOnClient({ url: 'https://example.org/a', worktree: 'id:wt-1' }) + const second = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/b', + worktree: 'id:wt-1' + }) + + await expect(commands.browserTabClose({ index: 1, worktree: 'id:wt-1' })).resolves.toEqual({ + closed: true + }) + expect(paired.pages.getPage(second.browserPageId)).toBeUndefined() + expect(paired.pages.listPages('wt-1')).toHaveLength(1) + await startup.stop() +}) diff --git a/src/main/orcad/orcad-client-page-commands.ts b/src/main/orcad/orcad-client-page-commands.ts new file mode 100644 index 000000000000..c5257cc84eca --- /dev/null +++ b/src/main/orcad/orcad-client-page-commands.ts @@ -0,0 +1,221 @@ +/** + * The browser commands orcad answers itself. Pages a paired desktop hosts for it live in this + * runtime's lease and page registries, so opening one never needs a browser engine here. Tab + * commands see one logical list: the engine's tabs (if any), then the desktop-hosted pages. + */ +import { BrowserError } from '../browser/browser-error' +import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import type { + BrowserTabListResult, + BrowserTabShowResult, + BrowserTabSwitchResult +} from '../../shared/runtime-types' +import { browserUnavailableError } from '../runtime/runtime-browser-commands-factory' +import { browserTabCreateClientPageStartsActive } from '../runtime/browser-tab-create-publication' +import { + closeBrowserPageWithoutEngine, + resolveClientPageToClose, + retireRuntimeBrowserClientPage +} from '../runtime/browser/browser-client-page-close' +import { + createBrowserClientPage, + createBrowserPageOnPairedHost, + DEFAULT_BROWSER_PROFILE_ID, + hasPairedPageHost, + openBrowserUrlOnClient +} from '../runtime/browser/browser-client-page-opening' +import { + BrowserClientPageQueries, + yieldClientPageSelection, + type BrowserEngineTabs +} from '../runtime/browser/browser-client-page-queries' + +/** Sends a call to the engine; `name` defaults to the member the proxy was asked for. */ +type Forward = (args: unknown[], name?: string) => unknown + +const LOGICAL_TAB_QUERIES = [ + 'browserTabList', + 'browserTabCurrent', + 'browserTabShow', + 'browserTabSwitch' +] as const + +/** The member orcad answers for `name`, or undefined to send it to the engine. */ +export function orcadClientPageCommand( + host: RuntimeBrowserCommandHost, + name: string, + forward: Forward, + engineAvailable: () => boolean +): ((...args: never[]) => unknown) | undefined { + const logicalTabs = (): BrowserClientPageQueries => + new BrowserClientPageQueries( + host, + orcadProfileLabel, + engineAvailable() ? engineTabs(forward) : null + ) + if (name === 'browserOpenUrlOnClient') { + return (...args: Parameters) => + openBrowserUrlOnClient(host, args[0]) + } + if (name === 'browserTabCreate') { + return (...args: Parameters) => + createTab(host, args, forward, engineAvailable) + } + if (name === 'browserTabClose') { + return async (...args: Parameters) => { + const params = args[0] ?? {} + // Why the logical list: an index or the current tab may name a desktop-hosted page. + const page = await resolveClientPageToClose(host, logicalTabs(), params) + if (page) { + return retireRuntimeBrowserClientPage(host, page) + } + return engineAvailable() + ? forward(args) + : closeBrowserPageWithoutEngine(host, params, browserUnavailableError) + } + } + const query = LOGICAL_TAB_QUERIES.find((candidate) => candidate === name) + if (query) { + return (...args: unknown[]) => { + const hasClientPages = host.getRuntimeBrowserPageRegistry().listPages().length > 0 + // Why: with nothing desktop-hosted, the engine's answer (or its refusal) is the whole answer. + if (!hasClientPages && (engineAvailable() || !hasPairedPageHost(host))) { + return forward(args) + } + return callTabQuery(logicalTabs(), query, args) + } + } + return undefined +} + +function createTab( + host: RuntimeBrowserCommandHost, + [params, caller]: Parameters, + forward: Forward, + engineAvailable: () => boolean +): unknown { + if (params?.placement?.kind === 'client') { + const placement = params.placement + return (async () => { + const worktree = params.worktree + ? await host.resolveBrowserWorkspace(params.worktree) + : undefined + return createBrowserClientPage( + host, + { ...params, placement }, + { worktree, browserProfileId: orcadBrowserProfileId(params.profileId), caller } + ) + })() + } + // Why only for this host's own CLI: a paired client that left placement out asked for the server. + if ( + !params?.placement && + !caller?.pairedDeviceId && + !engineAvailable() && + hasPairedPageHost(host) + ) { + return createBrowserPageOnPairedHost( + host, + params ?? {}, + orcadBrowserProfileId(params?.profileId) + ) + } + if ( + !engineAvailable() || + !browserTabCreateClientPageStartsActive(params?.activate) || + host.getRuntimeBrowserPageRegistry().listPages().length === 0 + ) { + return forward([params, caller]) + } + return (async () => { + const created = await forward([params, caller]) + await yieldClientPageSelection(host, params?.worktree) + return created + })() +} + +/** The engine's own tabs, read back through the proxy's forward. */ +function engineTabs(forward: Forward): BrowserEngineTabs { + return { + list: async (params) => + requireTabResult(await forward([params], 'browserTabList'), isTabListResult), + current: async (params) => + requireTabResult(await forward([params], 'browserTabCurrent'), isTabShowResult), + switchTo: async (params) => + requireTabResult(await forward([params], 'browserTabSwitch'), isTabSwitchResult) + } +} + +function requireTabResult(value: unknown, guard: (candidate: unknown) => candidate is T): T { + if (!guard(value)) { + throw new BrowserError('browser_error', 'The browser engine returned an unreadable tab result.') + } + return value +} + +function isTabListResult(value: unknown): value is BrowserTabListResult { + return ( + isRecord(value) && + Array.isArray(value.tabs) && + value.tabs.every( + (tab: unknown) => + isRecord(tab) && typeof tab.browserPageId === 'string' && typeof tab.active === 'boolean' + ) + ) +} + +function isTabShowResult(value: unknown): value is BrowserTabShowResult { + return isRecord(value) && isRecord(value.tab) && typeof value.tab.browserPageId === 'string' +} + +function isTabSwitchResult(value: unknown): value is BrowserTabSwitchResult { + return isRecord(value) && typeof value.browserPageId === 'string' +} + +function callTabQuery( + queries: BrowserClientPageQueries, + name: (typeof LOGICAL_TAB_QUERIES)[number], + args: unknown[] +): unknown { + const params: Record = isRecord(args[0]) ? args[0] : {} + const worktree = typeof params.worktree === 'string' ? params.worktree : undefined + const page = typeof params.page === 'string' ? params.page : undefined + switch (name) { + case 'browserTabList': + return queries.browserTabList({ worktree }) + case 'browserTabCurrent': + return queries.browserTabCurrent({ worktree }) + case 'browserTabShow': + if (!page) { + throw new BrowserError('invalid_argument', 'Missing required --page') + } + return queries.browserTabShow({ page, worktree }) + case 'browserTabSwitch': + return queries.browserTabSwitch({ + worktree, + page, + index: typeof params.index === 'number' ? params.index : undefined, + focus: typeof params.focus === 'boolean' ? params.focus : undefined + }) + } +} + +// Why only the default: orcad has no profile registry to describe any other to a desktop. +function orcadBrowserProfileId(profileId: string | undefined): string { + if (profileId !== undefined && profileId !== DEFAULT_BROWSER_PROFILE_ID) { + throw new BrowserError('invalid_argument', `Browser profile ${profileId} was not found`) + } + return DEFAULT_BROWSER_PROFILE_ID +} + +function orcadProfileLabel(browserProfileId: string): { id: string; label: string } { + return { + id: browserProfileId, + label: browserProfileId === DEFAULT_BROWSER_PROFILE_ID ? 'Default' : browserProfileId + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} diff --git a/src/main/runtime/browser/browser-client-page-close.ts b/src/main/runtime/browser/browser-client-page-close.ts index 8ed20d1a631a..ad45e8a4a3ab 100644 --- a/src/main/runtime/browser/browser-client-page-close.ts +++ b/src/main/runtime/browser/browser-client-page-close.ts @@ -41,6 +41,52 @@ export async function retireRuntimeBrowserClientPage( return { closed: true } } +type LogicalTabQueries = { + browserTabList(params: { worktree?: string }): Promise<{ tabs: { browserPageId: string }[] }> + browserTabCurrent(params: { worktree?: string }): Promise<{ tab: { browserPageId: string } }> +} + +/** + * The client-hosted page a close names, by id, index or as the current tab; undefined when the + * target is not one. Index and current resolve against the host's whole logical tab list, so a + * client page that leads it is never mistaken for an engine tab. + */ +export async function resolveClientPageToClose( + host: Pick< + BrowserClientPageCloseHost, + 'getRuntimeBrowserPageRegistry' | 'resolveBrowserWorkspace' + >, + queries: LogicalTabQueries, + params: PageCloseParams & { index?: number } +): Promise { + const pages = host.getRuntimeBrowserPageRegistry() + if (params.page) { + const page = pages.getPage(params.page) + if ( + page && + params.worktree && + (await host.resolveBrowserWorkspace(params.worktree)).id !== page.workspaceId + ) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${page.browserPageId} was not found in this worktree` + ) + } + return page + } + const workspaceId = params.worktree + ? (await host.resolveBrowserWorkspace(params.worktree)).id + : undefined + if (pages.listPages(workspaceId).length === 0) { + return undefined + } + const tab = + params.index !== undefined + ? (await queries.browserTabList({ worktree: params.worktree })).tabs[params.index] + : (await queries.browserTabCurrent({ worktree: params.worktree })).tab + return tab ? pages.getPage(tab.browserPageId) : undefined +} + /** Closes a named page this runtime's registry holds; null when the registry has no such page. */ export async function closeRegisteredBrowserClientPage( host: BrowserClientPageCloseHost, diff --git a/src/main/runtime/browser/browser-client-page-opening.ts b/src/main/runtime/browser/browser-client-page-opening.ts new file mode 100644 index 000000000000..8c0da2e32574 --- /dev/null +++ b/src/main/runtime/browser/browser-client-page-opening.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto' +import { BrowserError } from '../../browser/browser-error' +import { browserNetworkExecutionHostKey } from '../../browser/browser-network-execution-route' +import type { RuntimeNavigationTarget } from '../../../shared/runtime-navigation' +import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../../shared/browser-client-automation-protocol' +import { BROWSER_HOST_WEBVIEW_CAPABILITY } from '../browser-host/browser-host-capability-selection' +import { + publishCreatedBrowserSessionTab, + resolveBrowserTabCreateFocus +} from '../browser-tab-create-publication' +import { + createRuntimeBrowserClientPage, + navigateRuntimeBrowserClientPage +} from '../runtime-browser-client-page-creation' +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' + +// Why no Electron import: a Node host with no browser engine places pages on a paired desktop too. +export type BrowserClientPageOpeningHost = Pick< + RuntimeBrowserCommandHost, + | 'getAgentBrowserBridge' + | 'resolveBrowserWorkspace' + | 'resolveBrowserNetworkExecutionHost' + | 'getBrowserHostLeaseRegistry' + | 'getRuntimeBrowserPageRegistry' + | 'markHeadlessBrowserSessionTabActive' + | 'notifyHeadlessBrowserSessionTabsChanged' +> + +type ResolvedWorkspace = Awaited> + +export type BrowserClientPageCreateParams = { + url?: string + page?: string + activate?: boolean + navigation?: RuntimeNavigationTarget + targetGroupId?: string + placement: { kind: 'client'; browserHostClientId: string } +} + +export type BrowserClientPageCaller = { pairedDeviceId?: string; clientKind?: 'mobile' | 'runtime' } + +export const DEFAULT_BROWSER_PROFILE_ID = 'default' + +export const BROWSER_HOST_UNAVAILABLE_MESSAGE = + "No paired Orca desktop is hosting browser pages for this server. On a desktop paired with it, open a browser tab in one of this server's workspaces, then retry. The desktop needs an Orca version that hosts this server's pages." + +/** Creates a page that renders on a paired desktop; this runtime owns its placement and rows. */ +export async function createBrowserClientPage( + host: BrowserClientPageOpeningHost, + params: BrowserClientPageCreateParams, + context: { + worktree: ResolvedWorkspace | undefined + browserProfileId: string + caller: BrowserClientPageCaller | undefined + } +): Promise<{ browserPageId: string }> { + const { worktree, browserProfileId, caller } = context + if (!caller?.pairedDeviceId) { + throw new BrowserError( + 'forbidden', + 'Client-hosted browser pages require an authenticated paired runtime.' + ) + } + if (!worktree) { + throw new BrowserError( + 'invalid_argument', + 'Client-hosted browser pages require an explicit workspace.' + ) + } + const url = params.url ?? 'about:blank' + const focus = resolveBrowserTabCreateFocus({ + activate: params.activate, + navigation: params.navigation, + clientKind: caller.clientKind + }) + const browserPageId = params.page ?? randomUUID() + const executionHost = await host.resolveBrowserNetworkExecutionHost(worktree) + const authority = host.getBrowserHostLeaseRegistry() + const created = await createRuntimeBrowserClientPage(authority, { + browserPageId, + browserHostClientId: params.placement.browserHostClientId, + pairedDeviceId: caller.pairedDeviceId, + browserProfileId, + executionHost, + workspaceId: worktree.id + }) + const pages = host.getRuntimeBrowserPageRegistry() + pages.publishClientPage({ + browserPageId, + workspaceId: worktree.id, + browserProfileId, + executionHostKey: browserNetworkExecutionHostKey(executionHost), + placement: created.placement, + pairedDeviceId: caller.pairedDeviceId, + url: 'about:blank', + loading: url !== 'about:blank', + active: focus.startsActive + }) + publishCreatedBrowserSessionTab(host, { + placementKind: 'client', + browserPageId, + worktreeId: worktree.id, + focus, + clientNavigationId: caller.pairedDeviceId, + targetGroupId: params.targetGroupId + }) + if (url !== 'about:blank') { + try { + await navigateRuntimeBrowserClientPage(authority, { + browserPageId, + placement: created.placement, + url + }) + pages.updatePage(browserPageId, created.placement, { url, loading: false }) + } catch { + pages.updatePage(browserPageId, created.placement, { loading: false }) + } + host.notifyHeadlessBrowserSessionTabsChanged?.(worktree.id) + } + return { browserPageId } +} + +/** `orca open-url`: opens the page on the one paired desktop hosting this runtime's pages. */ +export function openBrowserUrlOnClient( + host: BrowserClientPageOpeningHost, + params: { url: string; worktree: string } +): Promise<{ browserPageId: string }> { + const protocol = new URL(params.url).protocol + if (protocol !== 'http:' && protocol !== 'https:') { + throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.') + } + return createBrowserPageOnPairedHost( + host, + { url: params.url, worktree: params.worktree, activate: true, navigation: 'caller' }, + DEFAULT_BROWSER_PROFILE_ID + ) +} + +/** A page its caller did not place goes to the one paired desktop hosting this runtime's pages. */ +export async function createBrowserPageOnPairedHost( + host: BrowserClientPageOpeningHost, + params: Omit & { worktree?: string }, + browserProfileId: string +): Promise<{ browserPageId: string }> { + const lease = selectPairedPageHost(host) + const { worktree, ...rest } = params + return createBrowserClientPage( + host, + { ...rest, placement: { kind: 'client', browserHostClientId: lease.browserHostClientId } }, + { + worktree: worktree ? await host.resolveBrowserWorkspace(worktree) : undefined, + browserProfileId, + caller: { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } + } + ) +} + +/** True when a paired desktop could take a page now; an ambiguous pick still has a host. */ +export function hasPairedPageHost( + host: Pick +): boolean { + try { + selectPairedPageHost(host) + return true + } catch (error) { + return error instanceof Error && error.message === 'browser_host_ambiguous' + } +} + +function selectPairedPageHost( + host: Pick +): { browserHostClientId: string; pairedDeviceId: string } { + try { + return host + .getBrowserHostLeaseRegistry() + .select(undefined, [ + BROWSER_HOST_WEBVIEW_CAPABILITY, + BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY + ]) + } catch (error) { + // Why reword only this code: the bare code names no action, the others name their own. + if (error instanceof Error && error.message === 'browser_host_unavailable') { + throw new BrowserError('browser_host_unavailable', BROWSER_HOST_UNAVAILABLE_MESSAGE) + } + throw error + } +} diff --git a/src/main/runtime/browser/browser-client-page-queries.ts b/src/main/runtime/browser/browser-client-page-queries.ts new file mode 100644 index 000000000000..34ed8cc50535 --- /dev/null +++ b/src/main/runtime/browser/browser-client-page-queries.ts @@ -0,0 +1,178 @@ +import { BrowserError } from '../../browser/browser-error' +import type { + BrowserTabInfo, + BrowserTabListResult, + BrowserTabShowResult, + BrowserTabSwitchResult +} from '../../../shared/runtime-types' +import { publishSwitchedBrowserSessionTab } from '../browser-tab-create-publication' +import type { RuntimeBrowserClientPage } from '../runtime-browser-page-registry' +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' + +type BrowserProfileLabel = { id: string; label: string } + +// Why no Electron import: a host with no engine lists and selects the pages a desktop hosts for it. +export type BrowserClientPageQueryHost = Pick< + RuntimeBrowserCommandHost, + | 'getAgentBrowserBridge' + | 'resolveBrowserWorkspace' + | 'getRuntimeBrowserPageRegistry' + | 'markHeadlessBrowserSessionTabActive' + | 'notifyHeadlessBrowserSessionTabsChanged' +> + +/** The tabs a host's own engine holds, beside the pages desktops host for it. */ +export type BrowserEngineTabs = { + list(params: { worktree?: string }): Promise + /** The engine's own per-workspace selection, which its list's global flags do not show. */ + current(params: { worktree?: string }): Promise + switchTo(params: { + page: string + worktree?: string + focus?: boolean + }): Promise +} + +/** + * One logical tab list per host: its engine's tabs (if any), then the pages desktops host for it. + * "Current" is what workspace automation without --page drives: the active client page, which the + * dispatcher routes to first, else the engine's own active tab. Nothing invents a current tab. + */ +export class BrowserClientPageQueries { + constructor( + private readonly host: BrowserClientPageQueryHost, + private readonly profileFor: (browserProfileId: string) => BrowserProfileLabel, + private readonly engine: BrowserEngineTabs | null + ) {} + + async browserTabList(params: { worktree?: string }): Promise { + const workspaceId = params.worktree + ? (await this.host.resolveBrowserWorkspace(params.worktree)).id + : undefined + const pages = this.host.getRuntimeBrowserPageRegistry().listPages(workspaceId) + const clientPageActive = pages.some((page) => page.active) + const engineTabs = this.engine + ? (await this.engine.list(params)).tabs.map((tab) => ({ + ...tab, + active: clientPageActive ? false : tab.active + })) + : [] + const tabs = [ + ...engineTabs, + ...pages.map((page) => clientPageTabRow(page, this.profileFor(page.browserProfileId))) + ] + return { tabs: tabs.map((tab, index) => ({ ...tab, index })) } + } + + async browserTabCurrent(params: { worktree?: string }): Promise { + // Why only within a workspace: unscoped automation never routes to a client page either. + const listed = params.worktree ? await this.browserTabList(params) : { tabs: [] } + const clientTab = listed.tabs.find( + (candidate) => + candidate.active && + this.host.getRuntimeBrowserPageRegistry().getPage(candidate.browserPageId) + ) + if (clientTab) { + return { tab: clientTab } + } + if (this.engine) { + // Why the engine's own answer: engine tabs come first, so its index is the logical one. + return this.engine.current(params) + } + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + + async browserTabShow(params: { page: string; worktree?: string }): Promise { + const page = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) + const worktree = params.worktree ?? (page ? `id:${page.workspaceId}` : undefined) + const tab = (await this.browserTabList({ worktree })).tabs.find( + (candidate) => candidate.browserPageId === params.page + ) + if (!tab) { + throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) + } + return { tab } + } + + async browserTabSwitch(params: { + index?: number + page?: string + worktree?: string + focus?: boolean + }): Promise { + const listed = await this.browserTabList({ worktree: params.worktree }) + const switched = params.page + ? listed.tabs.findIndex((tab) => tab.browserPageId === params.page) + : (params.index ?? -1) + const selected = listed.tabs[switched] + const pages = this.host.getRuntimeBrowserPageRegistry() + const page = selected ? pages.getPage(selected.browserPageId) : undefined + if (page) { + pages.activatePage(page.browserPageId, page.placement) + publishSwitchedBrowserSessionTab(this.host, { + placementKind: 'client', + browserPageId: page.browserPageId, + worktreeId: page.workspaceId, + focus: params.focus + }) + return { switched, browserPageId: page.browserPageId } + } + if (!selected || !this.engine) { + const label = params.page ? `Browser page ${params.page}` : `Tab index ${params.index}` + throw new BrowserError( + 'browser_tab_not_found', + `${label} out of range (0-${listed.tabs.length - 1})` + ) + } + const result = await this.engine.switchTo({ + page: selected.browserPageId, + ...(params.worktree ? { worktree: params.worktree } : {}), + ...(params.focus !== undefined ? { focus: params.focus } : {}) + }) + await yieldClientPageSelection( + this.host, + params.worktree ?? (selected.worktreeId ? `id:${selected.worktreeId}` : undefined) + ) + return { switched, browserPageId: result.browserPageId } + } +} + +function clientPageTabRow( + page: RuntimeBrowserClientPage, + profile: BrowserProfileLabel +): BrowserTabInfo { + return { + browserPageId: page.browserPageId, + index: 0, + url: page.url, + title: page.title, + active: page.active, + loadError: null, + certificateFailure: null, + worktreeId: page.workspaceId, + profileId: profile.id, + profileLabel: profile.label + } +} + +/** + * Once an engine tab is created or selected it leads, so no client page may still claim to be + * active and keep taking workspace automation. With no workspace to scope it to (an engine that + * does not report one), every client page steps back: guessing a workspace would misroute. + */ +export async function yieldClientPageSelection( + host: Pick< + BrowserClientPageQueryHost, + 'getRuntimeBrowserPageRegistry' | 'resolveBrowserWorkspace' + >, + worktree: string | undefined +): Promise { + const pages = host.getRuntimeBrowserPageRegistry() + pages.deactivateGlobal() + const workspaceIds = worktree + ? [(await host.resolveBrowserWorkspace(worktree)).id] + : [...new Set(pages.listPages().map((page) => page.workspaceId))] + for (const workspaceId of workspaceIds) { + pages.deactivateWorkspace(workspaceId) + } +} diff --git a/src/main/runtime/browser/browser-commands.ts b/src/main/runtime/browser/browser-commands.ts index 5fbcd73f5f0a..b4ce955719f7 100644 --- a/src/main/runtime/browser/browser-commands.ts +++ b/src/main/runtime/browser/browser-commands.ts @@ -110,7 +110,7 @@ export class RuntimeBrowserCommands { 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 close = new RuntimeBrowserTabClose(queries, host, targets) const profiles = new RuntimeBrowserProfiles(host, targets, catalog) const screencasts = new RuntimeBrowserScreencasts(host, targets, catalog) const dialogs = new RuntimeBrowserDialogs(targets, screencasts) diff --git a/src/main/runtime/browser/browser-tab-close.ts b/src/main/runtime/browser/browser-tab-close.ts index b89dad5fd3a6..c9fcbb47b7a7 100644 --- a/src/main/runtime/browser/browser-tab-close.ts +++ b/src/main/runtime/browser/browser-tab-close.ts @@ -1,11 +1,13 @@ import type { RuntimeBrowserTabQueries } from './browser-tab-queries' -import { retireRuntimeBrowserClientPage } from './browser-client-page-close' +import { + resolveClientPageToClose, + retireRuntimeBrowserClientPage +} from './browser-client-page-close' import { BrowserError } from '../../browser/browser-error' import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' 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( @@ -29,8 +31,7 @@ export class RuntimeBrowserTabClose { private readonly targets: Pick< RuntimeBrowserCommandTargets, 'resolveBrowserWorktreeId' | 'retireGhostBrowserSessionRow' - >, - private readonly catalog: Pick + > ) {} async browserTabClose(params: { @@ -38,22 +39,7 @@ export class RuntimeBrowserTabClose { page?: string worktree?: string }): Promise<{ closed: boolean }> { - const pages = this.host.getRuntimeBrowserPageRegistry() - let clientPage = params.page ? pages.getPage(params.page) : undefined - if (clientPage) { - await this.catalog.assertClientPageWorkspace(clientPage, params.worktree) - } else if (!params.page) { - const workspaceId = params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - if (pages.listPages(workspaceId).length > 0) { - const tab = - params.index !== undefined - ? (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 - } - } + const clientPage = await resolveClientPageToClose(this.host, this.queries, params) if (clientPage) { return retireRuntimeBrowserClientPage(this.host, clientPage) } diff --git a/src/main/runtime/browser/browser-tab-creation.ts b/src/main/runtime/browser/browser-tab-creation.ts index 4bd0599a30f4..744995372b08 100644 --- a/src/main/runtime/browser/browser-tab-creation.ts +++ b/src/main/runtime/browser/browser-tab-creation.ts @@ -7,17 +7,10 @@ import { } 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/browser-host-capability-selection' import type { RuntimeBrowserCommandHost } from './browser-command-contracts' import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +import { createBrowserClientPage, openBrowserUrlOnClient } from './browser-client-page-opening' export class RuntimeBrowserTabCreation { constructor( @@ -74,64 +67,15 @@ export class RuntimeBrowserTabCreation { ) } if (params.placement?.kind === 'client') { - if (!caller?.pairedDeviceId) { - throw new BrowserError( - 'forbidden', - 'Client-hosted browser pages require an authenticated paired runtime.' - ) - } - if (!worktree) { - throw new BrowserError( - 'invalid_argument', - 'Client-hosted browser pages require an explicit workspace.' - ) - } - const browserPageId = params.page ?? randomUUID() - const executionHost = await this.host.resolveBrowserNetworkExecutionHost(worktree) - const authority = this.host.getBrowserHostLeaseRegistry() - const browserProfileId = params.profileId ?? browserSessionRegistry.getDefaultProfile().id - const created = await createRuntimeBrowserClientPage(authority, { - browserPageId, - browserHostClientId: params.placement.browserHostClientId, - pairedDeviceId: caller.pairedDeviceId, - browserProfileId, - executionHost, - workspaceId: worktree.id - }) - const pages = this.host.getRuntimeBrowserPageRegistry() - pages.publishClientPage({ - browserPageId, - workspaceId: worktree.id, - browserProfileId, - executionHostKey: browserNetworkExecutionHostKey(executionHost), - placement: created.placement, - pairedDeviceId: caller.pairedDeviceId, - url: 'about:blank', - loading: url !== 'about:blank', - active: focus.startsActive - }) - publishCreatedBrowserSessionTab(this.host, { - placementKind: 'client', - browserPageId, - worktreeId: worktree.id, - focus, - clientNavigationId: caller.pairedDeviceId, - targetGroupId: params.targetGroupId - }) - if (url !== 'about:blank') { - try { - await navigateRuntimeBrowserClientPage(authority, { - browserPageId, - placement: created.placement, - url - }) - pages.updatePage(browserPageId, created.placement, { url, loading: false }) - } catch { - pages.updatePage(browserPageId, created.placement, { loading: false }) + return createBrowserClientPage( + this.host, + { ...params, placement: params.placement }, + { + worktree, + browserProfileId: params.profileId ?? browserSessionRegistry.getDefaultProfile().id, + caller } - this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktree.id) - } - return { browserPageId } + ) } // Why: headless serve has no renderer , so back the page with a main-process offscreen WebContents instead. if (!this.host.getAvailableAuthoritativeWindow()) { @@ -222,25 +166,6 @@ export class RuntimeBrowserTabCreation { url: string worktree: string }): Promise<{ browserPageId: string }> { - const protocol = new URL(params.url).protocol - if (protocol !== 'http:' && protocol !== 'https:') { - throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.') - } - const lease = this.host - .getBrowserHostLeaseRegistry() - .select(undefined, [ - BROWSER_HOST_WEBVIEW_CAPABILITY, - BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY - ]) - return this.browserTabCreate( - { - url: params.url, - worktree: params.worktree, - activate: true, - navigation: 'caller', - placement: { kind: 'client', browserHostClientId: lease.browserHostClientId } - }, - { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } - ) + return openBrowserUrlOnClient(this.host, params) } } diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index ac04222c6899..a70ee2c73742 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -321,6 +321,28 @@ describe('runtime composition parity (Electron vs orcad)', () => { expect(actual).toEqual([...declared].sort()) }) + // #30731: orcad once installed a browser factory that could not place pages on a desktop. + it('installs a browser factory on both roots that hosts pages on a paired desktop', () => { + const installs: Record = { + electron: { + root: 'src/main/startup/main-process-preflight.ts', + call: 'installElectronBrowserCommands(', + file: 'src/main/host/electron-browser-commands.ts', + option: /\bclientHosting: true\b/ + }, + orcad: { + root: 'src/main/orcad/orcad-lifecycle.ts', + call: 'startOrcadBrowserProvider(', + file: 'src/main/orcad/orcad-browser-startup.ts', + option: /\bclientHostingWithoutEngine: true\b/ + } + } + for (const [root, install] of Object.entries(installs)) { + expect(read(install.root), root).toContain(install.call) + expect(read(install.file), root).toMatch(install.option) + } + }) + it('lists each advertised-but-unserved capability against what the host advertises', () => { for (const difference of DECLARED_HOST_DIFFERENCES) { expect(difference.reason, difference.wiring).not.toBe('') diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts index e9886b6a1bc5..ba04c852feab 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts @@ -25,6 +25,7 @@ export async function routeDesktopBrowserBridgeRpc(options: { caller: RpcCallerIdentity | undefined hostEnabled: boolean registry: DesktopBrowserBridgeRegistry + /** This host serves browser commands itself: its own engine, or a desktop hosting its pages. */ localBrowserAvailable: boolean resolveWorkspace(selector: string): Promise<{ id: string }> signal?: AbortSignal diff --git a/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts index 68adf9e8e35b..b674dfa291aa 100644 --- a/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts @@ -1,9 +1,16 @@ import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { + OrcaRuntimeService, + RuntimeBrowserCommands, + setRuntimeBrowserCommandsFactory, + setRuntimeBrowserUnavailableCause +} from '../orca-runtime-test-mocks.spec' +import { runtimeAdvertisesBrowserClientHosting } from '../../../shared/browser-client-hosting-eligibility' import { TEST_REPO_ID, TEST_WORKTREE_ID, TEST_WORKTREE_PATH, + createRuntime, store } from '../orca-runtime-test-fixtures.spec' @@ -35,4 +42,22 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('selector_not_found') expect(internals.listResolvedWorktrees).not.toHaveBeenCalled() }) + + // #30731: orcad with no engine still hosts pages on a paired desktop, and says why it can't itself. + it('advertises client hosting from an engine-free factory while reporting the engine cause', () => { + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host), { + headless: true, + clientHostingWithoutEngine: true, + isAvailable: () => false + }) + setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' }) + const status = createRuntime().getStatus() + expect(runtimeAdvertisesBrowserClientHosting(status.capabilities)).toBe(true) + expect(status.capabilities).toContain('browser.tab-create-known-id.v1') + expect(status.capabilities).not.toContain('browser.headless.v1') + expect(status.degradations).toContainEqual( + expect.objectContaining({ code: 'browser_unavailable', reason: 'unconfigured' }) + ) + setRuntimeBrowserUnavailableCause(null) + }) }) diff --git a/src/main/runtime/runtime-browser-commands-factory.ts b/src/main/runtime/runtime-browser-commands-factory.ts index 9e228a8d1e9a..e8e2f4531691 100644 --- a/src/main/runtime/runtime-browser-commands-factory.ts +++ b/src/main/runtime/runtime-browser-commands-factory.ts @@ -30,6 +30,8 @@ export type RuntimeBrowserCommandsFactory = ( export type RuntimeBrowserCommandsFactoryOptions = { /** The provider creates pages against this runtime's client-host lease registry. */ clientHosting?: boolean + /** Client pages live in this runtime's own registries, so they need no healthy engine. */ + clientHostingWithoutEngine?: boolean /** The provider owns browser pages without a renderer window. */ headless?: boolean /** Live health probe; failure must remove advertised capability. */ @@ -62,6 +64,9 @@ export function runtimeBrowserCommandsFactoryIsHeadless(): boolean { } export function runtimeBrowserCommandsFactorySupportsClientHosting(): boolean { + if (currentFactory && currentOptions.clientHostingWithoutEngine === true) { + return true + } return runtimeBrowserCommandsFactoryIsAvailable() && currentOptions.clientHosting === true } @@ -83,17 +88,16 @@ export function setRuntimeBrowserUnavailableCause( } /** - * The cause this process can prove. An installed factory outranks any recorded note: it - * means resolution succeeded, so the failure is later and elsewhere — a dead provider, or - * a renderer-backed factory with no window. Unknown beats guessing. + * The cause this process can prove. A healthy factory outranks any recorded note: the failure + * is a renderer-backed factory with no window. An unhealthy one keeps the resolver's note, since + * a factory serving only client pages stays installed when no engine resolved; with no note the + * engine started and later died. Unknown beats guessing. */ export function runtimeBrowserUnavailableCause(): RuntimeBrowserUnavailableCause { - if (currentFactory) { - return runtimeBrowserCommandsFactoryIsAvailable() - ? { reason: 'desktop_window_unavailable' } - : { reason: 'provider_unhealthy' } + if (currentFactory && runtimeBrowserCommandsFactoryIsAvailable()) { + return { reason: 'desktop_window_unavailable' } } - return unavailableCause ?? { reason: 'unknown' } + return unavailableCause ?? { reason: currentFactory ? 'provider_unhealthy' : 'unknown' } } /** diff --git a/src/main/runtime/session-graph/session-graph-authority.ts b/src/main/runtime/session-graph/session-graph-authority.ts index 03e6595a6eeb..7fdaab0cb1e0 100644 --- a/src/main/runtime/session-graph/session-graph-authority.ts +++ b/src/main/runtime/session-graph/session-graph-authority.ts @@ -25,6 +25,7 @@ import { isDesktopBrowserBridgeHostEnabled } from '../desktop-browser-bridge/desktop-browser-bridge-registry' import { runtimeBrowserCommandsFactoryIsAvailable } from '../runtime-browser-commands-factory' +import { hasPairedPageHost } from '../browser/browser-client-page-opening' import { resolveRemoteCliBrowserScopeSelector } from '../desktop-browser-bridge/remote-cli-browser-scope' import type { RpcContext } from '../rpc/core' @@ -231,7 +232,12 @@ export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extend signal: context.signal, hostEnabled: isDesktopBrowserBridgeHostEnabled(), registry: getDesktopBrowserBridgeRegistry(this), - localBrowserAvailable: runtimeBrowserCommandsFactoryIsAvailable(), + // Why desktop-hosted pages count, even with their desktop gone: this runtime lists and + // retires them itself, so those commands never need the bridge. + localBrowserAvailable: + runtimeBrowserCommandsFactoryIsAvailable() || + getRuntimeBrowserPageRegistry(this).listPages().length > 0 || + hasPairedPageHost({ getBrowserHostLeaseRegistry: () => getBrowserHostLeaseRegistry(this) }), resolveWorkspace: (selector) => this.resolveBrowserWorkspace(selector) }) } diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx index 5befddce1679..a7e3fda241b4 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx @@ -102,4 +102,26 @@ describe('ClientHostedBrowserUnavailableNotice', () => { expect(mocks.ensure).toHaveBeenCalledTimes(1) expect(mocks.ensure).toHaveBeenCalledWith(expect.anything(), 'env-a') }) + + it('offers reopening on the server only when the server has a browser of its own', () => { + renderNotice(live) + expect(screen.getByRole('button', { name: 'Reopen on server' })).toBeInTheDocument() + cleanup() + + const status = live.status + if (!status) { + throw new Error('fixture status missing') + } + // orcad with no engine: it hosts pages on desktops but cannot render one itself. + renderNotice({ + ...live, + status: { + ...status, + capabilities: (status.capabilities ?? []).filter( + (capability) => capability !== 'browser.screencast.v1' + ) + } + }) + expect(screen.queryByRole('button', { name: 'Reopen on server' })).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx index ca0fdf2aa9ff..e7a7790e2ab5 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx @@ -13,6 +13,7 @@ import { ReopenBrowserPageOnServerButton, reopenOnServerCaveat } from './ReopenBrowserPageOnServerButton' +import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' /** * What a client-hosted page shows when its guest is not here: the page belongs to another desktop, @@ -43,6 +44,12 @@ export function ClientHostedBrowserUnavailableNotice({ ?.name ?? null) : null }) + // Why: a runtime known to have no browser of its own (orcad) cannot take the page back. + const runtimeCanHostPage = useAppStore((s) => { + const capabilities = + s.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId)?.status?.capabilities + return !capabilities || capabilities.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) + }) const ownHostClientId = readBrowserClientHostId() const onOtherDesktop = Boolean( placementHostClientId && ownHostClientId && placementHostClientId !== ownHostClientId @@ -78,12 +85,16 @@ export function ClientHostedBrowserUnavailableNotice({ {translate('browser.clientHosted.unavailableTitle', 'Client-hosted browser unavailable')}
{description}
-
{reopenOnServerCaveat()}
- + {runtimeCanHostPage ? ( + <> +
{reopenOnServerCaveat()}
+ + + ) : null} ) diff --git a/src/renderer/src/lib/client-creation-action-policy.test.ts b/src/renderer/src/lib/client-creation-action-policy.test.ts index b0edc2add676..4eac22f5ad0d 100644 --- a/src/renderer/src/lib/client-creation-action-policy.test.ts +++ b/src/renderer/src/lib/client-creation-action-policy.test.ts @@ -3,6 +3,8 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { RuntimeStatus } from '../../../shared/runtime-types' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES } from '../../../shared/browser-client-hosting-eligibility' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import { FLOATING_BROWSER_UNAVAILABLE_MESSAGE, LOCAL_BROWSER_UNAVAILABLE_MESSAGE, @@ -10,8 +12,11 @@ import { MOBILE_EMULATOR_UNAVAILABLE_MESSAGE, assertClientCreationActionAvailable, assertManagedBrowserMaterializationAllowed, + assertRuntimeManagedBrowserCreationAvailable, getClientCreationActionPolicy, - resolveClientCreationActionPolicy + resolveClientCreationActionPolicy, + runtimeHostsPagesOnThisDesktop, + type RuntimeBrowserHostingState } from './client-creation-action-policy' function runtimeStatus(capabilities?: string[]): RuntimeStatus { @@ -208,3 +213,60 @@ describe('client creation action guards', () => { ).toEqual({ state: 'hidden', reason: MANAGED_BROWSER_UNAVAILABLE_MESSAGE }) }) }) + +describe('a runtime whose pages this desktop hosts', () => { + function hostingState( + environment: Pick, + settings: RuntimeBrowserHostingState['settings'] = {} + ): RuntimeBrowserHostingState { + return { + settings, + runtimeEnvironments: [environment], + runtimeStatusByEnvironmentId: new Map([ + [ + environment.id, + { status: runtimeStatus([...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES]) } + ] + ]) + } + } + + it('creates runtime-placed tabs for a paired server with no browser of its own', () => { + const state = hostingState({ id: 'orcad-server' }) + expect(runtimeHostsPagesOnThisDesktop(state, 'orcad-server')).toBe(true) + expect(() => assertRuntimeManagedBrowserCreationAvailable(state, 'orcad-server')).not.toThrow() + expect( + resolveClientCreationActionPolicy({ + surface: 'electron', + runtimeStatus: runtimeStatus(), + runtimeHostsPagesOnThisDesktop: true + })['managed-browser'] + ).toEqual({ state: 'enabled', provider: 'paired-runtime' }) + }) + + it('keeps a managed SSH deployment on its desktop tabs and SSH browser route', () => { + const state = hostingState({ + id: 'managed-ssh', + orcadDeployment: { + sshTargetId: 'target-1', + sshTargetGeneration: 1, + localPort: 1, + remotePort: 2 + } + }) + expect(runtimeHostsPagesOnThisDesktop(state, 'managed-ssh')).toBe(false) + expect(() => assertRuntimeManagedBrowserCreationAvailable(state, 'managed-ssh')).toThrow() + }) + + it('stays off when desktop-hosted remote pages are turned off, and in the web client', () => { + const disabled = hostingState( + { id: 'orcad-server' }, + { browserClientHostedRemoteEnabled: false } + ) + expect(runtimeHostsPagesOnThisDesktop(disabled, 'orcad-server')).toBe(false) + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + expect( + runtimeHostsPagesOnThisDesktop(hostingState({ id: 'orcad-server' }), 'orcad-server') + ).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/client-creation-action-policy.ts b/src/renderer/src/lib/client-creation-action-policy.ts index 9933335b2650..a73564a29085 100644 --- a/src/renderer/src/lib/client-creation-action-policy.ts +++ b/src/renderer/src/lib/client-creation-action-policy.ts @@ -4,6 +4,12 @@ import type { RuntimeStatus } from '../../../shared/runtime-types' import type { AppState } from '@/store/types' import { isPairedWebClientWindow } from './desktop-window-chrome' import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner' +import { expectsBrowserClientHosting } from '../../../shared/browser-client-hosting-eligibility' +import { + getOrcadSshDeployment, + type PublicKnownRuntimeEnvironment +} from '../../../shared/runtime-environments' +import type { GlobalSettings } from '../../../shared/global-settings-types' export const MANAGED_BROWSER_UNAVAILABLE_MESSAGE = "Managed browser tabs are unavailable because this web client's paired runtime does not support browser streaming." @@ -32,6 +38,8 @@ export function resolveClientCreationActionPolicy(args: { surface: 'electron' | 'paired-web' runtimeStatus: Pick | null floatingWorkspace?: boolean + /** The runtime places its pages on this desktop (see runtimeHostsPagesOnThisDesktop). */ + runtimeHostsPagesOnThisDesktop?: boolean }): ClientCreationActionPolicy { const browserStreamingAvailable = args.runtimeStatus?.capabilities?.includes( BROWSER_SCREENCAST_RUNTIME_CAPABILITY @@ -41,7 +49,10 @@ export function resolveClientCreationActionPolicy(args: { return { 'managed-browser': { state: 'enabled', - provider: browserStreamingAvailable ? 'paired-runtime' : 'local-client' + provider: + browserStreamingAvailable || args.runtimeHostsPagesOnThisDesktop + ? 'paired-runtime' + : 'local-client' }, 'mobile-emulator': { state: 'enabled', provider: 'local-client' } } @@ -75,7 +86,43 @@ export function getClientCreationActionPolicy( return resolveClientCreationActionPolicy({ surface: isPairedWebClientWindow() ? 'paired-web' : 'electron', runtimeStatus, - floatingWorkspace + floatingWorkspace, + runtimeHostsPagesOnThisDesktop: + runtimeEnvironmentId !== null && runtimeHostsPagesOnThisDesktop(state, runtimeEnvironmentId) + }) +} + +/** The store fields that decide where a runtime's browser pages render. */ +export type RuntimeBrowserHostingState = { + runtimeStatusByEnvironmentId?: ReadonlyMap< + string, + { status: Pick | null } + > + runtimeEnvironments?: readonly Pick[] + settings?: Pick | null +} + +/** + * A runtime with no browser of its own (orcad) whose pages this desktop hosts, network tunnelled + * through the runtime. A managed SSH deployment keeps desktop tabs on its SSH browser route. + */ +export function runtimeHostsPagesOnThisDesktop( + state: RuntimeBrowserHostingState, + runtimeEnvironmentId: string +): boolean { + if (isPairedWebClientWindow()) { + return false + } + const environment = state.runtimeEnvironments?.find((entry) => entry.id === runtimeEnvironmentId) + if (environment && getOrcadSshDeployment(environment)) { + return false + } + const status = state.runtimeStatusByEnvironmentId?.get(runtimeEnvironmentId)?.status + return expectsBrowserClientHosting({ + enabled: state.settings?.browserClientHostedRemoteEnabled !== false, + preference: undefined, + deviceScope: status?.deviceScope, + capabilities: status?.capabilities }) } @@ -91,12 +138,15 @@ export function assertClientCreationActionAvailable( } export function assertRuntimeManagedBrowserCreationAvailable( - state: AppState, + state: RuntimeBrowserHostingState, runtimeEnvironmentId: string ): void { const capabilities = state.runtimeStatusByEnvironmentId?.get(runtimeEnvironmentId)?.status?.capabilities - if (!capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY)) { + if ( + !capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) && + !runtimeHostsPagesOnThisDesktop(state, runtimeEnvironmentId) + ) { throw new Error(RUNTIME_BROWSER_UNAVAILABLE_MESSAGE) } } diff --git a/src/renderer/src/lib/workspace-browser-tab-open.ts b/src/renderer/src/lib/workspace-browser-tab-open.ts index dd415fb04dd4..614823c20c1d 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.ts @@ -12,6 +12,7 @@ import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol- import type { BrowserClientHostPlacementPreference } from '../../../shared/browser-client-host-placement' import { getClientCreationActionPolicy, + runtimeHostsPagesOnThisDesktop, type ClientCreationActionAvailability } from './client-creation-action-policy' import { resolveWorktreeOperationRoute } from './worktree-operation-route' @@ -58,7 +59,8 @@ function isExpectedRuntimeBrowserRoute( state.runtimeStatusByEnvironmentId?.get(expectedEnvironmentId)?.status?.capabilities if ( environmentId !== expectedEnvironmentId || - !capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) + (!capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) && + !runtimeHostsPagesOnThisDesktop(state, expectedEnvironmentId)) ) { return false } diff --git a/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts b/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts new file mode 100644 index 000000000000..fd040ff0de4a --- /dev/null +++ b/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts @@ -0,0 +1,263 @@ +/** + * #30731: `orca open-url` on a paired `orca serve` running orcad with no browser engine opens the + * page in the paired desktop's browser, its network routed through the server. + * + * Needs the packaged orcad slot (`pnpm build:orcad`). Background launch only; nothing is focused. + * + * Run: + * ORCA_E2E_ORCAD_SERVE=1 ORCA_BACKGROUND_LAUNCH=1 pnpm exec playwright test \ + * tests/e2e/orcad-serve-open-url-on-desktop.spec.ts --config tests/playwright.config.ts \ + * --project electron-headless --workers=1 + */ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:http' + +import os from 'node:os' +import path from 'node:path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { runProcess } from '@orca/process-host' +import { RuntimeClient } from '../../src/cli/runtime/client' +import { resolveBundledOrcadRuntime } from '../../src/main/orcad/orcad-bundled-runtime' +import { expect, test } from './helpers/orca-app' +import { cleanupE2EDaemons } from './helpers/electron-process-shutdown' +import { cliServeProfile, spawnUntilReady } from './helpers/orca-serve-cli-host' +import { launchPairedElectronClient } from './helpers/paired-electron-client' + +const RUN = process.env.ORCA_E2E_ORCAD_SERVE === '1' +const slotDir = path.resolve('out/orcad') +const orcadRuntime = RUN && existsSync(slotDir) ? resolveBundledOrcadRuntime(slotDir) : null +const MARKER = 'open-url-desktop-marker' + +let scratch = '' + +test.beforeAll(() => { + test.skip(!RUN, 'Set ORCA_E2E_ORCAD_SERVE=1 (and build out/orcad) to run against orcad serve.') + expect(orcadRuntime, 'pnpm build:orcad must produce a packaged slot in out/orcad').toBeTruthy() + // Short root: macOS caps a unix socket path at 104 bytes, and the daemon's lives in userData. + scratch = mkdtempSync(path.join(process.platform === 'win32' ? os.tmpdir() : '/tmp', 'orca-ou-')) +}) + +test.afterAll(() => { + if (scratch) { + rmSync(scratch, { recursive: true, force: true, maxRetries: 50, retryDelay: 100 }) + } +}) + +/** A page on the server's loopback, as an agent's dev server would be. */ +async function startPageServer(): Promise<{ url: string; server: Server }> { + const server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end(`${MARKER}

${MARKER}

`) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('page server has no TCP address') + } + return { url: `http://127.0.0.1:${address.port}/`, server } +} + +/** The marker the desktop's own browser engine rendered for `url`, read from its guest. */ +async function readDesktopPageMarker( + app: ElectronApplication, + url: string +): Promise { + return app.evaluate(async ({ webContents }, prefix) => { + for (const contents of webContents.getAllWebContents()) { + if (contents.getURL().startsWith(prefix)) { + const marker: unknown = await contents + .executeJavaScript('document.querySelector("#marker")?.textContent ?? null') + .catch(() => null) + return typeof marker === 'string' ? marker : null + } + } + return null + }, url) +} + +test('orca open-url on orcad serve opens the page in the paired desktop', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const profile = cliServeProfile(scratch) + const serve = await spawnUntilReady({ + label: 'orcad serve', + program: orcadRuntime!, + args: [ + path.join(slotDir, 'orcad.js'), + '--bind', + '127.0.0.1', + '--port', + '0', + '--pairing-address', + '127.0.0.1', + '--json' + ], + env: { ...profile.env, ORCA_USER_DATA: profile.userDataDir }, + timeoutMs: 120_000, + parseReady: (stdout) => { + const newline = stdout.indexOf('\n') + return newline === -1 ? null : JSON.parse(stdout.slice(0, newline)) + } + }) + const page = await startPageServer() + const client = new RuntimeClient(profile.userDataDir, 15_000) + let desktop: Awaited> | null = null + try { + const pairingUrl = serve.ready.pairing?.url + expect(pairingUrl, serve.stderr()).toEqual(expect.any(String)) + const status = await client.call<{ capabilities: string[] }>('status.get') + expect(status.result.capabilities).toContain('browser.clientHost.v1') + await client.call('repo.add', { path: testRepoPath, kind: 'git' }) + + const worktree = `path:${testRepoPath}` + const openUrl = (): Promise<{ code: number | null; stdout: string; stderr: string }> => + runProcess({ + program: process.execPath, + args: [ + path.join(process.cwd(), 'out', 'cli', 'index.js'), + 'open-url', + '--url', + page.url, + '--worktree', + worktree, + '--json' + ], + env: { ...profile.env, ORCA_USER_DATA: profile.userDataDir }, + timeoutMs: 90_000 + }) + + // No desktop has taken this server's pages yet: a typed refusal that says what to do. + const refused = await openUrl() + writeFileSync( + testInfo.outputPath('open-url-before-desktop.txt'), + refused.stdout + refused.stderr + ) + expect(refused.code).not.toBe(0) + expect(refused.stdout + refused.stderr).toContain('browser_host_unavailable') + + desktop = await launchPairedElectronClient({ pairingUrl }, testInfo, '#30731 desktop') + const worktreeId = await expect + .poll( + () => + desktop!.page.evaluate( + (repoPath) => + window.__store + ?.getState() + .allWorktrees() + .find((entry) => entry.path === repoPath)?.id ?? null, + testRepoPath + ), + { timeout: 60_000 } + ) + .not.toBeNull() + .then(() => + desktop!.page.evaluate( + (repoPath) => + window.__store + ?.getState() + .allWorktrees() + .find((entry) => entry.path === repoPath)?.id ?? '', + testRepoPath + ) + ) + // The user opens a browser tab in the server's workspace, as in the report. + await desktop.page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: desktop.environmentId, worktreeId } + ) + await expect + .poll( + () => + desktop!.page.evaluate( + (id) => window.__store?.getState().activeGroupIdByWorktree[id] ?? null, + worktreeId + ), + { timeout: 60_000, message: 'the server workspace never got a tab group on the desktop' } + ) + .not.toBeNull() + await desktop.page.evaluate(async () => { + const state = window.__store?.getState() + const groupId = state?.activeWorktreeId + ? state.activeGroupIdByWorktree[state.activeWorktreeId] + : undefined + if (!state || !groupId) { + throw new Error('desktop has no active tab group in the server workspace') + } + state.setBrowserDefaultUrl('about:blank') + await state.openNewBrowserTabInActiveWorkspace(groupId) + }) + + let opened: { code: number | null; stdout: string; stderr: string } | null = null + await expect + .poll( + async () => { + opened = await openUrl() + return opened.code + }, + { timeout: 60_000, intervals: [2_000] } + ) + .toBe(0) + writeFileSync(testInfo.outputPath('open-url-with-desktop.txt'), opened!.stdout + opened!.stderr) + const browserPageId = JSON.parse(opened!.stdout).result.browserPageId + expect(browserPageId).toEqual(expect.any(String)) + + await expect + .poll(() => readDesktopPageMarker(desktop!.app, page.url), { + timeout: 60_000, + message: "the desktop never rendered the server's loopback page" + }) + .toBe(MARKER) + // Client placement: the server owns the page and the desktop renders it. + const mirrored = await desktop.page.evaluate( + ({ remotePageId, worktreeId }) => { + const state = window.__store?.getState() + for (const workspace of state?.browserTabsByWorktree[worktreeId] ?? []) { + for (const browserPage of state?.browserPagesByWorkspace[workspace.id] ?? []) { + const handle = state?.remoteBrowserPageHandlesByPageId[browserPage.id] + if ((handle?.remotePageId ?? browserPage.id) === remotePageId) { + return { localPageId: browserPage.id, placement: handle?.placement?.kind ?? null } + } + } + } + return null + }, + { remotePageId: browserPageId, worktreeId } + ) + expect(mirrored?.placement).toBe('client') + // Only for the evidence screenshot: bring the page's pane forward inside the hidden window. + await desktop.page.evaluate( + ({ localPageId, worktreeId }) => + window.__store + ?.getState() + .focusBrowserTabInWorktree(worktreeId, localPageId, { surfacePane: true }), + { localPageId: mirrored?.localPageId ?? '', worktreeId } + ) + await desktop.page.screenshot({ path: testInfo.outputPath('desktop-with-open-url-page.png') }) + const tabs = await runProcess({ + program: process.execPath, + args: [ + path.join(process.cwd(), 'out', 'cli', 'index.js'), + 'tab', + 'list', + '--worktree', + worktree, + '--json' + ], + env: { ...profile.env, ORCA_USER_DATA: profile.userDataDir }, + timeoutMs: 30_000 + }) + writeFileSync(testInfo.outputPath('tab-list-with-desktop.txt'), tabs.stdout + tabs.stderr) + expect(tabs.code, tabs.stderr).toBe(0) + expect(JSON.parse(tabs.stdout).result.tabs).toContainEqual( + expect.objectContaining({ browserPageId, url: page.url }) + ) + } finally { + await desktop?.dispose() + await new Promise((settle) => page.server.close(settle)) + await serve.stop() + await cleanupE2EDaemons(profile.userDataDir) + } +}) diff --git a/tests/e2e/ssh-orcad-browser-capabilities.spec.ts b/tests/e2e/ssh-orcad-browser-capabilities.spec.ts index 0930ef7f7629..807c9fe8a5b5 100644 --- a/tests/e2e/ssh-orcad-browser-capabilities.spec.ts +++ b/tests/e2e/ssh-orcad-browser-capabilities.spec.ts @@ -9,7 +9,7 @@ import { ORCAD_TEMPLATE, skipUnlessManagedDockerHost } from './helpers/orcad-man skipUnlessManagedDockerHost() test.use({ orcaAppExtraEnv: { ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE } }) -test('a backendless managed host does not promise client-hosted browser creation', async ({ +test('a backendless managed host offers desktop-hosted pages and refuses its own engine', async ({ orcaPage: page }, testInfo) => { test.setTimeout(3 * 60_000) @@ -41,20 +41,29 @@ test('a backendless managed host does not promise client-hosted browser creation ) const capabilities = (await status()).capabilities console.log('[managed-browser-capabilities]', JSON.stringify(capabilities)) - expect(runtimeAdvertisesBrowserClientHosting(capabilities)).toBe(false) - expect(capabilities).not.toContain('browser.clientHost.v1') - expect(capabilities).not.toContain('browser.tab-create-known-id.v1') + // #30731: client pages need no engine on the host, so it can create them for a desktop. + expect(runtimeAdvertisesBrowserClientHosting(capabilities)).toBe(true) + expect(capabilities).toContain('browser.tab-create-known-id.v1') expect(capabilities).toContain('network.browserTunnel.v1') - const refused = await page.evaluate( - (selector) => - window.api.runtimeEnvironments.call({ selector, method: 'browser.tabList', params: {} }), - environment.id - ) + const call = (method: string, params: Record) => + page.evaluate((input) => window.api.runtimeEnvironments.call(input), { + selector: environment.id, + method, + params + }) + const refused = await call('browser.tabList', {}) console.log('[managed-browser-command]', JSON.stringify(refused)) expect(refused).toMatchObject({ ok: false, error: { code: 'browser_unavailable' } }) + // A managed SSH desktop keeps its SSH-routed tabs, so it never takes this host's pages. + const openUrl = await call('browser.openUrl', { + url: 'https://example.org/', + worktree: 'active' + }) + console.log('[managed-browser-open-url]', JSON.stringify(openUrl)) + expect(openUrl).toMatchObject({ ok: false, error: { code: 'browser_host_unavailable' } }) await serverCall(page, environment.id, 'repo.list') await reconnect(page, targetId) - expect(runtimeAdvertisesBrowserClientHosting((await status()).capabilities)).toBe(false) + expect(runtimeAdvertisesBrowserClientHosting((await status()).capabilities)).toBe(true) } finally { target.cleanup() }