fix(desktop): drop the dead tabList override and close the hosted-bundle CI gap

- Revert CdpBridge.tabList to main's delegation: CdpBridge has no production
  constructor, browser.tabList is served by AgentBrowserBridgeTabs, and the
  override published canGoBack/canGoForward that BrowserTabInfo never declared.
  The hybrid shell reads navigation state from the screencast 'navigation'
  event, not from tabList, so nothing regresses.
- pr-code-change-scope: mobile/host-web-app and mobile/app/h import broadly
  across mobile/src, so a mobile/src edit changes out/mobile-web-rnw, which
  ships in every desktop installer. Add mobile/src/ to the carve-out and keep
  mobile test files desktop-irrelevant so the cost stays bounded.
- Move @noble/hashes to devDependencies: its only root consumers are
  src/mobile-web/src, which Metro inlines at packaging time, matching buffer.
- files.unwatch awaits teardown again on the connection-scoped path via
  cleanupIfOwnedByConnectionAndWait, so a rewatch cannot hold two watchers.
- readGuestNavigationState warns once instead of silently greying out
  Back/Forward when navigationHistory is missing.
- One exported isRecord in src/shared and one byte-length encoder in
  src/mobile-web/src; mobile/ copies left to a later pass.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-04 16:33:03 -04:00
parent 9b56c308da
commit b84ee23eec
14 changed files with 158 additions and 74 deletions
@@ -1,11 +1,24 @@
let warnedMissingNavigationHistory = false
export function readGuestNavigationState(guest: Electron.WebContents): {
canGoBack: boolean
canGoForward: boolean
} {
const history = guest.navigationHistory
// Why: Electron always provides navigationHistory, so `false` here is an API break, not a
// page with no history — say so once instead of silently greying out Back/Forward forever.
if (typeof history?.canGoBack !== 'function' || typeof history?.canGoForward !== 'function') {
if (!warnedMissingNavigationHistory) {
warnedMissingNavigationHistory = true
console.warn(
'[browser-guest] webContents.navigationHistory is unavailable; Back/Forward stay disabled'
)
}
return { canGoBack: false, canGoForward: false }
}
return {
canGoBack: history?.canGoBack?.() ?? false,
canGoForward: history?.canGoForward?.() ?? false
canGoBack: history.canGoBack(),
canGoForward: history.canGoForward()
}
}
+1 -23
View File
@@ -27,15 +27,12 @@ import type {
BrowserSelectResult,
BrowserSnapshotResult,
BrowserTabListResult,
BrowserTabInfo,
BrowserTabSwitchResult,
BrowserTypeResult,
BrowserUploadResult,
BrowserViewportResult,
BrowserWaitResult
} from '../../shared/runtime-types'
import { readGuestNavigationState } from './browser-guest-navigation-state'
import { webContents } from 'electron'
import type { BrowserManager } from './browser-manager'
import type { CdpAuxiliaryCommands, CdpTabState } from './cdp-auxiliary-commands'
import { CdpBridgeCommandSet } from './cdp-bridge-command-set'
@@ -259,26 +256,7 @@ export class CdpBridge {
}
tabList(): BrowserTabListResult {
const tabs: BrowserTabInfo[] = []
let index = 0
for (const [tabId, wcId] of this.getRegisteredTabs()) {
const guest = webContents.fromId(wcId)
if (!guest || guest.isDestroyed()) {
continue
}
tabs.push({
browserPageId: tabId,
index,
url: guest.getURL(),
title: guest.getTitle(),
active: wcId === this.activeWebContentsId,
...readGuestNavigationState(guest)
})
index++
}
return { tabs }
return this.commands.tabs.tabList()
}
async tabSwitch(index: number): Promise<BrowserTabSwitchResult> {
@@ -2,35 +2,81 @@ import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import { RuntimeSubscriptionRegistry } from '../../runtime-subscription-registry'
import { FILE_METHODS } from './files'
function unwatchRequest(subscriptionId: string): RpcRequest {
return {
id: 'req-1',
authToken: 'tok',
method: 'files.unwatch',
params: { subscriptionId }
}
}
describe('files.unwatch ownership', () => {
it('refuses teardown when the socket does not own the subscription', async () => {
const cleanupSubscriptionIfOwnedByConnection = vi.fn().mockReturnValue(false)
const cleanupSubscriptionIfOwnedByConnectionAndWait = vi.fn().mockResolvedValue(false)
const cleanupSubscriptionAndWait = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
cleanupSubscriptionIfOwnedByConnection,
cleanupSubscriptionIfOwnedByConnectionAndWait,
cleanupSubscriptionAndWait
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const request: RpcRequest = {
id: 'req-1',
authToken: 'tok',
method: 'files.unwatch',
params: { subscriptionId: 'files-watch-conn-owner-1' }
}
const replies: unknown[] = []
await dispatcher.dispatchStreaming(request, (reply) => replies.push(JSON.parse(reply)), {
connectionId: 'conn-attacker'
})
await dispatcher.dispatchStreaming(
unwatchRequest('files-watch-conn-owner-1'),
(reply) => replies.push(JSON.parse(reply)),
{ connectionId: 'conn-attacker' }
)
expect(cleanupSubscriptionIfOwnedByConnection).toHaveBeenCalledWith(
expect(cleanupSubscriptionIfOwnedByConnectionAndWait).toHaveBeenCalledWith(
'files-watch-conn-owner-1',
'conn-attacker'
)
expect(cleanupSubscriptionAndWait).not.toHaveBeenCalled()
expect(replies).toEqual([expect.objectContaining({ result: { unsubscribed: false } })])
})
// Why: returning before @parcel/watcher is released lets a rewatch hold two watchers.
it('does not reply until the owning connection teardown settles', async () => {
const subscriptions = new RuntimeSubscriptionRegistry()
let releaseWatcher: (() => void) | undefined
let released = false
subscriptions.register(
'files-watch-slow-1',
() =>
new Promise<void>((resolve) => {
releaseWatcher = () => {
released = true
resolve()
}
}),
'conn-owner'
)
const runtime = {
getRuntimeId: () => 'test-runtime',
cleanupSubscriptionIfOwnedByConnectionAndWait:
subscriptions.cleanupIfOwnedByConnectionAndWait.bind(subscriptions)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
let settled = false
const pending = dispatcher
.dispatch(unwatchRequest('files-watch-slow-1'), { connectionId: 'conn-owner' })
.then((response) => {
settled = true
return response
})
await vi.waitFor(() => expect(releaseWatcher).toBeDefined())
expect(settled).toBe(false)
expect(released).toBe(false)
releaseWatcher?.()
await expect(pending).resolves.toMatchObject({ ok: true, result: { unsubscribed: true } })
expect(released).toBe(true)
})
})
+1 -1
View File
@@ -280,7 +280,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime, connectionId }) => {
if (connectionId) {
return {
unsubscribed: runtime.cleanupSubscriptionIfOwnedByConnection(
unsubscribed: await runtime.cleanupSubscriptionIfOwnedByConnectionAndWait(
params.subscriptionId,
connectionId
)
@@ -23,6 +23,7 @@ export type RuntimeServiceCommandSurface = {
cleanupSubscriptionsByPrefix: RuntimeSubscriptionRegistry['cleanupByPrefix']
cleanupSubscriptionsForConnection: RuntimeSubscriptionRegistry['cleanupForConnection']
cleanupSubscriptionIfOwnedByConnection: RuntimeSubscriptionRegistry['cleanupIfOwnedByConnection']
cleanupSubscriptionIfOwnedByConnectionAndWait: RuntimeSubscriptionRegistry['cleanupIfOwnedByConnectionAndWait']
onNotificationDispatched: RuntimeMobileNotificationController['onDispatched']
getMobileNotificationListenerCount: RuntimeMobileNotificationController['getListenerCount']
dispatchMobileNotification: RuntimeMobileNotificationController['dispatch']
@@ -103,6 +104,8 @@ export function installRuntimeServiceCommandSurface(
cleanupSubscriptionsForConnection: subscriptions.cleanupForConnection.bind(subscriptions),
cleanupSubscriptionIfOwnedByConnection:
subscriptions.cleanupIfOwnedByConnection.bind(subscriptions),
cleanupSubscriptionIfOwnedByConnectionAndWait:
subscriptions.cleanupIfOwnedByConnectionAndWait.bind(subscriptions),
onNotificationDispatched: notifications.onDispatched.bind(notifications),
getMobileNotificationListenerCount: notifications.getListenerCount.bind(notifications),
dispatchMobileNotification: notifications.dispatch.bind(notifications),
@@ -42,18 +42,40 @@ export class RuntimeSubscriptionRegistry {
}
cleanupIfOwnedByConnection(subscriptionId: string, connectionId?: string): boolean {
if (!connectionId) {
const verdict = this.resolveConnectionOwnership(subscriptionId, connectionId)
if (verdict === 'cleanup') {
this.cleanup(subscriptionId)
return true
}
return verdict !== 'foreign'
}
// Why: an unwatch that returns before the underlying watcher is released lets a fast
// rewatch hold two watchers on one path and double-deliver change events.
async cleanupIfOwnedByConnectionAndWait(
subscriptionId: string,
connectionId?: string
): Promise<boolean> {
const verdict = this.resolveConnectionOwnership(subscriptionId, connectionId)
if (verdict === 'cleanup') {
await this.cleanupAndWait(subscriptionId)
}
return verdict !== 'foreign'
}
private resolveConnectionOwnership(
subscriptionId: string,
connectionId?: string
): 'cleanup' | 'absent' | 'foreign' {
if (!connectionId) {
return 'cleanup'
}
// An unregistered id is already gone, not refused.
if (!this.cleanups.has(subscriptionId)) {
return true
return 'absent'
}
if (this.connectionBySubscription.get(subscriptionId) !== connectionId) {
return false
}
this.cleanup(subscriptionId)
return true
return this.connectionBySubscription.get(subscriptionId) === connectionId
? 'cleanup'
: 'foreign'
}
cleanup(subscriptionId: string): void {
@@ -3,6 +3,7 @@ import {
MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS,
type MobileWebBridgeShellMessage
} from '../../shared/mobile-web/bridge-contract'
import { encodedMobileWebBridgeValueByteLength } from './mobile-web-bridge-request-encoding'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
type OperationGrant = Extract<MobileWebBridgeShellMessage, { type: 'init' }>['grants'][number]
@@ -25,7 +26,7 @@ export function mobileWebSubscriptionSetupError(args: {
if (!args.payloadValid) {
return new MobileWebBridgeClientError('invalid_request', false)
}
if (encodedByteLength(args.payload) > args.grant.limits.maxRequestBytes) {
if (encodedMobileWebBridgeValueByteLength(args.payload) > args.grant.limits.maxRequestBytes) {
return new MobileWebBridgeClientError('too_large', false)
}
if (
@@ -36,14 +37,3 @@ export function mobileWebSubscriptionSetupError(args: {
}
return null
}
function encodedByteLength(value: unknown): number {
try {
const encoded = JSON.stringify(value)
return encoded === undefined
? Number.POSITIVE_INFINITY
: new TextEncoder().encode(encoded).byteLength
} catch {
return Number.POSITIVE_INFINITY
}
}
+3
View File
@@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -1,4 +1,5 @@
import type { z } from 'zod'
import { isRecord } from '../is-record'
import { MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES } from './bridge-limits'
import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from './bridge-protocol-version'
import { isExactMobileWebJsonDocument } from './exact-json-document'
@@ -62,7 +63,3 @@ export function parseMobileWebBridgeMessageDocument<T>(
}
return { ok: true, value: parsed.data }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}