mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+240
@@ -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()
|
||||
})
|
||||
})
|
||||
+166
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -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
|
||||
})
|
||||
}
|
||||
+27
@@ -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)]
|
||||
}
|
||||
+10
-10
@@ -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}
|
||||
|
||||
+1
@@ -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) => {
|
||||
|
||||
+139
-13
@@ -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
|
||||
}
|
||||
|
||||
+3
@@ -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',
|
||||
|
||||
+1
@@ -190,6 +190,7 @@ export function createHarness() {
|
||||
callRpc,
|
||||
subscribeScreencast,
|
||||
getWorktreeSelector: () => 'worktree:wt-1',
|
||||
readCurrentObservation: () => [],
|
||||
getCurrentUrl: () => 'https://example.test/',
|
||||
readStoredHandle: () => storedHandle,
|
||||
writeStoredHandle: (handle) => {
|
||||
|
||||
+29
@@ -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)
|
||||
|
||||
+33
-31
@@ -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)
|
||||
|
||||
+1
-6
@@ -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
|
||||
}
|
||||
|
||||
+38
-5
@@ -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,
|
||||
|
||||
+653
-55
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+74
-85
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user