mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user