From f6d32ea7cf068bfe1783765900ca3360a104e384 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 15:07:41 -0400 Subject: [PATCH] refactor(mobile): move browser input and the screencast onto the host lane The page addresses the browser page id directly through requestMobileWebHost and parses each result with the shared contract, so no browser translator remains between it and the desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...mobile-web-browser-event-sanitizer.test.ts | 27 --- .../mobile-web-browser-event-sanitizer.ts | 49 ----- .../mobile-web-browser-operations.test.ts | 108 ---------- .../mobile-web-browser-operations.ts | 160 --------------- .../mobile-web-browser-streams.test.ts | 126 ------------ .../mobile-web/mobile-web-browser-streams.ts | 189 ------------------ .../mobile-web-production-browser-grants.ts | 12 -- .../methods/mobile-web-browser-input.test.ts | 2 +- .../methods/mobile-web-browser-stream.test.ts | 9 +- .../src/mobile-web-bridge-client.ts | 3 +- .../mobile-web-bridge-subscription-client.ts | 5 - .../mobile-web-bridge-subscription-setup.ts | 21 -- .../mobile-web-browser-request-client.test.ts | 137 +++++++++++++ .../src/mobile-web-browser-request-client.ts | 105 +++++----- ...bile-web-host-browser-subscription.test.ts | 100 +++++++++ .../mobile-web-host-browser-subscription.ts | 70 +++++++ .../mobile-web/browser-operation-contract.ts | 13 +- 17 files changed, 382 insertions(+), 754 deletions(-) delete mode 100644 mobile/src/mobile-web/mobile-web-browser-event-sanitizer.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-browser-event-sanitizer.ts delete mode 100644 mobile/src/mobile-web/mobile-web-browser-operations.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-browser-operations.ts delete mode 100644 mobile/src/mobile-web/mobile-web-browser-streams.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-browser-streams.ts delete mode 100644 mobile/src/mobile-web/mobile-web-production-browser-grants.ts create mode 100644 src/mobile-web/src/mobile-web-browser-request-client.test.ts create mode 100644 src/mobile-web/src/mobile-web-host-browser-subscription.test.ts create mode 100644 src/mobile-web/src/mobile-web-host-browser-subscription.ts diff --git a/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.test.ts b/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.test.ts deleted file mode 100644 index fa450d6b05b..00000000000 --- a/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { sanitizeMobileWebBrowserEvent } from './mobile-web-browser-event-sanitizer' - -describe('sanitizeMobileWebBrowserEvent', () => { - it('removes URL credentials from browser events', () => { - const event = sanitizeMobileWebBrowserEvent({ - type: 'navigation', - tab: { - url: 'https://user:password@example.com/callback?access_token=secret&tab=review', - title: 'Review', - canGoBack: true, - canGoForward: false - } - }) - - expect(event).toEqual({ - type: 'navigation', - tab: { - url: 'https://example.com/callback?tab=review', - title: 'Review', - canGoBack: true, - canGoForward: false - } - }) - expect(JSON.stringify(event)).not.toMatch(/password|access_token|secret/) - }) -}) diff --git a/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.ts b/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.ts deleted file mode 100644 index 6e7bb7eeff4..00000000000 --- a/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - MobileWebBrowserEventSchema, - type MobileWebBrowserEvent -} from '../../../src/shared/mobile-web/browser-operation-contract' -import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy' - -export function sanitizeMobileWebBrowserEvent(value: unknown): MobileWebBrowserEvent | null { - if (!isRecord(value)) { - return null - } - if (value.type === 'ready' || value.type === 'navigation') { - const tab = isRecord(value.tab) ? value.tab : {} - return MobileWebBrowserEventSchema.parse({ - type: value.type, - tab: { - url: mobileWebPageBrowserUrl(tab.url), - title: boundedText(tab.title, 240, ''), - canGoBack: tab.canGoBack === true, - canGoForward: tab.canGoForward === true - } - }) - } - if (value.type === 'end' || value.type === 'dialogClosed') { - return { type: value.type } - } - if (value.type === 'dialog') { - return MobileWebBrowserEventSchema.parse({ - type: 'dialog', - dialogType: isDialogType(value.dialogType) ? value.dialogType : 'alert', - message: boundedText(value.message, 8192, 'Browser dialog') - }) - } - if (value.type === 'error') { - return { type: 'error', message: 'Browser stream failed.' } - } - return null -} - -function boundedText(value: unknown, maximum: number, fallback: string): string { - return typeof value === 'string' ? value.slice(0, maximum) : fallback -} - -function isDialogType(value: unknown): value is 'alert' | 'confirm' | 'prompt' | 'beforeunload' { - return value === 'alert' || value === 'confirm' || value === 'prompt' || value === 'beforeunload' -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/mobile/src/mobile-web/mobile-web-browser-operations.test.ts b/mobile/src/mobile-web/mobile-web-browser-operations.test.ts deleted file mode 100644 index ca169f0d701..00000000000 --- a/mobile/src/mobile-web/mobile-web-browser-operations.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { executeMobileWebBrowserOperation } from './mobile-web-browser-operations' -import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' - -describe('mobile web browser operations', () => { - it('resolves opaque workspace authority and forwards the host page id for navigation', async () => { - const { workspaceAuthority, workspaceId, pageId } = authorities() - const sendRequest = vi.fn().mockResolvedValue({ - ok: true, - result: { url: 'https://example.com/', title: 'Example', rawPageId: 'raw-page' } - }) - - await expect( - executeMobileWebBrowserOperation({ - operation: 'navigate', - payload: { workspaceId, pageId, url: 'https://example.com' }, - client: { sendRequest } as unknown as RpcClient, - workspaceAuthority - }) - ).resolves.toEqual({ url: 'https://example.com/' }) - - expect(sendRequest).toHaveBeenCalledWith( - 'browser.goto', - { - worktree: 'id:host-workspace', - page: 'raw-page', - url: 'https://example.com' - }, - { timeoutMs: 30_000 } - ) - }) - - it('removes credentials from the authoritative navigation result', async () => { - const { workspaceAuthority, workspaceId, pageId } = authorities() - const sendRequest = vi.fn().mockResolvedValue({ - ok: true, - result: { - url: 'https://user:password@example.com/callback?code=secret&tab=review#access_token=secret' - } - }) - - await expect( - executeMobileWebBrowserOperation({ - operation: 'navigate', - payload: { workspaceId, pageId, url: 'https://example.com' }, - client: { sendRequest } as unknown as RpcClient, - workspaceAuthority - }) - ).resolves.toEqual({ url: 'https://example.com/callback?tab=review' }) - }) - - it('keeps pointer fallback native and refuses an unknown page workspace', async () => { - const { workspaceAuthority, workspaceId, pageId } = authorities() - const sendRequest = vi - .fn() - .mockResolvedValueOnce({ ok: false, error: { code: 'unsupported', message: 'unsupported' } }) - .mockResolvedValue({ ok: true, result: null }) - const client = { sendRequest } as unknown as RpcClient - - await executeMobileWebBrowserOperation({ - operation: 'pointer', - payload: { - workspaceId, - pageId, - action: 'click', - x: 20, - y: 30, - button: 'left', - modifiers: [] - }, - client, - workspaceAuthority - }) - - expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ - 'browser.mouseClick', - 'browser.mouseMove', - 'browser.mouseDown', - 'browser.mouseUp' - ]) - - workspaceAuthority.synchronize([]) - await expect( - executeMobileWebBrowserOperation({ - operation: 'reload', - payload: { workspaceId, pageId }, - client, - workspaceAuthority - }) - ).rejects.toMatchObject({ code: 'not_found' }) - }) -}) - -function authorities(): { - workspaceAuthority: MobileWebWorkspaceAuthority - workspaceId: string - pageId: string -} { - const randomBytes = (length: number): Uint8Array => new Uint8Array(length).fill(3) - const workspaceAuthority = new MobileWebWorkspaceAuthority(randomBytes) - workspaceAuthority.synchronize([{ workspaceId: 'host-workspace', repoId: 'repo-1' }]) - return { - workspaceAuthority, - workspaceId: workspaceAuthority.pageWorkspaceId('host-workspace'), - pageId: 'raw-page' - } -} diff --git a/mobile/src/mobile-web/mobile-web-browser-operations.ts b/mobile/src/mobile-web/mobile-web-browser-operations.ts deleted file mode 100644 index a3123b74a90..00000000000 --- a/mobile/src/mobile-web/mobile-web-browser-operations.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { - MobileWebBrowserCommandResultSchema, - MobileWebBrowserDialogPayloadSchema, - MobileWebBrowserKeyboardPayloadSchema, - MobileWebBrowserNavigatePayloadSchema, - MobileWebBrowserNavigateResultSchema, - MobileWebBrowserPointerPayloadSchema, - MobileWebBrowserTargetPayloadSchema -} from '../../../src/shared/mobile-web/browser-operation-contract' -import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy' -import type { RpcClient } from '../transport/rpc-client' -import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error' -import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' - -export async function executeMobileWebBrowserOperation(args: { - operation: string - payload: unknown - client: RpcClient - workspaceAuthority: MobileWebWorkspaceAuthority -}): Promise { - if (args.operation === 'navigate') { - const payload = MobileWebBrowserNavigatePayloadSchema.parse(args.payload) - const target = resolveTarget(payload, args.workspaceAuthority) - const response = await args.client.sendRequest( - 'browser.goto', - { ...target, url: payload.url }, - { timeoutMs: 30_000 } - ) - const result = requireResult(response) - const parsed = MobileWebBrowserNavigateResultSchema.safeParse({ - url: isRecord(result) ? mobileWebPageBrowserUrl(result.url) : 'about:blank' - }) - if (!parsed.success) { - throw new MobileWebBrokerError('host_error') - } - return parsed.data - } - if (args.operation === 'pointer') { - const payload = MobileWebBrowserPointerPayloadSchema.parse(args.payload) - const target = resolveTarget(payload, args.workspaceAuthority) - if (payload.action === 'scroll') { - await requireRequest(args.client, 'browser.mouseMove', { - ...target, - x: payload.x, - y: payload.y - }) - assertTarget(payload, target, args.workspaceAuthority) - await requireRequest(args.client, 'browser.mouseWheel', { - ...target, - dx: payload.dx, - dy: payload.dy - }) - return MobileWebBrowserCommandResultSchema.parse(null) - } - const click = await args.client.sendRequest( - 'browser.mouseClick', - { - ...target, - x: payload.x, - y: payload.y, - button: payload.button, - modifiers: payload.modifiers, - ...(payload.radius === undefined ? {} : { radius: payload.radius }) - }, - { timeoutMs: 5_000 } - ) - if (!click.ok && payload.modifiers.length === 0) { - assertTarget(payload, target, args.workspaceAuthority) - await requireRequest(args.client, 'browser.mouseMove', { - ...target, - x: payload.x, - y: payload.y - }) - assertTarget(payload, target, args.workspaceAuthority) - await requireRequest(args.client, 'browser.mouseDown', { - ...target, - button: payload.button - }) - assertTarget(payload, target, args.workspaceAuthority) - await requireRequest(args.client, 'browser.mouseUp', { - ...target, - button: payload.button - }) - } - return MobileWebBrowserCommandResultSchema.parse(null) - } - if (args.operation === 'keyboard') { - const payload = MobileWebBrowserKeyboardPayloadSchema.parse(args.payload) - const target = resolveTarget(payload, args.workspaceAuthority) - await requireRequest( - args.client, - payload.action === 'insertText' ? 'browser.keyboardInsertText' : 'browser.keypress', - payload.action === 'insertText' - ? { ...target, text: payload.text } - : { ...target, key: payload.key }, - 5_000 - ) - return MobileWebBrowserCommandResultSchema.parse(null) - } - if (args.operation === 'dialog') { - const payload = MobileWebBrowserDialogPayloadSchema.parse(args.payload) - const target = resolveTarget(payload, args.workspaceAuthority) - await requireRequest( - args.client, - payload.action === 'accept' ? 'browser.dialogAccept' : 'browser.dialogDismiss', - target, - 5_000 - ) - return MobileWebBrowserCommandResultSchema.parse(null) - } - if (args.operation === 'back' || args.operation === 'forward' || args.operation === 'reload') { - const payload = MobileWebBrowserTargetPayloadSchema.parse(args.payload) - const target = resolveTarget(payload, args.workspaceAuthority) - await requireRequest(args.client, `browser.${args.operation}`, target) - return MobileWebBrowserCommandResultSchema.parse(null) - } - throw new MobileWebBrokerError('unsupported_capability') -} - -// Re-reads the workspace binding mid-operation: a workspace switch must not land later frames. -function assertTarget( - payload: { workspaceId: string; pageId: string }, - expected: { worktree: string; page: string }, - workspaceAuthority: MobileWebWorkspaceAuthority -): void { - const current = resolveTarget(payload, workspaceAuthority) - if (current.worktree !== expected.worktree || current.page !== expected.page) { - throw new MobileWebBrokerError('conflict') - } -} - -function resolveTarget( - payload: { workspaceId: string; pageId: string }, - workspaceAuthority: MobileWebWorkspaceAuthority -): { worktree: string; page: string } { - return { - worktree: `id:${workspaceAuthority.hostWorkspaceId(payload.workspaceId)}`, - page: payload.pageId - } -} - -async function requireRequest( - client: RpcClient, - method: string, - payload: unknown, - timeoutMs = 15_000 -): Promise { - requireResult(await client.sendRequest(method, payload, { timeoutMs })) -} - -function requireResult(response: Awaited>): unknown { - if (!response.ok) { - throw mobileWebBrokerHostRpcError(response.error) - } - return response.result -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/mobile/src/mobile-web/mobile-web-browser-streams.test.ts b/mobile/src/mobile-web/mobile-web-browser-streams.test.ts deleted file mode 100644 index b8c06ad9f88..00000000000 --- a/mobile/src/mobile-web/mobile-web-browser-streams.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - BrowserScreencastOpcode, - type BrowserScreencastFrame -} from '../transport/browser-screencast-protocol' -import type { RpcClient } from '../transport/rpc-client' -import { MobileWebBrowserStreams } from './mobile-web-browser-streams' -import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' - -describe('mobile web browser streams', () => { - it('chunks frames below the bridge limit and cleans up', async () => { - const randomBytes = (length: number): Uint8Array => new Uint8Array(length).fill(4) - const workspaceAuthority = new MobileWebWorkspaceAuthority(randomBytes) - workspaceAuthority.synchronize([{ workspaceId: 'host-workspace', repoId: 'repo-1' }]) - const workspaceId = workspaceAuthority.pageWorkspaceId('host-workspace') - const pageId = 'raw-page' - const postEvent = vi.fn(async () => {}) - let onEvent: ((event: unknown) => void) | undefined - let onFrame: ((frame: BrowserScreencastFrame) => void) | undefined - const unsubscribe = vi.fn() - const subscribe = vi - .fn() - .mockImplementation((_method, _payload, eventListener, options) => { - onEvent = eventListener - onFrame = options?.onBinaryFrame - return unsubscribe - }) - const streams = new MobileWebBrowserStreams({ - isActive: () => true, - workspaceAuthority, - postEvent, - postClosed: vi.fn() - }) - - streams.start({ - requestId: 'request-1', - subscriptionId: 'subscription-1', - payload: { - workspaceId, - pageId, - format: 'jpeg', - quality: 72, - maxWidth: 800, - maxHeight: 600, - everyNthFrame: 1, - minFrameIntervalMs: 100 - }, - client: { subscribe } as unknown as RpcClient - }) - - expect(subscribe).toHaveBeenCalledWith( - 'browser.screencast', - { - worktree: 'id:host-workspace', - page: 'raw-page', - format: 'jpeg', - quality: 72, - maxWidth: 800, - maxHeight: 600, - everyNthFrame: 1, - minFrameIntervalMs: 100 - }, - expect.any(Function), - { onBinaryFrame: expect.any(Function) } - ) - - onEvent?.({ - type: 'ready', - browserPageId: 'raw-page', - tab: { - url: 'https://example.com', - title: 'Example', - canGoBack: true, - canGoForward: false, - rawSecret: 'must-not-cross' - } - }) - onEvent?.({ - type: 'navigation', - tab: { - url: 'https://www.iana.org/help/example-domains', - title: 'IANA-managed Reserved Domains', - canGoBack: true, - canGoForward: false - } - }) - const image = new Uint8Array(200_000).map((_, index) => index % 251) - onFrame?.({ - opcode: BrowserScreencastOpcode.Frame, - seq: 7, - format: 'jpeg', - metadata: { deviceWidth: 800, deviceHeight: 600 }, - image - }) - - await vi.waitFor(() => expect(postEvent).toHaveBeenCalledTimes(4)) - expect(postEvent.mock.calls.map(([, sequence]) => sequence)).toEqual([0, 1, 2, 3]) - expect(postEvent.mock.calls[0]?.[2]).toEqual({ - type: 'ready', - tab: { - url: 'https://example.com', - title: 'Example', - canGoBack: true, - canGoForward: false - } - }) - expect(postEvent.mock.calls[1]?.[2]).toEqual({ - type: 'navigation', - tab: { - url: 'https://www.iana.org/help/example-domains', - title: 'IANA-managed Reserved Domains', - canGoBack: true, - canGoForward: false - } - }) - const chunks = postEvent.mock.calls.slice(2).map((call) => call[2]) - expect(chunks).toMatchObject([ - { type: 'frameChunk', frameSequence: 7, chunkIndex: 0, chunkCount: 2 }, - { type: 'frameChunk', frameSequence: 7, chunkIndex: 1, chunkCount: 2 } - ]) - expect(JSON.stringify(chunks)).not.toContain('raw-page') - - expect(streams.cancel('subscription-1')).toBe('request-1') - expect(unsubscribe).toHaveBeenCalledOnce() - }) -}) diff --git a/mobile/src/mobile-web/mobile-web-browser-streams.ts b/mobile/src/mobile-web/mobile-web-browser-streams.ts deleted file mode 100644 index f56fd5a4258..00000000000 --- a/mobile/src/mobile-web/mobile-web-browser-streams.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - MobileWebSubscriptionLedger, - type MobileWebSubscriptionLedgerConfig, - type MobileWebSubscriptionRecord -} from './mobile-web-subscription-ledger' -import { Buffer } from 'buffer/' -import { - MOBILE_WEB_BROWSER_FRAME_CHUNK_BYTES, - MOBILE_WEB_BROWSER_FRAME_MAX_IMAGE_BYTES, - MobileWebBrowserEventSchema, - MobileWebBrowserStreamPayloadSchema, - type MobileWebBrowserEvent -} from '../../../src/shared/mobile-web/browser-operation-contract' -import type { - BrowserScreencastFrame, - BrowserScreencastFrameMetadata -} from '../transport/browser-screencast-protocol' -import type { RpcClient } from '../transport/rpc-client' -import { sanitizeMobileWebBrowserEvent } from './mobile-web-browser-event-sanitizer' -import { MobileWebBrokerError } from './mobile-web-broker-error' -import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' - -type ScreencastRecord = MobileWebSubscriptionRecord & { - frameQueued: boolean - pendingFrame: BrowserScreencastFrame | null -} - -type BrowserLedgerConfig = MobileWebSubscriptionLedgerConfig & { - workspaceAuthority: MobileWebWorkspaceAuthority -} - -export class MobileWebBrowserStreams extends MobileWebSubscriptionLedger< - MobileWebBrowserEvent, - ScreencastRecord -> { - constructor(private readonly config: BrowserLedgerConfig) { - super({ ...config, operationKey: 'browser.subscribe' }) - } - - start(args: { - requestId: string - subscriptionId: string - payload: unknown - client: RpcClient - }): void { - this.admit(args.subscriptionId) - const payload = MobileWebBrowserStreamPayloadSchema.parse(args.payload) - const hostWorkspaceId = this.config.workspaceAuthority.hostWorkspaceId(payload.workspaceId) - const record: ScreencastRecord = { - ...this.newRecord(args.requestId), - frameQueued: false, - pendingFrame: null - } - this.open(args.subscriptionId, record, () => - args.client.subscribe( - 'browser.screencast', - { - worktree: `id:${hostWorkspaceId}`, - page: payload.pageId, - format: payload.format, - quality: payload.quality, - maxWidth: payload.maxWidth, - maxHeight: payload.maxHeight, - everyNthFrame: payload.everyNthFrame, - minFrameIntervalMs: payload.minFrameIntervalMs, - ...(payload.viewportWidth === undefined ? {} : { viewportWidth: payload.viewportWidth }), - ...(payload.viewportHeight === undefined - ? {} - : { viewportHeight: payload.viewportHeight }), - ...(payload.deviceScaleFactor === undefined - ? {} - : { deviceScaleFactor: payload.deviceScaleFactor }), - ...(payload.mobile === undefined ? {} : { mobile: payload.mobile }) - }, - (event) => this.receiveEvent(args.subscriptionId, record, event), - { - onBinaryFrame: (frame) => this.receiveFrame(args.subscriptionId, record, frame) - } - ) - ) - } - - // Drops the parked image so a retired stream cannot pin a multi-megabyte frame. - protected override retire(record: ScreencastRecord): void { - record.pendingFrame = null - } - - private receiveEvent(subscriptionId: string, record: ScreencastRecord, value: unknown): void { - if (!this.isCurrent(subscriptionId, record)) { - return - } - const event = sanitizeMobileWebBrowserEvent(value) - if (event) { - this.enqueueTask(subscriptionId, record, () => this.deliver(subscriptionId, record, event)) - } - } - - private receiveFrame( - subscriptionId: string, - record: ScreencastRecord, - frame: BrowserScreencastFrame - ): void { - if (!this.isCurrent(subscriptionId, record)) { - return - } - if (frame.image.byteLength > MOBILE_WEB_BROWSER_FRAME_MAX_IMAGE_BYTES) { - this.enqueueTask(subscriptionId, record, () => - this.deliver(subscriptionId, record, { - type: 'error', - message: 'Browser frame is too large to display safely.' - }) - ) - return - } - record.pendingFrame = frame - if (record.frameQueued) { - return - } - record.frameQueued = true - this.enqueueTask(subscriptionId, record, async () => { - const latest = record.pendingFrame - record.pendingFrame = null - if (latest) { - await this.deliverFrame(subscriptionId, record, latest) - } - record.frameQueued = false - const pending = record.pendingFrame - if (pending && this.isCurrent(subscriptionId, record)) { - record.pendingFrame = null - this.receiveFrame(subscriptionId, record, pending) - } - }) - } - - private async deliverFrame( - subscriptionId: string, - record: ScreencastRecord, - frame: BrowserScreencastFrame - ): Promise { - const chunkCount = Math.ceil(frame.image.byteLength / MOBILE_WEB_BROWSER_FRAME_CHUNK_BYTES) - for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) { - if (!this.isCurrent(subscriptionId, record)) { - return - } - const start = chunkIndex * MOBILE_WEB_BROWSER_FRAME_CHUNK_BYTES - const end = Math.min(frame.image.byteLength, start + MOBILE_WEB_BROWSER_FRAME_CHUNK_BYTES) - await this.deliver(subscriptionId, record, { - type: 'frameChunk', - frameSequence: frame.seq, - format: frame.format, - metadata: boundedMetadata(frame.metadata), - imageBytes: frame.image.byteLength, - chunkIndex, - chunkCount, - data: Buffer.from(frame.image.subarray(start, end)).toString('base64') - }) - } - } - - // Why the sequence is claimed here and not at enqueue time: one queued frame task posts a whole - // chunk run, so the numbers must be handed out per post, in post order. - private async deliver( - subscriptionId: string, - record: ScreencastRecord, - value: MobileWebBrowserEvent - ): Promise { - const event = MobileWebBrowserEventSchema.parse(value) - const sequence = record.sequence - record.sequence += 1 - await this.options.postEvent(subscriptionId, sequence, event) - } -} - -function boundedMetadata(metadata: BrowserScreencastFrameMetadata): BrowserScreencastFrameMetadata { - const parsed = MobileWebBrowserEventSchema.parse({ - type: 'frameChunk', - frameSequence: 0, - format: 'jpeg', - metadata, - imageBytes: 1, - chunkIndex: 0, - chunkCount: 1, - data: 'AA==' - }) - if (parsed.type !== 'frameChunk') { - throw new MobileWebBrokerError('host_error') - } - return parsed.metadata -} diff --git a/mobile/src/mobile-web/mobile-web-production-browser-grants.ts b/mobile/src/mobile-web/mobile-web-production-browser-grants.ts deleted file mode 100644 index a2595733af7..00000000000 --- a/mobile/src/mobile-web/mobile-web-production-browser-grants.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { capabilityGrants, grantLimits } from './mobile-web-production-grant-table' - -export const MOBILE_WEB_PRODUCTION_BROWSER_GRANTS = capabilityGrants('browser', { - subscribe: grantLimits(4 * 1024, 1 * 1024, 1, 4, 1), - navigate: grantLimits(8 * 1024, 8 * 1024, 1, 8, 2), - back: grantLimits(2 * 1024, 256, 2, 12, 4), - forward: grantLimits(2 * 1024, 256, 2, 12, 4), - reload: grantLimits(2 * 1024, 256, 2, 12, 4), - dialog: grantLimits(2 * 1024, 256, 2, 12, 4), - pointer: grantLimits(4 * 1024, 256, 2, 40, 20), - keyboard: grantLimits(40 * 1024, 256, 2, 20, 10) -}) diff --git a/src/main/runtime/rpc/methods/mobile-web-browser-input.test.ts b/src/main/runtime/rpc/methods/mobile-web-browser-input.test.ts index 576568f5285..f81ff0a0314 100644 --- a/src/main/runtime/rpc/methods/mobile-web-browser-input.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-browser-input.test.ts @@ -138,7 +138,7 @@ describe('host-owned browser input', () => { let rejected = 0 for (let attempt = 0; attempt < 60; attempt += 1) { - await pointer.handler(move, f.context).catch(() => { + await Promise.resolve(pointer.handler(move, f.context)).catch(() => { rejected += 1 }) } diff --git a/src/main/runtime/rpc/methods/mobile-web-browser-stream.test.ts b/src/main/runtime/rpc/methods/mobile-web-browser-stream.test.ts index a075693e234..c51560fe59a 100644 --- a/src/main/runtime/rpc/methods/mobile-web-browser-stream.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-browser-stream.test.ts @@ -3,13 +3,14 @@ import { BrowserScreencastOpcode, encodeBrowserScreencastFrame } from '../../../../shared/browser-screencast-protocol' -import type { RpcContext } from '../core' +import { isStreamingMethod, type RpcContext, type RpcMethod } from '../core' import { isMobileWebHostRpcMethod } from './mobile-web-host-rpc-allowlist' import { MOBILE_WEB_BROWSER_STREAM_METHODS } from './mobile-web-browser-stream' -const methods = new Map(MOBILE_WEB_BROWSER_STREAM_METHODS.map((method) => [method.name, method])) -const subscribe = methods.get('mobileWeb.browser.subscribe')! -const unsubscribe = methods.get('mobileWeb.browser.unsubscribe')! +const subscribe = MOBILE_WEB_BROWSER_STREAM_METHODS.find(isStreamingMethod)! +const unsubscribe = MOBILE_WEB_BROWSER_STREAM_METHODS.find( + (method) => !isStreamingMethod(method) +) as RpcMethod const REQUEST = { worktree: 'id:workspace', diff --git a/src/mobile-web/src/mobile-web-bridge-client.ts b/src/mobile-web/src/mobile-web-bridge-client.ts index cba8b1ec43c..978c2e1eac5 100644 --- a/src/mobile-web/src/mobile-web-bridge-client.ts +++ b/src/mobile-web/src/mobile-web-bridge-client.ts @@ -25,6 +25,7 @@ import type { } from '../../shared/mobile-web/browser-operation-contract' import type { MobileWebBrowserRequestClient } from './mobile-web-browser-request-client' import { mobileWebBrowserNavigationClientBindings } from './mobile-web-browser-navigation-client-bindings' +import { subscribeMobileWebHostBrowser } from './mobile-web-host-browser-subscription' import { MobileWebAccountRequestClient } from './mobile-web-account-request-client' import { MobileWebAgentHistoryRequestClient } from './mobile-web-agent-history-request-client' import { mobileWebFileClientBindings } from './mobile-web-file-client-bindings' @@ -258,7 +259,7 @@ export class MobileWebBridgeClient { onEvent: (event: MobileWebBrowserEvent) => void, onError: (error: MobileWebBridgeClientError) => void ): MobileWebBridgeSubscription { - return this.subscriptions.subscribeBrowser(payload, onEvent, onError) + return subscribeMobileWebHostBrowser(this.subscriptions, payload, onEvent, onError) } receive(message: MobileWebBridgeShellMessage): void { diff --git a/src/mobile-web/src/mobile-web-bridge-subscription-client.ts b/src/mobile-web/src/mobile-web-bridge-subscription-client.ts index 25ea6873d36..b0c24990b0d 100644 --- a/src/mobile-web/src/mobile-web-bridge-subscription-client.ts +++ b/src/mobile-web/src/mobile-web-bridge-subscription-client.ts @@ -19,7 +19,6 @@ import { deliverMobileWebSubscriptionEvent } from './mobile-web-bridge-subscript import { accountSubscriptionSetup, terminalSubscriptionSetup, - browserSubscriptionSetup, speechSubscriptionSetup, workspaceSubscriptionSetup, type MobileWebBridgeSubscriptionSetup @@ -94,10 +93,6 @@ export class MobileWebBridgeSubscriptionClient { return this.subscribeWith(hostSubscriptionSetup(...args)) } - subscribeBrowser(...args: Parameters) { - return this.subscribeWith(browserSubscriptionSetup(...args)) - } - subscribeSpeech(...args: Parameters) { return this.subscribeWith(speechSubscriptionSetup(...args)) } diff --git a/src/mobile-web/src/mobile-web-bridge-subscription-setup.ts b/src/mobile-web/src/mobile-web-bridge-subscription-setup.ts index bedfd657c3b..284fa77fa49 100644 --- a/src/mobile-web/src/mobile-web-bridge-subscription-setup.ts +++ b/src/mobile-web/src/mobile-web-bridge-subscription-setup.ts @@ -17,12 +17,6 @@ import { type MobileWebWorkspaceChange } from '../../shared/mobile-web/bridge-operation-contract' import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' -import { - MobileWebBrowserEventSchema, - MobileWebBrowserStreamPayloadSchema, - type MobileWebBrowserEvent, - type MobileWebBrowserStreamPayload -} from '../../shared/mobile-web/browser-operation-contract' import { MobileWebSpeechEventSchema, MobileWebSpeechSubscribePayloadSchema, @@ -67,21 +61,6 @@ export function speechSubscriptionSetup( } } -export function browserSubscriptionSetup( - payload: MobileWebBrowserStreamPayload, - onEvent: (event: MobileWebBrowserEvent) => void, - onError: (error: MobileWebBridgeClientError) => void -): MobileWebBridgeSubscriptionSetup { - return { - capability: 'browser', - payload, - payloadSchema: MobileWebBrowserStreamPayloadSchema, - eventSchema: MobileWebBrowserEventSchema, - onEvent: (value) => onEvent(value as MobileWebBrowserEvent), - onError - } -} - export function workspaceSubscriptionSetup( onEvent: (event: MobileWebWorkspaceChange) => void, onError: (error: MobileWebBridgeClientError) => void diff --git a/src/mobile-web/src/mobile-web-browser-request-client.test.ts b/src/mobile-web/src/mobile-web-browser-request-client.test.ts new file mode 100644 index 00000000000..dd160801a80 --- /dev/null +++ b/src/mobile-web/src/mobile-web-browser-request-client.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import { MobileWebBrowserRequestClient } from './mobile-web-browser-request-client' +import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' + +const TARGET = { workspaceId: 'workspace-1', pageId: 'browser-1' } as const + +function fixture(result: unknown = { applied: true }) { + const request = vi.fn(async (..._args: unknown[]) => result) + const requests = { supports: () => true, request } as unknown as MobileWebOneShotRequestClient + return { request, client: new MobileWebBrowserRequestClient(requests) } +} + +function payloads( + request: ReturnType['request'] +): { method: string; workspaceId?: string; params: Record }[] { + return request.mock.calls.map( + (call) => call[2] as { method: string; workspaceId?: string; params: Record } + ) +} + +describe('page-owned browser commands', () => { + it('names the workspace in the envelope and the page in the params', async () => { + const f = fixture({ url: 'https://app.example/' }) + + await expect(f.client.navigate({ ...TARGET, url: 'https://app.example/' })).resolves.toEqual({ + url: 'https://app.example/' + }) + + expect(payloads(f.request)).toEqual([ + { + method: 'mobileWeb.browser.navigate', + workspaceId: 'workspace-1', + params: { page: 'browser-1', url: 'https://app.example/' } + } + ]) + }) + + it('sends one host request per pointer, keyboard and dialog action', async () => { + const f = fixture() + + await expect( + f.client.pointer({ ...TARGET, action: 'scroll', x: 1, y: 2, dx: 0, dy: -30 }) + ).resolves.toBeNull() + await f.client.pointer({ + ...TARGET, + action: 'click', + x: 3, + y: 4, + button: 'left', + modifiers: ['cmd'], + radius: 8 + }) + await f.client.keyboard({ ...TARGET, action: 'insertText', text: 'hi' }) + await f.client.keyboard({ ...TARGET, action: 'keypress', key: 'Escape' }) + await f.client.dialog({ ...TARGET, action: 'accept' }) + + expect(payloads(f.request)).toEqual([ + { + method: 'mobileWeb.browser.pointer', + workspaceId: 'workspace-1', + params: { page: 'browser-1', action: 'scroll', x: 1, y: 2, dx: 0, dy: -30 } + }, + { + method: 'mobileWeb.browser.pointer', + workspaceId: 'workspace-1', + params: { + page: 'browser-1', + action: 'click', + x: 3, + y: 4, + button: 'left', + modifiers: ['cmd'], + radius: 8 + } + }, + { + method: 'mobileWeb.browser.keyboard', + workspaceId: 'workspace-1', + params: { page: 'browser-1', action: 'insertText', text: 'hi' } + }, + { + method: 'mobileWeb.browser.keyboard', + workspaceId: 'workspace-1', + params: { page: 'browser-1', action: 'keypress', key: 'Escape' } + }, + { + method: 'mobileWeb.browser.dialog', + workspaceId: 'workspace-1', + params: { page: 'browser-1', action: 'accept' } + } + ]) + }) + + it('carries back, forward and reload as one history method', async () => { + const f = fixture() + + await f.client.back(TARGET) + await f.client.forward(TARGET) + await f.client.reload(TARGET) + + expect(payloads(f.request)).toEqual( + ['back', 'forward', 'reload'].map((action) => ({ + method: 'mobileWeb.browser.history', + workspaceId: 'workspace-1', + params: { page: 'browser-1', action } + })) + ) + }) + + it('rejects a command the host did not acknowledge as applied', async () => { + const f = fixture({ applied: false }) + + await expect(f.client.reload(TARGET)).rejects.toMatchObject({ code: 'invalid_message' }) + }) + + it('rejects a navigation result with no URL and tolerates an unknown field', async () => { + const f = fixture({ title: 'App' }) + await expect(f.client.navigate({ ...TARGET, url: 'https://a.example/' })).rejects.toMatchObject( + { + code: 'invalid_message' + } + ) + + const forwardCompatible = fixture({ url: 'https://a.example/', title: 'App' }) + await expect( + forwardCompatible.client.navigate({ ...TARGET, url: 'https://a.example/' }) + ).resolves.toEqual({ url: 'https://a.example/' }) + }) + + it('never forwards the opaque workspace handle inside the host params', async () => { + const f = fixture() + + await f.client.dialog({ ...TARGET, action: 'dismiss' }) + + expect(JSON.stringify(payloads(f.request)[0]!.params)).not.toContain('workspace-1') + }) +}) diff --git a/src/mobile-web/src/mobile-web-browser-request-client.ts b/src/mobile-web/src/mobile-web-browser-request-client.ts index dde0c6f9745..6aafb8b3693 100644 --- a/src/mobile-web/src/mobile-web-browser-request-client.ts +++ b/src/mobile-web/src/mobile-web-browser-request-client.ts @@ -1,32 +1,39 @@ +import type { z } from 'zod' import { - MobileWebBrowserCommandResultSchema, - MobileWebBrowserDialogPayloadSchema, - MobileWebBrowserKeyboardPayloadSchema, - MobileWebBrowserNavigatePayloadSchema, + MobileWebBrowserAckSchema, MobileWebBrowserNavigateResultSchema, - MobileWebBrowserPointerPayloadSchema, - MobileWebBrowserTargetPayloadSchema, type MobileWebBrowserDialogPayload, type MobileWebBrowserKeyboardPayload, type MobileWebBrowserNavigatePayload, type MobileWebBrowserPointerPayload, type MobileWebBrowserTargetPayload } from '../../shared/mobile-web/browser-operation-contract' +import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state' +import { requestMobileWebHost } from './mobile-web-host-request-client' import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' +/** The workspace handle rides in the host request envelope, and the browser page id is the only + * target field the desktop wrapper reads. */ +function hostParams({ + workspaceId: _workspaceId, + pageId, + ...fields +}: TPayload): Record { + return { page: pageId, ...fields } +} + export class MobileWebBrowserRequestClient { constructor(private readonly requests: MobileWebOneShotRequestClient) {} - navigate( + async navigate( payload: MobileWebBrowserNavigatePayload, options?: MobileWebBridgeRequestOptions ): Promise<{ url: string }> { - return this.requests.request( - 'browser', - 'navigate', + return this.send( + 'mobileWeb.browser.navigate', payload, - MobileWebBrowserNavigatePayloadSchema, + hostParams(payload), MobileWebBrowserNavigateResultSchema, options ) @@ -36,77 +43,85 @@ export class MobileWebBrowserRequestClient { payload: MobileWebBrowserPointerPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.requests.request( - 'browser', - 'pointer', - payload, - MobileWebBrowserPointerPayloadSchema, - MobileWebBrowserCommandResultSchema, - options - ) + return this.command('mobileWeb.browser.pointer', payload, hostParams(payload), options) } keyboard( payload: MobileWebBrowserKeyboardPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.requests.request( - 'browser', - 'keyboard', - payload, - MobileWebBrowserKeyboardPayloadSchema, - MobileWebBrowserCommandResultSchema, - options - ) + return this.command('mobileWeb.browser.keyboard', payload, hostParams(payload), options) } dialog( payload: MobileWebBrowserDialogPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.requests.request( - 'browser', - 'dialog', - payload, - MobileWebBrowserDialogPayloadSchema, - MobileWebBrowserCommandResultSchema, - options - ) + return this.command('mobileWeb.browser.dialog', payload, hostParams(payload), options) } back( payload: MobileWebBrowserTargetPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.command('back', payload, options) + return this.history('back', payload, options) } forward( payload: MobileWebBrowserTargetPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.command('forward', payload, options) + return this.history('forward', payload, options) } reload( payload: MobileWebBrowserTargetPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.command('reload', payload, options) + return this.history('reload', payload, options) } - private command( - operation: 'back' | 'forward' | 'reload', + private history( + action: 'back' | 'forward' | 'reload', payload: MobileWebBrowserTargetPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.requests.request( - 'browser', - operation, + return this.command( + 'mobileWeb.browser.history', payload, - MobileWebBrowserTargetPayloadSchema, - MobileWebBrowserCommandResultSchema, + { ...hostParams(payload), action }, options ) } + + private async command( + method: string, + payload: MobileWebBrowserTargetPayload, + params: Record, + options?: MobileWebBridgeRequestOptions + ): Promise { + await this.send(method, payload, params, MobileWebBrowserAckSchema, options) + return null + } + + private async send( + method: string, + payload: MobileWebBrowserTargetPayload, + params: Record, + schema: z.ZodType, + options?: MobileWebBridgeRequestOptions + ): Promise { + const result = await requestMobileWebHost( + this.requests, + method, + payload.workspaceId, + params, + options + ) + const parsed = schema.safeParse(result) + if (!parsed.success) { + throw new MobileWebBridgeClientError('invalid_message', false) + } + return parsed.data + } } diff --git a/src/mobile-web/src/mobile-web-host-browser-subscription.test.ts b/src/mobile-web/src/mobile-web-host-browser-subscription.test.ts new file mode 100644 index 00000000000..3a8c481be9c --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-browser-subscription.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MobileWebBrowserEvent } from '../../shared/mobile-web/browser-operation-contract' +import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' +import type { MobileWebBridgeSubscriptionClient } from './mobile-web-bridge-subscription-client' +import { subscribeMobileWebHostBrowser } from './mobile-web-host-browser-subscription' + +const PAYLOAD = { + workspaceId: 'workspace-1', + pageId: 'browser-1', + format: 'jpeg' as const, + quality: 72, + maxWidth: 800, + maxHeight: 600, + everyNthFrame: 1, + minFrameIntervalMs: 100 +} + +function fixture() { + const unsubscribe = vi.fn() + let emit: (event: unknown) => void = () => {} + const subscribeHost = vi.fn((_payload: unknown, onEvent: (event: unknown) => void) => { + emit = onEvent + return { ready: Promise.resolve(), unsubscribe } + }) + const subscriptions = { subscribeHost } as unknown as MobileWebBridgeSubscriptionClient + const events: MobileWebBrowserEvent[] = [] + const errors: MobileWebBridgeClientError[] = [] + const subscription = subscribeMobileWebHostBrowser( + subscriptions, + PAYLOAD, + (event) => events.push(event), + (error) => errors.push(error) + ) + return { emit: (event: unknown) => emit(event), errors, events, subscribeHost, subscription } +} + +describe('page-owned browser stream', () => { + it('opens the host stream with the page id and the screencast request', () => { + const f = fixture() + + expect(f.subscribeHost.mock.calls[0]![0]).toEqual({ + method: 'mobileWeb.browser.subscribe', + workspaceId: 'workspace-1', + params: { + page: 'browser-1', + format: 'jpeg', + quality: 72, + maxWidth: 800, + maxHeight: 600, + everyNthFrame: 1, + minFrameIntervalMs: 100 + } + }) + }) + + it('swallows the lane handshake and delivers the browser ready that carries tab state', () => { + const f = fixture() + const tab = { url: 'https://a.example/', title: 'A', canGoBack: false, canGoForward: false } + + f.emit({ type: 'ready', subscriptionId: 'lane-id' }) + f.emit({ type: 'ready', tab }) + + expect(f.events).toEqual([{ type: 'ready', tab }]) + expect(f.errors).toEqual([]) + }) + + it('reports an event the browser contract cannot describe', () => { + const f = fixture() + + f.emit({ type: 'frameChunk', chunkIndex: 4, chunkCount: 1 }) + + expect(f.events).toEqual([]) + expect(f.errors.map((error) => error.code)).toEqual(['invalid_message']) + }) + + it('stops delivering once the page unsubscribes', () => { + const f = fixture() + + f.subscription.unsubscribe() + f.emit({ type: 'dialogClosed' }) + + expect(f.events).toEqual([]) + }) + + it('refuses a screencast request the contract does not admit', async () => { + const errors: MobileWebBridgeClientError[] = [] + const subscribeHost = vi.fn() + const subscription = subscribeMobileWebHostBrowser( + { subscribeHost } as unknown as MobileWebBridgeSubscriptionClient, + { ...PAYLOAD, quality: 0 }, + () => {}, + (error) => errors.push(error) + ) + + await expect(subscription.ready).rejects.toMatchObject({ code: 'invalid_request' }) + await Promise.resolve() + expect(subscribeHost).not.toHaveBeenCalled() + expect(errors.map((error) => error.code)).toEqual(['invalid_request']) + }) +}) diff --git a/src/mobile-web/src/mobile-web-host-browser-subscription.ts b/src/mobile-web/src/mobile-web-host-browser-subscription.ts new file mode 100644 index 00000000000..f5a303950f1 --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-browser-subscription.ts @@ -0,0 +1,70 @@ +import { + MobileWebBrowserEventSchema, + MobileWebBrowserStreamPayloadSchema, + type MobileWebBrowserEvent, + type MobileWebBrowserStreamPayload +} from '../../shared/mobile-web/browser-operation-contract' +import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' +import type { MobileWebBridgeSubscription } from './mobile-web-bridge-subscription' +import type { MobileWebBridgeSubscriptionClient } from './mobile-web-bridge-subscription-client' + +export type MobileWebBrowserSubscriptionArgs = [ + payload: MobileWebBrowserStreamPayload, + onEvent: (event: MobileWebBrowserEvent) => void, + onError: (error: MobileWebBridgeClientError) => void +] + +export function subscribeMobileWebHostBrowser( + subscriptions: MobileWebBridgeSubscriptionClient, + ...[payload, onEvent, onError]: MobileWebBrowserSubscriptionArgs +): MobileWebBridgeSubscription { + if (!MobileWebBrowserStreamPayloadSchema.safeParse(payload).success) { + const error = new MobileWebBridgeClientError('invalid_request', false) + queueMicrotask(() => onError(error)) + return { ready: Promise.reject(error), unsubscribe() {} } + } + const { workspaceId, pageId, ...request } = payload + let cancelled = false + const current = subscriptions.subscribeHost( + { + method: 'mobileWeb.browser.subscribe', + workspaceId, + params: { page: pageId, ...request } + }, + (event) => { + if (cancelled || isSubscriptionHandshake(event)) { + return + } + const parsed = MobileWebBrowserEventSchema.safeParse(event) + if (!parsed.success) { + onError(new MobileWebBridgeClientError('invalid_message', false)) + return + } + onEvent(parsed.data) + }, + (error) => { + if (!cancelled) { + onError(error) + } + } + ) + return { + ready: current.ready, + unsubscribe() { + cancelled = true + current.unsubscribe() + } + } +} + +/** The lane opens with a bare `ready` carrying the cancel id; the browser's own `ready` carries + * tab state, which is what the pane waits for. */ +function isSubscriptionHandshake(event: unknown): boolean { + return ( + typeof event === 'object' && + event !== null && + 'type' in event && + event.type === 'ready' && + !('tab' in event) + ) +} diff --git a/src/shared/mobile-web/browser-operation-contract.ts b/src/shared/mobile-web/browser-operation-contract.ts index d975a7452c0..2baa24d6181 100644 --- a/src/shared/mobile-web/browser-operation-contract.ts +++ b/src/shared/mobile-web/browser-operation-contract.ts @@ -92,13 +92,14 @@ export const MobileWebBrowserDialogPayloadSchema = MobileWebBrowserTargetSchema. action: z.enum(['accept', 'dismiss']) }).strict() -export const MobileWebBrowserNavigateResultSchema = z - .object({ - url: z.string().min(1).max(MOBILE_WEB_BROWSER_URL_MAX_LENGTH) - }) - .strict() +// Desktop-produced results stay open: a newer host may add a field a cached page has never seen. +export const MobileWebBrowserNavigateResultSchema = z.object({ + url: z.string().min(1).max(MOBILE_WEB_BROWSER_URL_MAX_LENGTH) +}) -export const MobileWebBrowserCommandResultSchema = z.null() +/** Every browser command the page issues answers with this acknowledgement; the host result + * carries the raw tab URL, which the desktop wrapper never forwards. */ +export const MobileWebBrowserAckSchema = z.object({ applied: z.literal(true) }) const MobileWebBrowserTabStateSchema = z .object({