Keep remote browser certificate warnings actionable (#41235)

* Preserve remote browser certificate warnings through navigation

* Apply current remote observations at the session boundary

* Document array proxy forwarding in the performance contract

* Keep remote browser failure fallbacks within current observations

* Defer remote observation reads to lifecycle callbacks

* Apply remote page observations without preempting pending outcomes

* Preserve command loading ownership across partial page updates

* Reuse browser host identity within each mirror record

* Explain transparent record access in the loading contract fixture

* Keep adoption fixture aligned with remote loading session

* Name the remote navigation fixture return contract
This commit is contained in:
Brennan Benson
2026-11-14 00:52:46 +00:00
committed by GitHub
parent 80a514cfb6
commit 70525610f7
28 changed files with 1757 additions and 250 deletions
@@ -65,7 +65,6 @@
2 src/renderer/src/components/browser-pane/passwords/BrowserPasswordFillDropdown.tsx
1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts
4 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts
1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts
2 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-wheel.ts
3 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-directory-access.tsx
1 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx
@@ -58,7 +58,10 @@ vi.mock('../stream-remote/use-remote-browser-page-lifecycle', () => ({
useRemoteBrowserPageLifecycle: () => ({
lifecycle: {
session: {
ensureRemotePage: mocks.ensureRemotePage,
resolveNavigationPage: mocks.ensureRemotePage,
invalidateTabInfoReads: vi.fn(),
setPageLoading: vi.fn(),
refreshTabInfo: vi.fn(async () => null),
scheduleTabInfoRefresh: vi.fn()
},
tokens: {}
@@ -575,7 +578,6 @@ describe.each([{ tree: 'under StrictMode', strict: true }])(
adoptOntoServer()
})
expect(mocks.ensureRemotePage).toHaveBeenCalledTimes(1)
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
expect.objectContaining({ environmentId: ENVIRONMENT_ID }),
'browser.goto',
@@ -8,6 +8,7 @@ import type {
} from '../../../../../shared/browser/element-grab/browser-grab-types'
import type { BrowserContextMenuRequestedEvent } from '../../../../../shared/browser/guest-lifecycle/browser-guest-events'
import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types'
import type { BrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership'
import type { GrabModeHook } from '../annotate/useGrabMode'
import type { BrowserOverlayViewport } from './browser-annotation-geometry'
@@ -18,6 +19,12 @@ export type BrowserTabPageState = Partial<
>
>
export type BrowserPageStateWriter = (
pageId: string,
updates: BrowserTabPageState,
loadingOwner?: BrowserPageLoadingOwner
) => void
export type BrowserPageUrlSetter = (
tabId: string,
url: string,
@@ -57,7 +57,7 @@ export const WHEEL_DELTA_LINE = 1
export const WHEEL_DELTA_PAGE = 2
export type RemoteBrowserStreamBridge = {
applyTabInfo: (tab: Pick<BrowserTabInfo, 'url' | 'title'>) => void
applyTabInfo: (tab: BrowserTabInfo) => void
clearFrame: () => void
handleFrameBytes: (token: RemoteBrowserStreamToken, bytes: Uint8Array<ArrayBufferLike>) => void
closeMissingRemotePage: (remotePageId: string | null) => void
@@ -0,0 +1,240 @@
// @vitest-environment happy-dom
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api'
import { recordBrowserInitializationReceipt } from '@/runtime/web-session-tabs-sync/browser-initialization-presentation'
import type { RuntimeMobileSessionTabsResult } from '../../../../../shared/runtime-types'
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts'
import {
currentPage,
deferred,
failedTab,
renderNavigation,
page
} from './remote-browser-page-navigation.test-fixture'
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: vi.fn() }))
vi.mock('@/runtime/browser/client-hosting/browser-client-host-identity', () => ({
readBrowserClientHostId: () => 'loading-client'
}))
beforeEach(() => vi.mocked(callRuntimeRpc).mockReset())
afterEach(cleanup)
function applyNativeLoading(loading: boolean, pendingOnClient = false): void {
const snapshot: RuntimeMobileSessionTabsResult = {
worktree: 'worktree-a',
publicationEpoch: 'loading-test',
snapshotVersion: pendingOnClient ? 2 : 1,
activeTabType: 'browser',
activeTabId: 'host-tab',
activeGroupId: 'host-group',
tabs: [
{
type: 'browser',
id: 'host-tab',
browserWorkspaceId: 'workspace-a',
browserPageId: 'remote-page-a',
url: 'about:blank',
title: 'New Tab',
loading,
canGoBack: false,
canGoForward: false,
isActive: true,
loadError: null,
certificateFailure: null,
...(pendingOnClient
? {
initializationState: 'pending',
placement: {
kind: 'client',
browserHostClientId: 'loading-client',
browserHostGeneration: 1,
pageHostGeneration: 0
}
}
: {})
}
]
}
if (pendingOnClient) {
recordBrowserInitializationReceipt('environment-a', snapshot, 2)
}
useAppStore.setState((state) =>
applyWebSessionTabsSnapshot(state, snapshot, 'environment-a', 100)
)
}
describe('remote command loading ownership', () => {
it.each([
['synthetic', true, false],
['explicit', true, true],
['native', true, true],
['native', false, true]
] as const)(
'settles only %s loading after local updates=%s',
async (owner, edit, expectedLoading) => {
const { result, busy } = renderNavigation(1, 'mailto:help@example.test')
act(() => applyNativeLoading(false))
const read = deferred<{ tab: BrowserTabInfo }>()
vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => {
if (method === 'browser.reload') {
return { url: 'about:blank', title: 'New Tab' }
}
if (method === 'browser.tabShow') {
return read.promise
}
return undefined
})
let command!: Promise<void>
await act(async () => {
command = result.current.runRemoteNavigation('browser.reload')
})
expect(currentPage().loading).toBe(true)
const before = useAppStore.getState()
const changed = vi.fn()
const unsubscribe = useAppStore.subscribe(changed)
act(() => {
if (owner === 'native') {
applyNativeLoading(true)
}
if (owner === 'explicit') {
useAppStore.getState().updateBrowserPageState('page-a', { loading: true })
}
})
unsubscribe()
expect(useAppStore.getState().browserPagesByWorkspace).toBe(before.browserPagesByWorkspace)
expect(useAppStore.getState().browserTabsByWorktree).toBe(before.browserTabsByWorktree)
expect(changed).not.toHaveBeenCalled()
if (edit) {
act(() => {
result.current.submitAddressBar()
useAppStore.getState().setBrowserPageViewportPreset('page-a', 'mobile-s')
})
expect(currentPage().loadError).toMatchObject({
code: 0,
validatedUrl: 'mailto:help@example.test'
})
}
await act(async () => {
read.resolve({ tab: { ...failedTab('stale'), title: 'Stale result' } })
await command
})
expect(currentPage().loading).toBe(expectedLoading)
expect(currentPage().title).toBe('New Tab')
expect(currentPage().url).toBe('about:blank')
expect(busy).toHaveBeenLastCalledWith(false)
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined()
if (edit) {
expect(currentPage().loadError).toMatchObject({
code: 0,
validatedUrl: 'mailto:help@example.test'
})
expect(currentPage().viewportPresetId).toBe('mobile-s')
}
}
)
it('keeps initialization loading when an older remote command retires', async () => {
const { session, tokens } = renderNavigation()
act(() => applyNativeLoading(false))
const token = tokens.createOperationToken('remote-page-a')!
act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState))
const read = deferred<{ tab: BrowserTabInfo }>()
vi.mocked(callRuntimeRpc).mockReturnValueOnce(read.promise)
const pending = session.refreshTabInfo(token)
act(() => applyNativeLoading(true, true))
await act(async () => {
read.resolve({ tab: failedTab('stale') })
expect(await pending).toBeNull()
session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState)
})
expect(currentPage().loading).toBe(true)
expect(currentPage().loadError).toBeNull()
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined()
})
it('discharges synthetic loading across external ABA without admitting the old read', async () => {
const { session, tokens } = renderNavigation()
const token = tokens.createOperationToken('remote-page-a')!
act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState))
const read = deferred<{ tab: BrowserTabInfo }>()
vi.mocked(callRuntimeRpc).mockReturnValueOnce(read.promise)
const pending = session.refreshTabInfo(token)
act(() => {
useAppStore.getState().updateBrowserPageState('page-a', { loadError: failedTab().loadError })
useAppStore.getState().updateBrowserPageState('page-a', { loadError: null })
session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState)
})
await act(async () => {
read.resolve({ tab: failedTab('stale') })
expect(await pending).toBeNull()
})
expect(currentPage().loading).toBe(false)
expect(currentPage().loadError).toBeNull()
expect(currentPage().url).toBe('about:blank')
})
it('does not claim an explicit equal loading write from a synchronous store subscriber', () => {
const { session, tokens } = renderNavigation()
const token = tokens.createOperationToken('remote-page-a')!
const unsubscribe = useAppStore.subscribe(() => {
useAppStore.getState().updateBrowserPageState('page-a', { loading: true })
})
act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState))
unsubscribe()
act(() => session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState))
expect(currentPage().loading).toBe(true)
})
it.each([100, 1000])('native takeover visits no unrelated records at N=%s', async (count) => {
const { session, tokens } = renderNavigation()
act(() => applyNativeLoading(false))
const token = tokens.createOperationToken('remote-page-a')!
act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState))
const read = deferred<{ tab: BrowserTabInfo }>()
vi.mocked(callRuntimeRpc).mockReturnValueOnce(read.promise)
const pending = session.refreshTabInfo(token)
const unrelated = Object.fromEntries(
Array.from({ length: count }, (_, index) => {
const workspaceId = `unrelated-${index}`
return [workspaceId, [{ ...page(`other-${index}`), workspaceId, worktreeId: 'other' }]]
})
)
let enumerations = 0
let unrelatedReads = 0
const pages = new Proxy(
{ ...useAppStore.getState().browserPagesByWorkspace, ...unrelated },
{
ownKeys(target) {
enumerations += 1
return Reflect.ownKeys(target)
},
get(target, key, receiver) {
if (typeof key === 'string' && key.startsWith('unrelated-')) {
unrelatedReads += 1
}
// oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy must forward record access unchanged while counting unrelated lookups.
return Reflect.get(target, key, receiver)
}
}
)
useAppStore.setState({ browserPagesByWorkspace: pages })
const changed = vi.fn()
const unsubscribe = useAppStore.subscribe(changed)
act(() => applyNativeLoading(true))
unsubscribe()
expect(enumerations).toBe(0)
expect(unrelatedReads).toBe(0)
expect(useAppStore.getState().browserPagesByWorkspace).toBe(pages)
expect(changed).not.toHaveBeenCalled()
await act(async () => {
read.resolve({ tab: failedTab('stale') })
expect(await pending).toBeNull()
session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState)
})
expect(currentPage().loading).toBe(true)
expect(currentPage().loadError).toBeNull()
})
})
@@ -0,0 +1,166 @@
import type { useRemoteBrowserPageNavigation } from './use-remote-browser-page-navigation'
import type {
BrowserBackResult,
BrowserGotoResult,
BrowserReloadResult,
BrowserTabInfo
} from '../../../../../shared/browser/runtime/runtime-browser-contracts'
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
import { redactKagiSessionToken } from '../../../../../shared/browser-url'
import { deferBrowserPageNavigation } from '../navigate/browser-page-deferred-navigation'
import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors'
type RemoteBrowserNavigationCommandOptions = Pick<
Parameters<typeof useRemoteBrowserPageNavigation>[0],
| 'browserTab'
| 'stagedPage'
| 'lifecycle'
| 'runtimeWorktree'
| 'runtimeTarget'
| 'createRemoteOperationToken'
| 'isCurrentRemoteOperationToken'
| 'closeMissingRemotePage'
| 'onUpdatePageState'
| 'setPaneNotice'
| 'setPaneBusy'
> & {
navigationSequence: { current: number }
applyRemoteTabMetadata: (tab: Pick<BrowserTabInfo, 'url' | 'title'>) => void
}
export async function runRemoteBrowserNavigationCommand(
{
browserTab,
stagedPage,
lifecycle,
runtimeWorktree,
runtimeTarget,
createRemoteOperationToken,
isCurrentRemoteOperationToken,
closeMissingRemotePage,
onUpdatePageState,
setPaneNotice,
setPaneBusy,
navigationSequence,
applyRemoteTabMetadata
}: RemoteBrowserNavigationCommandOptions,
method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload',
url?: string
): Promise<void> {
const target = runtimeTarget()
if (!target) {
return
}
if (stagedPage) {
// No staged host page exists yet: park goto until adoption; history/reload cannot replay against an uncreated page.
if (method === 'browser.goto' && url) {
deferBrowserPageNavigation(browserTab.id, url)
onUpdatePageState(browserTab.id, { loading: true, loadError: null })
}
return
}
const operationToken = createRemoteOperationToken()
if (!operationToken || !isCurrentRemoteOperationToken(operationToken)) {
return
}
const sequence = ++navigationSequence.current
lifecycle.session.invalidateTabInfoReads()
let pageId: string | null = null
const ownsAction = (): boolean =>
sequence === navigationSequence.current &&
isCurrentRemoteOperationToken({ ...operationToken, remotePageId: pageId })
const reportFailure = (error: unknown): void => {
if (!ownsAction()) {
return
}
if (isRemoteBrowserPageMissingError(error)) {
closeMissingRemotePage(pageId)
return
}
const message = error instanceof Error ? error.message : 'Remote browser command failed.'
setPaneNotice({ kind: 'consequence', text: message })
// Why: goto results carry no load error, but the rejection keeps Electron's "ERR_NAME (-code)" text.
const netError = /\b(ERR_[A-Z_]+) \((-\d+)\)/.exec(message)
onUpdatePageState(browserTab.id, {
loading: false,
// Why: validatedUrl is persisted, so redact the Kagi session token like the main-process failure path does.
loadError: {
code: netError ? Number(netError[2]) : 0,
description: netError ? netError[1] : message,
validatedUrl: redactKagiSessionToken(url ?? browserTab.url)
}
})
}
setPaneBusy(true)
setPaneNotice(null)
// A standing failure stays visible until the native outcome is observed.
lifecycle.session.setPageLoading(operationToken, true, onUpdatePageState)
try {
pageId = await lifecycle.session.resolveNavigationPage(operationToken)
if (!pageId || !ownsAction()) {
return
}
const pageToken = { ...operationToken, remotePageId: pageId }
const params =
method === 'browser.goto'
? { worktree: runtimeWorktree, page: pageId, url: url ?? 'about:blank' }
: { worktree: runtimeWorktree, page: pageId }
let commandError: unknown
let result: BrowserGotoResult | BrowserBackResult | BrowserReloadResult | undefined
try {
result = await callRuntimeRpc<BrowserGotoResult | BrowserBackResult | BrowserReloadResult>(
target,
method,
params,
{ timeoutMs: 30_000, suppressFeatureInteraction: true }
)
} catch (error) {
if (isRemoteBrowserPageMissingError(error)) {
throw error
}
commandError = error
}
if (!ownsAction()) {
return
}
await lifecycle.session.refreshTabInfo(pageToken, {
onApplied: (tab) => {
if (!ownsAction() || !commandError || tab.loadError) {
return
}
if (tab.loadError === undefined) {
reportFailure(commandError)
} else {
setPaneNotice({
kind: 'consequence',
text:
commandError instanceof Error
? commandError.message
: 'Remote browser command failed.'
})
}
},
onError: (error) => {
if (!ownsAction()) {
return
}
if (isRemoteBrowserPageMissingError(error)) {
reportFailure(error)
} else if (result) {
if (!result.url.startsWith('chrome-error:')) {
applyRemoteTabMetadata(result)
}
} else {
reportFailure(commandError ?? error)
}
}
})
} catch (error) {
reportFailure(error)
} finally {
if (ownsAction()) {
lifecycle.session.setPageLoading(operationToken, false, onUpdatePageState)
setPaneBusy(false)
}
}
}
@@ -0,0 +1,184 @@
import { renderHook, type RenderHookResult } from '@testing-library/react'
import { expect, vi, type Mock } from 'vitest'
import { useAppStore } from '@/store'
import { annotateBrowserPageHandles } from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership'
import { findPage } from '@/store/slices/browser/pages/browser-page-records'
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
import type { BrowserPage } from '../../../../../shared/browser-workspace-types'
import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts'
import { useRemoteBrowserPageNavigation } from './use-remote-browser-page-navigation'
import {
readRemoteBrowserPage,
readRemoteBrowserPageObservation
} from './remote-browser-page-observation'
import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle'
import type { RemoteBrowserScreencastSubscribe } from './remote-browser-screencast-subscription'
type NavigationOptions = Parameters<typeof useRemoteBrowserPageNavigation>[0]
type NavigationFixture = RenderHookResult<
ReturnType<typeof useRemoteBrowserPageNavigation>,
unknown
> & {
lifecycle: RemoteBrowserStreamLifecycle
subscribe: Mock<RemoteBrowserScreencastSubscribe>
session: RemoteBrowserStreamLifecycle['session']
tokens: RemoteBrowserStreamLifecycle['tokens']
identity: { mounted: boolean; active: boolean; environmentId: string }
busy: Mock<NavigationOptions['setPaneBusy']>
notice: Mock<NavigationOptions['setPaneNotice']>
closeMissing: Mock<NavigationOptions['closeMissingRemotePage']>
}
export function page(id = 'page-a'): BrowserPage {
return {
id,
workspaceId: 'workspace-a',
worktreeId: 'worktree-a',
url: 'about:blank',
title: 'New Tab',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
}
export function failedTab(challengeId = 'challenge-x'): BrowserTabInfo {
return {
browserPageId: 'remote-page-a',
index: 0,
active: true,
url: 'https://selfsigned.test/',
title: 'TLS',
loadError: {
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://selfsigned.test/'
},
certificateFailure: {
challengeId,
browserPageId: 'remote-page-a',
errorCode: -202,
error: 'ERR_CERT_AUTHORITY_INVALID',
origin: 'https://selfsigned.test',
displayHost: 'selfsigned.test',
canProceed: true,
observedAt: 1
}
}
}
export function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
export function renderNavigation(count = 1, addressBarValue = 'about:blank'): NavigationFixture {
const pages = Array.from({ length: count }, (_, index) =>
page(index ? `page-${index}` : 'page-a')
)
const first = pages[0]
useAppStore.setState({
browserPagesByWorkspace: { 'workspace-a': pages },
browserTabsByWorktree: {
'worktree-a': [
{
...first,
id: 'workspace-a',
activePageId: 'page-a',
pageIds: pages.map((entry) => entry.id)
}
]
},
unifiedTabsByWorktree: {},
browserCertificateFailuresByPageId: {},
browserUrlHistory: [],
remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles(
{ 'page-a': { environmentId: 'environment-a', remotePageId: 'remote-page-a' } },
['page-a']
)
})
const identity = { mounted: true, active: true, environmentId: 'environment-a' }
const paneIdentity = {
isMounted: () => identity.mounted,
isActive: () => identity.active,
getTabId: () => 'page-a',
getEnvironmentId: () => identity.environmentId,
browserPageExists: (pageId) =>
Boolean(readRemoteBrowserPage(useAppStore.getState(), 'workspace-a', pageId))
}
let project: (tab: BrowserTabInfo) => void = () => {}
const subscribe = vi.fn<RemoteBrowserScreencastSubscribe>(async () => ({ unsubscribe: vi.fn() }))
const lifecycle = new RemoteBrowserStreamLifecycle({
identity: paneIdentity,
subscribeScreencast: subscribe,
askJavaScriptDialog: async () => null,
waitForViewportSize: async () => ({ width: 800, height: 600 }),
readViewportSize: () => ({ width: 800, height: 600 }),
syncViewport: async () => {},
getDeviceScaleFactor: () => 1,
setStatus: vi.fn(),
clearFrame: vi.fn(),
handleFrameBytes: vi.fn(),
callRpc: callRuntimeRpc,
getWorktreeSelector: () => 'worktree-a',
getCurrentUrl: () => currentPage().url,
readCurrentObservation: () =>
readRemoteBrowserPageObservation(useAppStore.getState(), 'workspace-a', 'page-a'),
readStoredHandle: () => ({ environmentId: 'environment-a', remotePageId: 'remote-page-a' }),
writeStoredHandle: vi.fn(),
removeStoredHandle: vi.fn(),
applyTabInfo: (tab) => project(tab),
closeMissingRemotePage: vi.fn()
})
const { session, tokens } = lifecycle
tokens.setRemotePage('remote-page-a')
const closeMissing = vi.fn<NavigationOptions['closeMissingRemotePage']>()
const busy = vi.fn<NavigationOptions['setPaneBusy']>()
const notice = vi.fn<NavigationOptions['setPaneNotice']>()
const hook = renderHook(() =>
useRemoteBrowserPageNavigation({
browserTab: first,
stagedPage: false,
addressBarValue,
setAddressBarValueFromPage: vi.fn(),
lifecycle,
runtimeWorktree: 'worktree-a',
runtimeTarget: () => ({ kind: 'environment', environmentId: identity.environmentId }),
createRemoteOperationToken: () => tokens.createOperationToken(),
isCurrentRemoteOperationToken: (token) => tokens.isCurrent(token),
closeMissingRemotePage: closeMissing,
onSetUrl: useAppStore.getState().setBrowserPageUrl,
onUpdatePageState: useAppStore.getState().updateBrowserPageState,
setPaneNotice: notice,
setPaneBusy: busy
})
)
project = (tab) => hook.result.current.applyRemoteTabInfo(tab)
return { ...hook, lifecycle, subscribe, session, tokens, identity, busy, notice, closeMissing }
}
export function currentPage(): BrowserPage {
return findPage(useAppStore.getState().browserPagesByWorkspace, 'page-a')!
}
export function expectPending(challengeId = 'challenge-x') {
expect(currentPage().url).toBe('https://selfsigned.test/')
expect(currentPage().loadError).toEqual({
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://selfsigned.test/'
})
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toMatchObject({
challengeId,
browserPageId: 'remote-page-a',
canProceed: true
})
}
@@ -0,0 +1,27 @@
import type {
BrowserPage,
BrowserCertificateFailure
} from '../../../../../shared/browser-workspace-types'
import { readBrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership'
type BrowserObservationState = {
browserPagesByWorkspace: Record<string, BrowserPage[]>
browserCertificateFailuresByPageId: Record<string, BrowserCertificateFailure>
}
export function readRemoteBrowserPage(
state: BrowserObservationState,
workspaceId: string,
pageId: string
): BrowserPage | undefined {
return state.browserPagesByWorkspace[workspaceId]?.find((page) => page.id === pageId)
}
export function readRemoteBrowserPageObservation(
state: BrowserObservationState,
workspaceId: string,
pageId: string
): readonly unknown[] {
const page = readRemoteBrowserPage(state, workspaceId, pageId)
return [page, state.browserCertificateFailuresByPageId[pageId], readBrowserPageLoadingOwner(page)]
}
@@ -21,7 +21,7 @@ import {
import type {
BrowserChromeShortcutScope,
BrowserPageUrlSetter,
BrowserTabPageState
BrowserPageStateWriter
} from '../describe-page/browser-page-types'
import type { RemoteBrowserPaneNotice } from './remote-browser-page-input-model'
import { useRemoteBrowserPageLifecycle } from './use-remote-browser-page-lifecycle'
@@ -57,10 +57,9 @@ export function RemoteBrowserPagePane({
worktreeId: string
isActive: boolean
chromeShortcutScope: BrowserChromeShortcutScope
onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void
onUpdatePageState: BrowserPageStateWriter
onSetUrl: BrowserPageUrlSetter
}): React.JSX.Element {
const activeRuntimeEnvironmentId = runtimeEnvironmentId
const addressBarInputRef = useRef<HTMLInputElement | null>(null)
const imageRef = useRef<HTMLImageElement | null>(null)
const remoteViewportRef = useRef<HTMLDivElement | null>(null)
@@ -94,16 +93,15 @@ export function RemoteBrowserPagePane({
// Why: runtimes predating browser.certificate-trust.v1 can't honor a proceed request, so hide "Proceed Anyway" until support is advertised.
const [remoteCertificateTrustSupported, setRemoteCertificateTrustSupported] = useState(false)
const remoteCertificateEnvironmentId = remotePageHandle?.environmentId ?? null
const certificateChallengeId = certificateFailure?.challengeId ?? null
useEffect(() => {
if (!remoteCertificateEnvironmentId || !certificateChallengeId) {
if (!remotePageHandle?.environmentId || !certificateChallengeId) {
setRemoteCertificateTrustSupported(false)
return
}
let cancelled = false
void runtimeEnvironmentSupportsCapability(
remoteCertificateEnvironmentId,
remotePageHandle?.environmentId,
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY
)
.then((supported) => {
@@ -119,7 +117,7 @@ export function RemoteBrowserPagePane({
return () => {
cancelled = true
}
}, [remoteCertificateEnvironmentId, certificateChallengeId])
}, [remotePageHandle?.environmentId, certificateChallengeId])
const {
enqueueRemoteInput,
@@ -154,7 +152,7 @@ export function RemoteBrowserPagePane({
} = useRemoteBrowserPageLifecycle({
browserTab,
worktreeId,
activeRuntimeEnvironmentId,
activeRuntimeEnvironmentId: runtimeEnvironmentId,
isActive,
setPaneNotice,
setPaneBusy,
@@ -174,6 +172,7 @@ export function RemoteBrowserPagePane({
const {
applyRemoteTabInfo,
prepareCertificateProceed,
scheduleRemoteTabInfoRefresh,
runRemoteNavigation,
navigateToUrl,
@@ -224,7 +223,7 @@ export function RemoteBrowserPagePane({
}, [hasStreamFrame])
const { reconnectRemoteStream } = useRemoteBrowserPageStream({
activeRuntimeEnvironmentId,
activeRuntimeEnvironmentId: runtimeEnvironmentId,
browserPageId: browserTab.id,
isActive,
lifecycle,
@@ -405,7 +404,7 @@ export function RemoteBrowserPagePane({
remoteCertificateTrustSupported={remoteCertificateTrustSupported}
certificateFailure={certificateFailure}
remotePageHandle={remotePageHandle}
activeRuntimeEnvironmentId={activeRuntimeEnvironmentId}
activeRuntimeEnvironmentId={runtimeEnvironmentId}
worktreeId={worktreeId}
runtimeWorktree={runtimeWorktree}
runtimeTarget={runtimeTarget}
@@ -413,6 +412,7 @@ export function RemoteBrowserPagePane({
onGoto={(url) => runRemoteNavigation('browser.goto', url)}
onNavigateToUrl={navigateToUrl}
onReconnect={reconnectRemoteStream}
onBeforeProceedCertificate={prepareCertificateProceed}
handleRemotePointerDown={handleRemotePointerDown}
handleRemotePointerUp={handleRemotePointerUp}
handleRemoteContextMenu={handleRemoteContextMenu}
@@ -53,6 +53,7 @@ function setup(args: { show: ShowReply; switchOn?: boolean; onShow?: () => void
tokens,
callRpc,
getWorktreeSelector: () => 'id:wt-1',
readCurrentObservation: () => [],
getCurrentUrl: () => 'about:blank',
readStoredHandle: () => stored,
writeStoredHandle: (handle) => {
@@ -1,4 +1,6 @@
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import type { BrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership'
import type { BrowserPageStateWriter } from '../describe-page/browser-page-types'
import '../../../../../shared/constants'
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/browser/browser-blank-page'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
@@ -26,20 +28,148 @@ export type RemoteBrowserPageSessionDeps = {
callRpc: RemoteBrowserRpcCall
getWorktreeSelector(): string
getCurrentUrl(): string
readCurrentObservation(): readonly unknown[]
readStoredHandle(): RemoteBrowserPageHandle | null
writeStoredHandle(handle: RemoteBrowserPageHandle): void
removeStoredHandle(remotePageId: string): void
applyTabInfo(tab: Pick<BrowserTabInfo, 'url' | 'title'>): void
applyTabInfo(tab: BrowserTabInfo): void
closeMissingRemotePage(remotePageId: string | null): void
}
// Keep runtime page identity and post-input refresh separate from screencast ownership.
export class RemoteBrowserPageSession {
private tabRefreshTimer: ReturnType<typeof setTimeout> | null = null
private observationGeneration = 0
private nextObservationId = 0
private appliedObservationId = 0
private observationAnchor: readonly unknown[] | null = null
private loadingOwner: BrowserPageLoadingOwner | null = null
constructor(private readonly deps: RemoteBrowserPageSessionDeps) {}
async fetchTabInfo(token: RemoteBrowserOperationToken): Promise<BrowserTabInfo | null> {
invalidateTabInfoReads(): void {
this.observationGeneration += 1
this.cancelTabInfoRefresh()
}
private observeExternalChanges(): readonly unknown[] {
const current = this.deps.readCurrentObservation()
if (
this.observationAnchor &&
(this.observationAnchor.length !== current.length ||
this.observationAnchor.some((value, index) => value !== current[index]))
) {
this.observationGeneration += 1
if (this.loadingOwner && current[2] !== this.loadingOwner) {
this.loadingOwner.active = false
this.loadingOwner = null
}
}
this.observationAnchor = current
return current
}
private applyObservedWrite(write: () => void): void {
try {
write()
} finally {
this.observationAnchor = this.deps.readCurrentObservation()
}
}
setPageLoading(
token: RemoteBrowserOperationToken,
loading: boolean,
update: BrowserPageStateWriter
): void {
if (!this.deps.tokens.isCurrent(token)) {
return
}
const observation = this.observeExternalChanges()
if (!loading && (!this.loadingOwner || observation[2] !== this.loadingOwner)) {
return
}
const owner = loading ? { active: true } : undefined
this.loadingOwner = owner ?? null
this.applyObservedWrite(() => update(token.tabId, { loading }, owner))
}
captureTabInfoAdmission(token: RemoteBrowserOperationToken): {
isCurrent: () => boolean
apply: (tab: BrowserTabInfo, onApplied?: (tab: BrowserTabInfo) => void) => boolean
} {
const id = ++this.nextObservationId
const active = this.deps.tokens.isCurrent(token)
if (active) {
this.observeExternalChanges()
}
const generation = this.observationGeneration
const isCurrent = (): boolean => {
if (
generation !== this.observationGeneration ||
!active ||
id < this.appliedObservationId ||
!this.deps.tokens.isCurrent(token)
) {
return false
}
this.observeExternalChanges()
return generation === this.observationGeneration
}
return {
isCurrent,
apply: (tab, onApplied) => {
if (!isCurrent()) {
return false
}
this.applyObservedWrite(() => {
this.deps.applyTabInfo(tab)
this.appliedObservationId = id
this.loadingOwner = null
onApplied?.(tab)
})
return true
}
}
}
async refreshTabInfo(
token: RemoteBrowserOperationToken,
callbacks: {
onApplied?: (tab: BrowserTabInfo) => void
onError?: (error: unknown) => void
} = {}
): Promise<Pick<BrowserTabInfo, 'loadError'> | null> {
const admission = this.captureTabInfoAdmission(token)
let tab: BrowserTabInfo | null
try {
tab = await this.readTabInfo(token)
} catch (error) {
if (!admission.isCurrent()) {
return null
}
if (callbacks.onError) {
this.applyObservedWrite(() => callbacks.onError?.(error))
return null
}
throw error
}
return tab && admission.apply(tab, callbacks.onApplied) ? { loadError: tab.loadError } : null
}
async resolveNavigationPage(token: RemoteBrowserOperationToken): Promise<string | null> {
if (!this.deps.tokens.isCurrent(token)) {
return null
}
const handle = this.deps.readStoredHandle()
if (handle?.environmentId === token.environmentId) {
this.deps.tokens.setRemotePage(handle.remotePageId)
return handle.remotePageId
}
return this.ensureRemotePage(token)
}
private async readTabInfo(token: RemoteBrowserOperationToken): Promise<BrowserTabInfo | null> {
if (!this.deps.tokens.isCurrent(token) || !token.remotePageId) {
return null
}
@@ -70,7 +200,7 @@ export class RemoteBrowserPageSession {
}
tokens.setRemotePage(existingHandle.remotePageId)
try {
const cachedTab = await this.fetchTabInfo({
const cachedTab = await this.readTabInfo({
...token,
remotePageId: existingHandle.remotePageId
})
@@ -101,17 +231,13 @@ export class RemoteBrowserPageSession {
if (!tokens.isCurrent(token)) {
return
}
void this.fetchTabInfo(token)
.then((tab) => {
if (tab && tokens.isCurrent(token)) {
this.deps.applyTabInfo(tab)
}
})
.catch((error: unknown) => {
if (tokens.isCurrent(token) && isRemoteBrowserPageMissingError(error)) {
void this.refreshTabInfo(token, {
onError: (error) => {
if (isRemoteBrowserPageMissingError(error)) {
this.deps.closeMissingRemotePage(token.remotePageId)
}
})
}
})
}, delayMs)
}
@@ -137,7 +263,7 @@ export class RemoteBrowserPageSession {
tokens.setRemotePage(provisional)
try {
// tabShow also starts a saved page the host has not opened yet.
if (!(await this.fetchTabInfo(probe)) || !tokens.isCurrent(probe)) {
if (!(await this.readTabInfo(probe)) || !tokens.isCurrent(probe)) {
release()
return null
}
@@ -46,6 +46,7 @@ export function RemoteBrowserPageViewport({
onGoto,
onNavigateToUrl,
onReconnect,
onBeforeProceedCertificate,
handleRemotePointerDown,
handleRemotePointerUp,
handleRemoteContextMenu,
@@ -73,6 +74,7 @@ export function RemoteBrowserPageViewport({
/** The address bar's submit path, which also parks a URL typed before the host page exists. */
onNavigateToUrl: (url: string) => void
onReconnect: () => void
onBeforeProceedCertificate: () => void
handleRemotePointerDown: (event: React.PointerEvent<HTMLImageElement>) => void
handleRemotePointerUp: (event: React.PointerEvent<HTMLImageElement>) => void
handleRemoteContextMenu: (event: React.MouseEvent<HTMLImageElement>) => void
@@ -180,6 +182,7 @@ export function RemoteBrowserPageViewport({
) {
return { ok: false, reason: 'missing' }
}
onBeforeProceedCertificate()
return callRuntimeRpc<BrowserCertificateProceedResult>(
target,
'browser.certificate.proceed',
@@ -190,6 +190,7 @@ export function createHarness() {
callRpc,
subscribeScreencast,
getWorktreeSelector: () => 'worktree:wt-1',
readCurrentObservation: () => [],
getCurrentUrl: () => 'https://example.test/',
readStoredHandle: () => storedHandle,
writeStoredHandle: (handle) => {
@@ -15,6 +15,35 @@ describe('RemoteBrowserStreamLifecycle', () => {
vi.useRealTimers()
})
it('applies current ready metadata after open and a viewport restart', async () => {
const harness = createHarness()
harness.lifecycle.open()
await settle()
const before = harness.appliedTitles.length
harness.streams[0].emitReady()
await settle()
expect(harness.appliedTitles.slice(before)).toEqual(['Example'])
harness.setViewportSize({ width: 1400, height: 900 })
harness.lifecycle.restartForViewport('page-1')
await settle()
const beforeRestartReady = harness.appliedTitles.length
harness.streams.at(-1)!.emitReady()
await settle()
expect(harness.appliedTitles.slice(beforeRestartReady)).toEqual(['Example'])
})
it('keeps a stream live without applying ready metadata superseded by navigation', async () => {
const harness = createHarness()
harness.lifecycle.open()
await settle()
const before = [...harness.appliedTitles]
harness.lifecycle.session.invalidateTabInfoReads()
harness.streams[0].emitReady()
await settle()
expect(harness.appliedTitles).toEqual(before)
expect(harness.currentStatusKind).toBe('live')
})
it("asks once about the streamed page's dialog and answers it on the host", async () => {
const harness = createHarness()
await openStreamAndConfirmReady(harness)
@@ -1,4 +1,3 @@
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import type { RuntimeStatus } from '../../../../../shared/runtime-types'
import { RemoteBrowserPageSession } from './remote-browser-page-session'
import { RemoteBrowserJavaScriptDialog } from './remote-browser-javascript-dialog'
@@ -22,6 +21,7 @@ import {
resolveRemoteBrowserStreamFailure
} from './remote-browser-stream-errors'
import {
toOperationToken,
areRemoteViewportSizesNear,
RemoteBrowserOperationTokens,
type RemoteBrowserStreamSubscription,
@@ -74,6 +74,25 @@ export class RemoteBrowserStreamLifecycle {
this.setStatus(REMOTE_BROWSER_STREAM_IDLE)
return () => {}
}
const handleOpenFailure = (error: unknown): void => {
if (cancelled) {
return
}
if (isRemoteBrowserPageMissingError(error)) {
deps.closeMissingRemotePage(tokens.remotePage)
return
}
const failure = resolveRemoteBrowserStreamFailure(error, 'opening')
if (failure.logRawError) {
console.warn('[browser-pane] remote browser failed to open:', error)
}
// The host can close before subscribe rejects; preserve the newer armed restart’s ownership of status.
if (this.restartScheduler.isScheduled) {
return
}
// An initial open has no retry budget; preserve manual recovery.
this.setStatus(remoteBrowserStreamStopped(failure.message))
}
void this.session
.ensureRemotePage(operationToken)
.then(async (pageId) => {
@@ -81,11 +100,14 @@ export class RemoteBrowserStreamLifecycle {
return
}
const pageToken = { ...operationToken, remotePageId: pageId }
const tab = await this.session.fetchTabInfo(pageToken)
if (tab && !cancelled && tokens.isCurrent(pageToken)) {
deps.applyTabInfo(tab)
}
if (cancelled || !tokens.isCurrent(pageToken)) {
let readFailed = false
await this.session.refreshTabInfo(pageToken, {
onError: (error) => {
readFailed = true
handleOpenFailure(error)
}
})
if (readFailed || cancelled || !tokens.isCurrent(pageToken)) {
return
}
const subscription = await this.startStream(pageId)
@@ -95,25 +117,7 @@ export class RemoteBrowserStreamLifecycle {
}
this.adoptSubscription(subscription)
})
.catch((error: unknown) => {
if (cancelled) {
return
}
if (isRemoteBrowserPageMissingError(error)) {
deps.closeMissingRemotePage(tokens.remotePage)
return
}
const failure = resolveRemoteBrowserStreamFailure(error, 'opening')
if (failure.logRawError) {
console.warn('[browser-pane] remote browser failed to open:', error)
}
// The host can close before subscribe rejects; preserve the newer armed restart’s ownership of status.
if (this.restartScheduler.isScheduled) {
return
}
// An initial open has no retry budget; preserve manual recovery.
this.setStatus(remoteBrowserStreamStopped(failure.message))
})
.catch(handleOpenFailure)
return () => {
cancelled = true
this.retireInFlightWork()?.unsubscribe()
@@ -216,10 +220,8 @@ export class RemoteBrowserStreamLifecycle {
if (!operationToken || !tokens.isCurrent(operationToken)) {
return null
}
const target: RuntimeClientTarget = {
kind: 'environment',
environmentId: operationToken.environmentId
}
const { environmentId } = operationToken
const target = { kind: 'environment' as const, environmentId }
const status = await deps.callRpc<RuntimeStatus>(target, 'status.get', undefined, {
timeoutMs: 15_000
})
@@ -236,6 +238,7 @@ export class RemoteBrowserStreamLifecycle {
}
this.streamViewportSize = viewportSize
const token = tokens.claimStreamToken(operationToken, pageId)
const admitsTabInfo = this.session.captureTabInfoAdmission(toOperationToken(token))
// Treat a silent host as a drop so it spends the same bounded retry budget.
this.liveness.watch(() => {
if (this.tokens.isCurrentStreamToken(token)) {
@@ -258,7 +261,7 @@ export class RemoteBrowserStreamLifecycle {
// Record uptime; only sustained streams earn a refill at close. Live status also clears healed notices.
this.liveness.markReady()
this.setStatus(REMOTE_BROWSER_STREAM_LIVE)
deps.applyTabInfo(event.tab)
admitsTabInfo.apply(event.tab)
void deps.syncViewport(event.browserPageId).catch(() => {})
},
onEnded: () => this.handleStreamClosed(token, true),
@@ -324,7 +327,6 @@ export class RemoteBrowserStreamLifecycle {
tokens: this.tokens,
session: this.session,
setStatus: (status) => this.setStatus(status),
applyTabInfo: (tab) => this.deps.applyTabInfo(tab),
closeMissingRemotePage: (remotePageId) => this.deps.closeMissingRemotePage(remotePageId),
startStream: (pageId) => this.startStream(pageId),
adoptSubscription: (subscription) => this.adoptSubscription(subscription)
@@ -14,13 +14,11 @@ import {
type RemoteBrowserStreamSubscription,
type RemoteBrowserStreamToken
} from './remote-browser-stream-tokens'
import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts'
export type RemoteBrowserStreamRestartAttemptDeps = {
tokens: RemoteBrowserOperationTokens
session: RemoteBrowserPageSession
setStatus: (status: RemoteBrowserStreamStatus) => void
applyTabInfo: (tab: BrowserTabInfo) => void
closeMissingRemotePage: (remotePageId: string) => void
startStream: (pageId: string) => Promise<RemoteBrowserStreamSubscription | null>
adoptSubscription: (subscription: RemoteBrowserStreamSubscription) => void
@@ -41,10 +39,7 @@ export function createRemoteBrowserStreamRestartAttempt(
deps.setStatus(remoteBrowserStreamRetrying(lastNotice))
const operationToken = toOperationToken(token)
try {
const tab = await deps.session.fetchTabInfo(operationToken).catch(() => null)
if (tab && tokens.isCurrentStreamOperation(token)) {
deps.applyTabInfo(tab)
}
await deps.session.refreshTabInfo(operationToken).catch(() => null)
if (!tokens.isCurrentStreamOperation(token)) {
return false
}
@@ -11,7 +11,6 @@ import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-t
import { toRuntimeWorktreeSelector } from '@/runtime/rpc/targets/runtime-worktree-selector'
import type { BrowserScreencastFrameMetadata } from '../../../../../shared/browser/screencast/browser-screencast-protocol'
import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types'
import { browserPageExists } from '../describe-page/browser-page-load-error'
import {
REMOTE_BROWSER_STREAM_IDLE,
type RemoteBrowserStreamStatus
@@ -23,6 +22,10 @@ import {
type RemoteBrowserStreamBridge
} from './remote-browser-page-input-model'
import { subscribeRuntimeEnvironment } from '@/runtime/rpc/environment-revisions/runtime-environment-pairing-refresh'
import {
readRemoteBrowserPage,
readRemoteBrowserPageObservation
} from './remote-browser-page-observation'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { javaScriptDialogPrompt } from '../browser-javascript-dialog-prompt'
@@ -60,6 +63,7 @@ export function useRemoteBrowserPageLifecycle({
const pendingFrameDecodeRef = useRef(0)
const mountedRef = useRef(true)
const isActiveRef = useRef(isActive)
const currentWorkspaceIdRef = useRef(browserTab.workspaceId)
const currentBrowserTabIdRef = useRef(browserTab.id)
const currentBrowserTabUrlRef = useRef(browserTab.url)
const runtimeWorktree = useMemo(() => toRuntimeWorktreeSelector(worktreeId), [worktreeId])
@@ -74,12 +78,20 @@ export function useRemoteBrowserPageLifecycle({
const confirm = useConfirmationDialog()
useLayoutEffect(() => {
currentWorkspaceIdRef.current = browserTab.workspaceId
currentBrowserTabIdRef.current = browserTab.id
currentBrowserTabUrlRef.current = browserTab.url
activeRuntimeEnvironmentIdRef.current = activeRuntimeEnvironmentId
isActiveRef.current = isActive
runtimeWorktreeRef.current = runtimeWorktree
}, [activeRuntimeEnvironmentId, browserTab.id, browserTab.url, isActive, runtimeWorktree])
}, [
activeRuntimeEnvironmentId,
browserTab.id,
browserTab.workspaceId,
browserTab.url,
isActive,
runtimeWorktree
])
if (!lifecycleRef.current) {
lifecycleRef.current = new RemoteBrowserStreamLifecycle({
@@ -88,12 +100,21 @@ export function useRemoteBrowserPageLifecycle({
isActive: () => isActiveRef.current,
getTabId: () => currentBrowserTabIdRef.current,
getEnvironmentId: () => activeRuntimeEnvironmentIdRef.current,
browserPageExists
browserPageExists: (pageId) =>
Boolean(
readRemoteBrowserPage(useAppStore.getState(), currentWorkspaceIdRef.current, pageId)
)
},
callRpc: callRuntimeRpc,
subscribeScreencast: (args, callbacks) => subscribeRuntimeEnvironment(args, callbacks),
getWorktreeSelector: () => runtimeWorktreeRef.current,
getCurrentUrl: () => currentBrowserTabUrlRef.current,
readCurrentObservation: () =>
readRemoteBrowserPageObservation(
useAppStore.getState(),
currentWorkspaceIdRef.current,
currentBrowserTabIdRef.current
),
readStoredHandle: () =>
useAppStore.getState().remoteBrowserPageHandlesByPageId[currentBrowserTabIdRef.current] ??
null,
@@ -235,7 +256,12 @@ export function useRemoteBrowserPageLifecycle({
}
const state = useAppStore.getState()
const currentEnvironmentId = runtimeEnvironmentIdOrLocalUnlessTwin(state, worktreeId)
const pageStillExists = browserPageExists(browserTab.id)
const pageStillExists =
useAppStore
.getState()
.browserPagesByWorkspace[browserTab.workspaceId]?.some(
(page) => page.id === browserTab.id
) ?? false
if (currentEnvironmentId === activeRuntimeEnvironmentId && pageStillExists) {
return
}
@@ -252,7 +278,14 @@ export function useRemoteBrowserPageLifecycle({
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
).catch(() => {})
}
}, [activeRuntimeEnvironmentId, browserTab.id, lifecycle, runtimeWorktree, worktreeId])
}, [
activeRuntimeEnvironmentId,
browserTab.id,
browserTab.workspaceId,
lifecycle,
runtimeWorktree,
worktreeId
])
return {
lifecycle,
@@ -1,74 +1,672 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { BrowserPage } from '../../../../../shared/browser-workspace-types'
import { useRemoteBrowserPageNavigation } from './use-remote-browser-page-navigation'
import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle'
import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api'
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts'
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({
callRuntimeRpc: vi.fn(async () => ({}))
}))
import {
page,
failedTab,
deferred,
renderNavigation,
currentPage,
expectPending
} from './remote-browser-page-navigation.test-fixture'
function page(): BrowserPage {
return {
id: 'page-a',
workspaceId: 'workspace-a',
worktreeId: 'worktree-a',
url: 'about:blank',
title: 'New Tab',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
}
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: vi.fn() }))
function renderNavigation(): ReturnType<
typeof renderHook<ReturnType<typeof useRemoteBrowserPageNavigation>, void>
> {
return renderHook(() =>
useRemoteBrowserPageNavigation({
browserTab: page(),
stagedPage: false,
addressBarValue: 'about:blank',
setAddressBarValueFromPage: vi.fn(),
lifecycle: {
session: { ensureRemotePage: vi.fn(), scheduleTabInfoRefresh: vi.fn() }
} as unknown as RemoteBrowserStreamLifecycle,
runtimeWorktree: 'worktree-a',
runtimeTarget: () => null,
createRemoteOperationToken: () => null,
isCurrentRemoteOperationToken: () => false,
closeMissingRemotePage: vi.fn(),
onSetUrl: vi.fn(),
onUpdatePageState: vi.fn(),
setPaneNotice: vi.fn(),
setPaneBusy: vi.fn()
})
)
}
beforeEach(() => vi.mocked(callRuntimeRpc).mockReset())
afterEach(() => {
cleanup()
vi.useRealTimers()
})
describe('useRemoteBrowserPageNavigation history recording', () => {
beforeEach(() => {
useAppStore.setState({ browserUrlHistory: [] })
describe('remote browser authoritative page state', () => {
it('projects a pending host challenge through local actions and clears an old full snapshot without fields', () => {
const { result } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
expectPending()
act(() =>
result.current.applyRemoteTabInfo({
browserPageId: 'remote-page-a',
index: 0,
active: true,
url: 'https://example.test/',
title: 'Example'
})
)
expect(currentPage().loadError).toBeNull()
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined()
})
afterEach(() => cleanup())
it('redacts a Kagi session token before it reaches history', () => {
const { result } = renderNavigation()
act(() =>
result.current.applyRemoteTabInfo({
browserPageId: 'remote-page-a',
index: 0,
active: true,
url: 'https://kagi.com/search?q=orca&token=secret-session',
title: 'Kagi'
})
)
const [entry] = useAppStore.getState().browserUrlHistory
expect(entry.url).not.toContain('secret-session')
expect(useAppStore.getState().browserUrlHistory[0].url).not.toContain('secret-session')
})
it.each(['browser.goto', 'browser.back', 'browser.forward', 'browser.reload'] as const)(
'reads authoritative state after %s rejects or resolves, without a pre-command read',
async (method) => {
const { result, busy, notice } = renderNavigation()
const calls: string[] = []
vi.mocked(callRuntimeRpc).mockImplementation(async (_target, called) => {
if (typeof called !== 'string' || !called.startsWith('browser.')) {
return undefined
}
calls.push(called)
if (called === 'browser.tabShow') {
return { tab: failedTab('challenge-y') }
}
if (called === 'browser.goto') {
throw new Error('ERR_CERT_AUTHORITY_INVALID (-202)')
}
return { url: 'chrome-error://chromewebdata/', title: 'Error' }
})
await act(() => result.current.runRemoteNavigation(method, 'https://selfsigned.test/'))
expect(calls).toEqual([method, 'browser.tabShow'])
expectPending('challenge-y')
expect(currentPage().loading).toBe(false)
expect(busy).toHaveBeenLastCalledWith(false)
expect(notice).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'consequence' }))
expect(
useAppStore
.getState()
.browserUrlHistory.some((entry) => entry.url.startsWith('chrome-error:'))
).toBe(false)
}
)
it('keeps a pending failure when a resolved reload outcome read times out', async () => {
const { result } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => {
if (method === 'browser.tabShow') {
throw new Error('request timed out')
}
return { url: 'chrome-error://chromewebdata/', title: 'Error' }
})
await act(() => result.current.runRemoteNavigation('browser.reload'))
expectPending()
expect(currentPage().loading).toBe(false)
})
it('does not let a command settling after Proceed overwrite the native success or start another read', async () => {
const { result, busy, notice } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
const command = deferred<unknown>()
vi.mocked(callRuntimeRpc).mockReturnValue(command.promise)
let pending!: Promise<void>
await act(async () => {
pending = result.current.runRemoteNavigation('browser.reload')
await Promise.resolve()
})
act(() => {
result.current.prepareCertificateProceed()
useAppStore.getState().updateBrowserPageState('page-a', { loadError: null, loading: false })
})
const publications = busy.mock.calls.length
await act(async () => {
command.resolve({ url: 'chrome-error://chromewebdata/', title: 'Old' })
await pending
})
expect(currentPage().loadError).toBeNull()
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined()
expect(vi.mocked(callRuntimeRpc).mock.calls.map((call) => call[1])).toEqual(['browser.reload'])
expect(busy.mock.calls.length).toBe(publications)
expect(notice).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'consequence' }))
})
it.each(['older-first', 'newer-first', 'newer-fails'] as const)(
'retains current outcomes across command cleanup with %s settlement',
async (order) => {
const { result, session, tokens } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
const older = deferred<{ tab: BrowserTabInfo }>()
const newer = deferred<{ tab: BrowserTabInfo }>()
vi.mocked(callRuntimeRpc)
.mockResolvedValueOnce({ url: 'https://selfsigned.test/', title: 'Reload' })
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise)
let command!: Promise<void>
await act(async () => {
command = result.current.runRemoteNavigation('browser.reload')
})
const refresh = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)
if (order === 'newer-fails') {
await act(async () => {
newer.reject(new Error('input read unavailable'))
await expect(refresh).rejects.toThrow('input read unavailable')
})
} else if (order === 'newer-first') {
await act(async () => {
newer.resolve({ tab: { ...failedTab('challenge-z'), title: 'Current input' } })
await refresh
})
}
await act(async () => {
older.resolve({ tab: { ...failedTab('challenge-y'), title: 'Reload outcome' } })
await command
})
if (order === 'older-first') {
await act(async () => {
newer.resolve({ tab: { ...failedTab('challenge-z'), title: 'Current input' } })
await refresh
})
}
expectPending(order === 'newer-fails' ? 'challenge-y' : 'challenge-z')
expect(currentPage().title).toBe(order === 'newer-fails' ? 'Reload outcome' : 'Current input')
expect(currentPage().loading).toBe(false)
}
)
it('releases retired Reload loading before failed Proceed without a native wake', async () => {
const { result } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
const reload = deferred<unknown>()
vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) =>
method === 'browser.reload' ? reload.promise : undefined
)
let pending!: Promise<void>
await act(async () => {
pending = result.current.runRemoteNavigation('browser.reload')
})
expect(currentPage().loading).toBe(true)
act(() => result.current.prepareCertificateProceed())
expect(currentPage().loading).toBe(false)
await act(async () => {
reload.reject(new Error('connection lost before reaching host'))
await pending
})
expectPending()
expect(currentPage().loading).toBe(false)
})
it('does not bless external ABA or clear native-owned loading during local cleanup', async () => {
const { session, tokens, result } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
const token = tokens.createOperationToken('remote-page-a')!
act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState))
const response = deferred<{ tab: BrowserTabInfo }>()
vi.mocked(callRuntimeRpc).mockReturnValue(response.promise)
const pending = session.refreshTabInfo(token)
act(() => {
useAppStore.getState().updateBrowserPageState('page-a', { loadError: null })
result.current.applyRemoteTabInfo(failedTab())
useAppStore.getState().updateBrowserPageState('page-a', { loading: true })
session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState)
})
await act(async () => {
response.resolve({ tab: failedTab('stale') })
await pending
})
expectPending()
expect(currentPage().loading).toBe(true)
})
it.each(['initial-open', 'restart'] as const)(
'preserves command failure when %s fails before ready',
async (phase) => {
vi.useFakeTimers()
const { lifecycle, subscribe, result } = renderNavigation()
const status = deferred<{ capabilities: string[] }>()
const read = deferred<{ tab: BrowserTabInfo }>()
let held = phase === 'initial-open'
let commandStarted = false
vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => {
if (method === 'status.get') {
return held ? status.promise : { capabilities: ['browser.screencast.v1'] }
}
if (method === 'browser.goto') {
commandStarted = true
throw new Error('ERR_NAME_NOT_RESOLVED (-105)')
}
if (method === 'browser.tabShow') {
return commandStarted ? read.promise : { tab: failedTab() }
}
return undefined
})
try {
lifecycle.open()
await act(() => vi.advanceTimersByTimeAsync(0))
if (phase === 'restart') {
const callbacks = subscribe.mock.calls[0][1]
callbacks.onResponse({
id: 'stream-1',
ok: true,
result: {
type: 'ready',
subscriptionId: 'stream-1',
browserPageId: 'remote-page-a',
format: 'jpeg',
tab: failedTab()
},
_meta: { runtimeId: 'host-a' }
})
held = true
callbacks.onClose?.()
await act(() => vi.runOnlyPendingTimersAsync())
}
let command!: Promise<void>
await act(async () => {
command = result.current.runRemoteNavigation('browser.goto', 'https://missing.test/')
})
subscribe.mockRejectedValueOnce(
Object.assign(new Error('stream unavailable'), { code: 'browser_screencast_unsupported' })
)
await act(async () => {
status.resolve({ capabilities: ['browser.screencast.v1'] })
await vi.advanceTimersByTimeAsync(0)
})
await act(async () => {
read.reject(new Error('outcome read unavailable'))
await command
})
expect(currentPage().loadError).toEqual({
code: -105,
description: 'ERR_NAME_NOT_RESOLVED',
validatedUrl: 'https://missing.test/'
})
expect(currentPage().loading).toBe(false)
} finally {
lifecycle.dispose()
}
}
)
it('rejects a late read after native replacement/clear, another action, host switch, or unmount, then accepts a current read', async () => {
const { result, session, tokens, identity } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
for (const supersede of [
() =>
useAppStore
.getState()
.setBrowserPageCertificateFailure('page-a', failedTab('challenge-y').certificateFailure!),
() => useAppStore.getState().updateBrowserPageState('page-a', { loadError: null }),
() => session.invalidateTabInfoReads(),
() => {
identity.environmentId = 'environment-b'
},
() => {
identity.mounted = false
}
]) {
const response = deferred<unknown>()
vi.mocked(callRuntimeRpc).mockReturnValueOnce(response.promise)
const read = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)
act(supersede)
response.resolve({ tab: failedTab() })
expect(await read).toBeNull()
identity.environmentId = 'environment-a'
identity.mounted = true
}
vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ tab: failedTab('challenge-z') })
await act(() => session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!))
expectPending('challenge-z')
})
it('keeps the second navigation loading when an older outcome read rejects', async () => {
const { result, busy, notice } = renderNavigation()
const stale = deferred<unknown>()
const second = deferred<unknown>()
vi.mocked(callRuntimeRpc)
.mockResolvedValueOnce({ url: 'about:blank' })
.mockReturnValueOnce(stale.promise)
.mockReturnValueOnce(second.promise)
.mockResolvedValueOnce({ tab: failedTab('challenge-y') })
let first!: Promise<void>
let next!: Promise<void>
await act(async () => {
first = result.current.runRemoteNavigation('browser.reload')
await Promise.resolve()
await Promise.resolve()
next = result.current.runRemoteNavigation('browser.back')
await Promise.resolve()
})
await act(async () => {
stale.reject(new Error('stale read failed'))
await first
})
expect(currentPage().loading).toBe(true)
expect(busy).toHaveBeenLastCalledWith(true)
expect(notice).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'consequence' }))
await act(async () => {
second.resolve({ url: 'chrome-error://chromewebdata/' })
await next
})
expectPending('challenge-y')
expect(currentPage().loading).toBe(false)
})
it.each(['success', 'failure'] as const)(
'accepts a later native mirror %s after an early Proceed observation and rejects the older read',
async (outcome) => {
const { result, session, tokens } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
expectPending()
act(() => {
result.current.prepareCertificateProceed()
result.current.applyRemoteTabInfo({
...failedTab(),
certificateFailure: null
})
})
const stale = deferred<unknown>()
vi.mocked(callRuntimeRpc).mockReturnValueOnce(stale.promise)
const read = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)
const failure = outcome === 'failure' ? failedTab('challenge-y') : null
act(() =>
useAppStore.setState((state) =>
applyWebSessionTabsSnapshot(
state,
{
worktree: 'worktree-a',
publicationEpoch: 'tls-test',
snapshotVersion: 1,
activeTabType: 'browser',
activeTabId: 'host-tab',
activeGroupId: 'host-group',
tabs: [
{
type: 'browser',
id: 'host-tab',
browserWorkspaceId: 'workspace-a',
browserPageId: 'remote-page-a',
url: 'https://selfsigned.test/',
title: 'TLS',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: true,
loadError: failure?.loadError ?? null,
certificateFailure: failure?.certificateFailure ?? null
}
]
},
'environment-a',
100
)
)
)
stale.resolve({ tab: failedTab() })
expect(await read).toBeNull()
if (outcome === 'failure') {
expectPending('challenge-y')
} else {
expect(currentPage().loadError).toBeNull()
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined()
}
}
)
it.each([null, undefined])(
'keeps an explicit native clear but retains the legacy omitted-error fallback (%s)',
async (loadError) => {
const { result } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => {
if (typeof method !== 'string') {
return undefined
}
if (method === 'browser.tabShow') {
return {
tab: {
...failedTab(),
url: 'https://loaded.test/',
loadError,
certificateFailure: null
}
}
}
throw new Error('ERR_TIMED_OUT (-7)')
})
await act(() => result.current.runRemoteNavigation('browser.goto', 'https://loaded.test/'))
expect(currentPage().url).toBe('https://loaded.test/')
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined()
if (loadError === null) {
expect(currentPage().loadError).toBeNull()
} else {
expect(currentPage().loadError).toEqual({
code: -7,
description: 'ERR_TIMED_OUT',
validatedUrl: 'https://loaded.test/'
})
}
}
)
it('retains parsed command failure when its authoritative read also fails', async () => {
const { result } = renderNavigation()
vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => {
if (typeof method !== 'string') {
return undefined
}
throw new Error(method === 'browser.tabShow' ? 'read timed out' : 'ERR_TIMED_OUT (-7)')
})
await act(() => result.current.runRemoteNavigation('browser.goto', 'https://loaded.test/'))
expect(currentPage().loadError).toEqual({
code: -7,
description: 'ERR_TIMED_OUT',
validatedUrl: 'https://loaded.test/'
})
expect(currentPage().loading).toBe(false)
})
it('does not reinstall X after native clear-to-failure-to-clear at the same URL', async () => {
const { result, session, tokens } = renderNavigation()
act(() =>
result.current.applyRemoteTabInfo({
...failedTab(),
loadError: null,
certificateFailure: null
})
)
const response = deferred<unknown>()
vi.mocked(callRuntimeRpc).mockReturnValueOnce(response.promise)
const pending = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)
act(() => {
useAppStore.getState().updateBrowserPageState('page-a', { loadError: failedTab().loadError })
useAppStore
.getState()
.setBrowserPageCertificateFailure('page-a', failedTab().certificateFailure!)
useAppStore.getState().updateBrowserPageState('page-a', { loadError: null })
})
response.resolve({ tab: failedTab() })
expect(await pending).toBeNull()
expect(currentPage().loadError).toBeNull()
vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ tab: failedTab('challenge-y') })
await act(() => session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!))
expectPending('challenge-y')
})
it('applies admitted state before resolving, so a queued native clear remains final', async () => {
const { session, tokens } = renderNavigation()
const response = deferred<unknown>()
vi.mocked(callRuntimeRpc).mockReturnValueOnce(response.promise)
const pending = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)
const nativeClear = response.promise.then(() =>
queueMicrotask(() =>
useAppStore.getState().updateBrowserPageState('page-a', { loadError: null })
)
)
await act(async () => {
response.resolve({ tab: failedTab() })
await pending
await nativeClear
})
expect(currentPage().loadError).toBeNull()
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined()
})
it.each(['rejected', 'resolved', 'missing'] as const)(
'keeps a queued native replacement after an admitted %s read failure',
async (outcome) => {
const { result, notice, closeMissing } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
const read = deferred<unknown>()
vi.mocked(callRuntimeRpc).mockImplementation((_target, method) => {
if (typeof method !== 'string') {
return Promise.resolve(undefined)
}
if (method === 'browser.tabShow') {
return read.promise
}
if (outcome === 'rejected') {
return Promise.reject(new Error('ERR_CERT_AUTHORITY_INVALID (-202)'))
}
return Promise.resolve({ url: 'https://old.test/', title: 'Old' })
})
let pending!: Promise<void>
await act(async () => {
pending = result.current.runRemoteNavigation('browser.goto', 'https://old.test/')
await Promise.resolve()
await Promise.resolve()
})
const native = read.promise.catch(() =>
queueMicrotask(() => {
useAppStore.getState().setBrowserPageUrl('page-a', 'https://native.test/')
useAppStore
.getState()
.updateBrowserPageState('page-a', { loadError: null, loading: false, title: 'Native' })
notice.mockClear()
closeMissing.mockClear()
})
)
await act(async () => {
read.reject(
outcome === 'missing'
? Object.assign(new Error('page missing'), { code: 'browser_tab_not_found' })
: new Error('read timed out')
)
await pending
await native
})
expect(currentPage().url).toBe('https://native.test/')
expect(currentPage().title).toBe('Native')
expect(currentPage().loadError).toBeNull()
expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined()
expect(notice).not.toHaveBeenCalled()
expect(closeMissing).not.toHaveBeenCalled()
}
)
it.each([100, 1000])(
'drops an obsolete read without visiting %i unrelated workspace pages after map replacement',
async (count) => {
const { result, session, tokens } = renderNavigation()
act(() => result.current.applyRemoteTabInfo(failedTab()))
const response = deferred<unknown>()
vi.mocked(callRuntimeRpc).mockReturnValueOnce(response.promise)
const pending = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)
let visits = 0
let copies = 0
const unrelated = Object.fromEntries(
Array.from({ length: count }, (_, index) => [
`other-${index}`,
new Proxy([page(`other-${index}`)], {
get(target, key, receiver) {
if (key === 'map') {
copies += 1
}
if (typeof key === 'string' && /^\d+$/.test(key)) {
visits += 1
}
// oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy must forward array access unchanged while counting visits.
return Reflect.get(target, key, receiver)
}
})
])
)
useAppStore.setState((state) => ({
browserPagesByWorkspace: { ...state.browserPagesByWorkspace, ...unrelated }
}))
session.invalidateTabInfoReads()
response.resolve({ tab: failedTab('stale') })
expect(await pending).toBeNull()
expect(visits).toBe(0)
expect(copies).toBe(0)
expectPending()
vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ tab: failedTab('challenge-y') })
await act(() => session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!))
expectPending('challenge-y')
}
)
it.each([100, 1000])(
'publishes no work for identical decoded failure observations among %i pages',
async (count) => {
const { result, session, tokens } = renderNavigation(count)
const failures = Object.fromEntries(
Array.from({ length: count }, (_, index) => [
`page-${index}`,
failedTab(`other-${index}`).certificateFailure!
])
)
useAppStore.setState({ browserCertificateFailuresByPageId: failures })
act(() => result.current.applyRemoteTabInfo(failedTab()))
let enumerations = 0
let copies = 0
let visits = 0
const state = useAppStore.getState()
const counted = <T extends object>(value: T): T =>
new Proxy(value, {
ownKeys(target) {
enumerations += 1
return Reflect.ownKeys(target)
}
})
const countedPages = new Proxy(state.browserPagesByWorkspace['workspace-a'], {
get(target, key, receiver) {
if (key === 'map') {
copies += 1
}
if (typeof key === 'string' && /^\d+$/.test(key)) {
visits += 1
}
// oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy must forward array access unchanged while counting visits.
return Reflect.get(target, key, receiver)
}
})
useAppStore.setState({
browserPagesByWorkspace: counted({
...state.browserPagesByWorkspace,
'workspace-a': countedPages
}),
browserCertificateFailuresByPageId: counted(state.browserCertificateFailuresByPageId)
})
currentPage()
const before = useAppStore.getState()
enumerations = 0
copies = 0
visits = 0
const changed = vi.fn()
const unsubscribe = useAppStore.subscribe(changed)
vi.mocked(callRuntimeRpc).mockResolvedValueOnce({
tab: JSON.parse(JSON.stringify(failedTab()))
})
await act(async () => {
await session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)
})
unsubscribe()
expect(changed).not.toHaveBeenCalled()
expect(enumerations).toBe(0)
expect(copies).toBe(0)
expect(visits).toBeLessThanOrEqual(count + 20)
expect(useAppStore.getState()).toBe(before)
const unrelated = before.browserPagesByWorkspace['workspace-a'][1]
act(() => result.current.applyRemoteTabInfo(failedTab('challenge-y')))
expectPending('challenge-y')
expect(useAppStore.getState().browserPagesByWorkspace['workspace-a'][1]).toBe(unrelated)
}
)
})
@@ -1,22 +1,18 @@
import { runRemoteBrowserNavigationCommand } from './remote-browser-page-navigation-command'
import { useCallback, useRef } from 'react'
import { useAppStore } from '@/store'
import { redactKagiSessionToken } from '../../../../../shared/browser-url'
import { normalizeBrowserHistoryUrl } from '../../../../../shared/workspace-session/workspace-session-browser-history'
import { resolveBrowserAddressBarSubmission } from '../navigate/browser-address-bar-navigation'
import { routeWorkspaceDocAddressSubmission } from '../navigate/workspace-doc-address-submission'
import { deferBrowserPageNavigation } from '../navigate/browser-page-deferred-navigation'
import type {
BrowserBackResult,
BrowserGotoResult,
BrowserReloadResult,
BrowserTabInfo
} from '../../../../../shared/browser/runtime/runtime-browser-contracts'
import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts'
import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types'
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors'
import type { RemoteBrowserOperationToken } from './remote-browser-stream-tokens'
import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle'
import type { BrowserPageUrlSetter, BrowserTabPageState } from '../describe-page/browser-page-types'
import type {
BrowserPageUrlSetter,
BrowserPageStateWriter
} from '../describe-page/browser-page-types'
import { getBrowserDisplayTitle, toDisplayUrl } from '../describe-page/browser-page-url-display'
import type {
RemoteBrowserPaneNotice,
@@ -51,11 +47,12 @@ export function useRemoteBrowserPageNavigation({
isCurrentRemoteOperationToken: (token: RemoteBrowserOperationToken) => boolean
closeMissingRemotePage: (remotePageId?: string | null) => void
onSetUrl: BrowserPageUrlSetter
onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void
onUpdatePageState: BrowserPageStateWriter
setPaneNotice: (notice: RemoteBrowserPaneNotice | null) => void
setPaneBusy: (busy: boolean) => void
}): {
applyRemoteTabInfo: (tab: Pick<BrowserTabInfo, 'url' | 'title'>) => void
applyRemoteTabInfo: (tab: BrowserTabInfo) => void
prepareCertificateProceed: () => void
scheduleRemoteTabInfoRefresh: (token: RemoteBrowserOperationToken, delayMs?: number) => void
runRemoteNavigation: (
method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload',
@@ -66,16 +63,26 @@ export function useRemoteBrowserPageNavigation({
} {
const addBrowserHistoryEntry = useAppStore((state) => state.addBrowserHistoryEntry)
const lastFiledHistoryRef = useRef<string | null>(null)
const navigationSequence = useRef(0)
const applyRemoteTabInfo = useCallback(
(tab: Pick<BrowserTabInfo, 'url' | 'title'>): void => {
const applyRemoteTabMetadata = useCallback(
(
tab: Pick<BrowserTabInfo, 'url' | 'title'> & Partial<Pick<BrowserTabInfo, 'loadError'>>
): void => {
const safeUrl = redactKagiSessionToken(tab.url || 'about:blank')
const title = getBrowserDisplayTitle(tab.title, safeUrl)
onSetUrl(browserTab.id, safeUrl)
const page = useAppStore
.getState()
.browserPagesByWorkspace[browserTab.workspaceId]?.find(
(entry) => entry.id === browserTab.id
)
if (page?.url !== safeUrl) {
onSetUrl(browserTab.id, safeUrl, { preserveLoadError: true })
}
onUpdatePageState(browserTab.id, {
title,
loading: false,
loadError: null
...(tab.loadError !== undefined ? { loadError: tab.loadError } : {})
})
// File remote navigations in client suggestion history only when URL/title changes; post-input tab reads must not rewrite the store.
const filing = `${normalizeBrowserHistoryUrl(safeUrl)}\n${title}`
@@ -85,9 +92,37 @@ export function useRemoteBrowserPageNavigation({
}
setAddressBarValueFromPage(toDisplayUrl(safeUrl))
},
[addBrowserHistoryEntry, browserTab.id, onSetUrl, onUpdatePageState, setAddressBarValueFromPage]
[
addBrowserHistoryEntry,
browserTab.id,
browserTab.workspaceId,
onSetUrl,
onUpdatePageState,
setAddressBarValueFromPage
]
)
const applyRemoteTabInfo = useCallback(
(tab: BrowserTabInfo): void => {
applyRemoteTabMetadata({ ...tab, loadError: tab.loadError ?? null })
useAppStore
.getState()
.setBrowserPageCertificateFailure(browserTab.id, tab.certificateFailure ?? null)
},
[applyRemoteTabMetadata, browserTab.id]
)
const prepareCertificateProceed = useCallback((): void => {
navigationSequence.current += 1
const token = createRemoteOperationToken()
if (token) {
lifecycle.session.setPageLoading(token, false, onUpdatePageState)
}
lifecycle.session.invalidateTabInfoReads()
setPaneBusy(false)
setPaneNotice(null)
}, [createRemoteOperationToken, lifecycle, onUpdatePageState, setPaneBusy, setPaneNotice])
const scheduleRemoteTabInfoRefresh = useCallback(
(token: RemoteBrowserOperationToken, delayMs = 250): void => {
lifecycle.session.scheduleTabInfoRefresh(token, delayMs)
@@ -100,76 +135,29 @@ export function useRemoteBrowserPageNavigation({
method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload',
url?: string
) => {
const target = runtimeTarget()
if (!target) {
return
}
if (stagedPage) {
// No staged host page exists yet: park goto until adoption; history/reload cannot replay against an uncreated page.
if (method === 'browser.goto' && url) {
deferBrowserPageNavigation(browserTab.id, url)
onUpdatePageState(browserTab.id, { loading: true, loadError: null })
}
return
}
const operationToken = createRemoteOperationToken()
if (!operationToken) {
return
}
const pageId = await lifecycle.session.ensureRemotePage(operationToken)
if (!pageId) {
return
}
const pageToken = { ...operationToken, remotePageId: pageId }
if (!isCurrentRemoteOperationToken(pageToken)) {
return
}
setPaneBusy(true)
setPaneNotice(null)
// Why: a standing failure (and its auto-retry) stays up until a load succeeds or fails anew.
onUpdatePageState(browserTab.id, { loading: true })
try {
const params =
method === 'browser.goto'
? { worktree: runtimeWorktree, page: pageId, url: url ?? 'about:blank' }
: { worktree: runtimeWorktree, page: pageId }
const result = await callRuntimeRpc<
BrowserGotoResult | BrowserBackResult | BrowserReloadResult
>(target, method, params, { timeoutMs: 30_000, suppressFeatureInteraction: true })
if (isCurrentRemoteOperationToken(pageToken)) {
applyRemoteTabInfo(result)
}
} catch (error) {
if (!isCurrentRemoteOperationToken(pageToken)) {
return
}
if (isRemoteBrowserPageMissingError(error)) {
closeMissingRemotePage(pageId)
return
}
const message = error instanceof Error ? error.message : 'Remote browser command failed.'
setPaneNotice({ kind: 'consequence', text: message })
// Why: goto results carry no load error, but the rejection keeps Electron's "ERR_NAME (-code)" text.
const netError = /\b(ERR_[A-Z_]+) \((-\d+)\)/.exec(message)
onUpdatePageState(browserTab.id, {
loading: false,
// Why: validatedUrl is persisted, so redact the Kagi session token like the main-process failure path does.
loadError: {
code: netError ? Number(netError[2]) : 0,
description: netError ? netError[1] : message,
validatedUrl: redactKagiSessionToken(url ?? browserTab.url)
}
})
} finally {
if (isCurrentRemoteOperationToken(pageToken)) {
setPaneBusy(false)
}
}
return runRemoteBrowserNavigationCommand(
{
browserTab,
stagedPage,
lifecycle,
runtimeWorktree,
runtimeTarget,
createRemoteOperationToken,
isCurrentRemoteOperationToken,
closeMissingRemotePage,
onUpdatePageState,
setPaneNotice,
setPaneBusy,
navigationSequence,
applyRemoteTabMetadata
},
method,
url
)
},
[
applyRemoteTabInfo,
browserTab.id,
browserTab.url,
applyRemoteTabMetadata,
browserTab,
createRemoteOperationToken,
lifecycle,
closeMissingRemotePage,
@@ -216,6 +204,7 @@ export function useRemoteBrowserPageNavigation({
return {
applyRemoteTabInfo,
prepareCertificateProceed,
scheduleRemoteTabInfoRefresh,
runRemoteNavigation,
navigateToUrl,
@@ -58,7 +58,7 @@ export function useRemoteBrowserPageStream({
pendingFrameDecodeRef: React.MutableRefObject<number>
streamBridgeRef: React.MutableRefObject<RemoteBrowserStreamBridge>
isActiveRef: React.RefObject<boolean>
applyTabInfo: (tab: Pick<BrowserTabInfo, 'url' | 'title'>) => void
applyTabInfo: (tab: BrowserTabInfo) => void
clearStreamFrame: () => void
closeMissingRemotePage: (remotePageId?: string | null) => void
clearPendingRemoteWheel: () => void
@@ -0,0 +1,39 @@
import type { BrowserPage } from '../../../../shared/browser-workspace-types'
export type BrowserPageLoadingOwner = { active: boolean }
const loadingOwners = new WeakMap<BrowserPage, BrowserPageLoadingOwner>()
export function readBrowserPageLoadingOwner(
page: BrowserPage | undefined
): BrowserPageLoadingOwner | undefined {
const owner = page && loadingOwners.get(page)
return owner?.active ? owner : undefined
}
export function revokeBrowserPageLoadingOwner(page: BrowserPage): void {
const owner = loadingOwners.get(page)
if (owner) {
owner.active = false
loadingOwners.delete(page)
}
}
export function preserveBrowserPageLoadingOwner(previous: BrowserPage, next: BrowserPage): void {
const owner = readBrowserPageLoadingOwner(previous)
if (owner && previous !== next) {
loadingOwners.delete(previous)
loadingOwners.set(next, owner)
}
}
export function assignBrowserPageLoadingOwner(
previous: BrowserPage,
next: BrowserPage,
owner?: BrowserPageLoadingOwner
): void {
revokeBrowserPageLoadingOwner(previous)
if (owner?.active) {
loadingOwners.set(next, owner)
}
}
@@ -1,5 +1,9 @@
import { annotateBrowserPageHandles } from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership'
import type { applyTerminalRecordUpdates } from './apply-terminal-records'
import {
preserveBrowserPageLoadingOwner,
revokeBrowserPageLoadingOwner
} from '@/lib/browser/browser-page-loading-ownership'
import {
browserCertificateFailureEqual,
optionalRuntimeBrowserPlacementsEqual,
@@ -50,6 +54,7 @@ export function applyBrowserRecordUpdates(context: BrowserRecordContext) {
if (nextBrowserPageIds.has(page.id)) {
continue
}
revokeBrowserPageLoadingOwner(page)
if (nextBrowserCertificateFailuresByPageId[page.id]) {
nextBrowserCertificateFailuresByPageId =
nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId
@@ -79,12 +84,21 @@ export function applyBrowserRecordUpdates(context: BrowserRecordContext) {
for (const {
page,
previousPage,
preservesLoading,
certificateFailure,
remotePageId,
browserProfileId,
placement,
initializationPresentation
} of mirroredBrowserTabs) {
if (previousPage) {
if (preservesLoading) {
preserveBrowserPageLoadingOwner(previousPage, page)
} else {
revokeBrowserPageLoadingOwner(previousPage)
}
}
const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? []
if (!sameBrowserPages(current, [page])) {
nextBrowserPagesByWorkspace =
@@ -128,7 +128,13 @@ export function resolveMirroredBrowserPageContent(
previousPresentation?: BrowserInitializationPresentation,
stale = false,
reconcile = false
): Pick<BrowserPage, 'url' | 'title' | 'loading' | 'canGoBack' | 'canGoForward' | 'faviconUrl'> {
): {
content: Pick<
BrowserPage,
'url' | 'title' | 'loading' | 'canGoBack' | 'canGoForward' | 'faviconUrl'
>
preservesLoading: boolean
} {
const localAction = existingPage && hasBrowserPageInitializationAction(existingPage.id)
if (
existingPage &&
@@ -137,27 +143,33 @@ export function resolveMirroredBrowserPageContent(
(!reconcile && clientHostsMirroredBrowserPage(tab) && !presentation && !previousPresentation))
) {
return {
url: existingPage.url,
title: existingPage.title,
loading:
presentation?.disposition === 'pending'
? true
: presentation
? false
: existingPage.loading,
canGoBack: existingPage.canGoBack,
canGoForward: existingPage.canGoForward,
faviconUrl: existingPage.faviconUrl
preservesLoading: !presentation,
content: {
url: existingPage.url,
title: existingPage.title,
loading:
presentation?.disposition === 'pending'
? true
: presentation
? false
: existingPage.loading,
canGoBack: existingPage.canGoBack,
canGoForward: existingPage.canGoForward,
faviconUrl: existingPage.faviconUrl
}
}
}
return {
url: tab.url,
title: resolveMirroredBrowserTitle(tab, existingPage),
loading: tab.loading,
canGoBack: tab.canGoBack,
canGoForward: tab.canGoForward,
// Old hosts omit the field; keep what this client already has.
faviconUrl: tab.faviconUrl === undefined ? (existingPage?.faviconUrl ?? null) : tab.faviconUrl
preservesLoading: false,
content: {
url: tab.url,
title: resolveMirroredBrowserTitle(tab, existingPage),
loading: tab.loading,
canGoBack: tab.canGoBack,
canGoForward: tab.canGoForward,
// Old hosts omit the field; keep what this client already has.
faviconUrl: tab.faviconUrl === undefined ? (existingPage?.faviconUrl ?? null) : tab.faviconUrl
}
}
}
@@ -210,7 +222,7 @@ export function buildMirroredBrowserTabs(
const groupId = clientGroupId ?? hostGroupId
const previousHandle = existing ? state.remoteBrowserPageHandlesByPageId[pageId] : undefined
const presentation = browserInitializationPresentation(snapshot, tab, previousHandle)
const content = resolveMirroredBrowserPageContent(
const { content, preservesLoading } = resolveMirroredBrowserPageContent(
tab,
existing?.page,
presentation,
@@ -270,6 +282,8 @@ export function buildMirroredBrowserTabs(
return {
workspace,
page,
previousPage: existing?.page,
preservesLoading,
certificateFailure: tab.certificateFailure ?? null,
remotePageId: tab.browserPageId,
initializationPresentation: presentation,
@@ -190,6 +190,8 @@ export type MirroredTerminalTab = {
export type MirroredBrowserTab = {
workspace: BrowserWorkspace
page: BrowserPage
previousPage?: BrowserPage
preservesLoading: boolean
certificateFailure: BrowserCertificateFailure | null
remotePageId: string
browserProfileId?: string
@@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import type { BrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership'
import type { ManagedBrowserProfileOwner } from '../../../../../shared/browser/profiles/browser-profile-scope'
/** What a new tab opens on; a partition only when it must not be derived from the profile. */
@@ -190,8 +191,16 @@ export type BrowserSlice = {
options?: { surfacePane?: boolean }
) => void
consumeAddressBarFocusRequest: (pageId: string) => boolean
updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void
updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void
updateBrowserTabPageState: (
pageId: string,
updates: BrowserTabPageState,
loadingOwner?: BrowserPageLoadingOwner
) => void
updateBrowserPageState: (
pageId: string,
updates: BrowserTabPageState,
loadingOwner?: BrowserPageLoadingOwner
) => void
setBrowserPageCertificateFailure: (
pageId: string,
failure: BrowserCertificateFailure | null
@@ -97,12 +97,17 @@ export function mirrorWorkspaceFromActivePage(
workspace: BrowserWorkspace,
pages: BrowserPage[]
): BrowserWorkspace {
const pageIds =
workspace.pageIds?.length === pages.length &&
pages.every((page, index) => page.id === workspace.pageIds?.[index])
? workspace.pageIds
: pages.map((page) => page.id)
const activePage = pages.find((page) => page.id === workspace.activePageId) ?? null
if (!activePage) {
return {
...workspace,
activePageId: null,
pageIds: pages.map((page) => page.id),
pageIds,
url: 'about:blank',
title: translate('auto.store.slices.browser.08fc23631d', 'Browser'),
loading: false,
@@ -116,7 +121,7 @@ export function mirrorWorkspaceFromActivePage(
return {
...workspace,
activePageId: activePage.id,
pageIds: pages.map((page) => page.id),
pageIds,
url: activePage.url,
title: activePage.title,
loading: activePage.loading,
@@ -136,8 +141,9 @@ export function browserWorkspaceMirrorFieldsEqual(
const mirroredPageIds = mirrored.pageIds ?? []
return (
workspace.activePageId === mirrored.activePageId &&
workspacePageIds.length === mirroredPageIds.length &&
workspacePageIds.every((pageId, index) => pageId === mirroredPageIds[index]) &&
(workspacePageIds === mirroredPageIds ||
(workspacePageIds.length === mirroredPageIds.length &&
workspacePageIds.every((pageId, index) => pageId === mirroredPageIds[index]))) &&
workspace.url === mirrored.url &&
workspace.title === mirrored.title &&
workspace.loading === mirrored.loading &&
@@ -1,5 +1,10 @@
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from '../browser-slice-contract'
import type { AppState } from '../../../types'
import {
assignBrowserPageLoadingOwner,
preserveBrowserPageLoadingOwner,
revokeBrowserPageLoadingOwner
} from '@/lib/browser/browser-page-loading-ownership'
import '../../../../../../shared/constants'
import { ORCA_BROWSER_BLANK_URL } from '../../../../../../shared/browser/browser-blank-page'
import {
@@ -12,6 +17,10 @@ import {
} from './browser-page-records'
import { normalizeBrowserHistoryUrl } from '../../../../../../shared/workspace-session/workspace-session-browser-history'
import { replaceTabBuckets } from '../../../keyed-record-transitions'
import {
browserCertificateFailureEqual,
browserPageEqual
} from '@/runtime/web-session-tabs-sync/state-equality-tabs'
export function createBrowserPageStateActions(
set: BrowserSliceSet,
@@ -25,9 +34,10 @@ export function createBrowserPageStateActions(
| 'setBrowserPageUrl'
> {
return {
updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates),
updateBrowserTabPageState: (pageId, updates, loadingOwner) =>
get().updateBrowserPageState(pageId, updates, loadingOwner),
updateBrowserPageState: (pageId, updates) => {
updateBrowserPageState: (pageId, updates, loadingOwner) => {
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
@@ -58,13 +68,12 @@ export function createBrowserPageStateActions(
: -1
const unifiedLabelNeedsRepair =
unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title
const pageStateUnchanged =
nextPage.title === page.title &&
nextPage.loading === page.loading &&
nextPage.faviconUrl === page.faviconUrl &&
nextPage.canGoBack === page.canGoBack &&
nextPage.canGoForward === page.canGoForward &&
nextPage.loadError === page.loadError
const pageStateUnchanged = browserPageEqual(nextPage, page)
if (updates.loading === undefined) {
preserveBrowserPageLoadingOwner(page, pageStateUnchanged ? page : nextPage)
} else {
assignBrowserPageLoadingOwner(page, pageStateUnchanged ? page : nextPage, loadingOwner)
}
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
const mirroredWorkspace = pageStateUnchanged
? mirrorWorkspaceFromActivePage(workspace, currentPages)
@@ -158,7 +167,10 @@ export function createBrowserPageStateActions(
delete nextFailures[pageId]
return { browserCertificateFailuresByPageId: nextFailures }
}
if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) {
if (
!findPage(s.browserPagesByWorkspace, pageId) ||
browserCertificateFailureEqual(current, failure)
) {
return s
}
return {
@@ -191,6 +203,7 @@ export function createBrowserPageStateActions(
}
// Keep document URL blank at navigation sink too; committing grants here leaks to persistence, publication and address bar.
const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl
revokeBrowserPageLoadingOwner(page)
const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) =>
entry.id === pageId
? {
@@ -217,7 +230,9 @@ export function createBrowserPageStateActions(
}
}
})
get().setBrowserPageCertificateFailure(pageId, null)
if (!options?.preserveLoadError) {
get().setBrowserPageCertificateFailure(pageId, null)
}
}
}
}
@@ -1,5 +1,6 @@
import type { BrowserSlice, BrowserSliceSet } from '../browser-slice-contract'
import { findPage, findWorkspace } from './browser-page-records'
import { preserveBrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership'
export function createBrowserPageViewportActions(
set: BrowserSliceSet
@@ -15,9 +16,14 @@ export function createBrowserPageViewportActions(
if (!workspace) {
return s
}
const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) =>
entry.id === pageId ? { ...entry, viewportPresetId } : entry
)
const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => {
if (entry.id !== pageId) {
return entry
}
const nextPage = { ...entry, viewportPresetId }
preserveBrowserPageLoadingOwner(entry, nextPage)
return nextPage
})
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,