mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(browser-preview): render remote HTML docs locally over an orca-preview scheme (STA-5557) (#16679)
This commit is contained in:
+505
-110
File diff suppressed because one or more lines are too long
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
|
||||
vi.mock('./popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge'
|
||||
import {
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from './browser-manager-test-harness'
|
||||
import {
|
||||
createViewportGuestFactory,
|
||||
flushViewportOps
|
||||
} from './browser-manager-viewport-test-fixtures'
|
||||
|
||||
const { webContentsFromIdMock } = browserMocks
|
||||
const makeGuest = createViewportGuestFactory(browserMocks)
|
||||
|
||||
const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
}
|
||||
|
||||
function registerPage(pageId: string, guest: Record<string, unknown>): void {
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: pageId,
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
}
|
||||
|
||||
/** The production resolver's shape: read the registry now, not when the request was made. */
|
||||
function resolveFromRegistry(pageId: string): () => Electron.WebContents | null {
|
||||
return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId)
|
||||
}
|
||||
|
||||
describe('browserManager.setAnnotationViewportBridge', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('installs the bridge in an isolated world', async () => {
|
||||
const { guest } = makeGuest(4646)
|
||||
registerPage('tab-annotations', guest)
|
||||
|
||||
const ok = await browserManager.setAnnotationViewportBridge(
|
||||
'tab-annotations',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-annotations')
|
||||
)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
// Why this is the case that matters: a cross-process navigation re-registers the same page id
|
||||
// with a new WebContents and leaves the retired one alive, so a bridge op that resolved its
|
||||
// guest when the request arrived would inject into a page nobody is looking at — and the
|
||||
// annotation badges would stop tracking the document on screen.
|
||||
it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => {
|
||||
const { guest: firstGuest } = makeGuest(5101)
|
||||
let releaseFirstInjection = (): void => {}
|
||||
// One shared gate for every call, so a wrongly-routed second injection still settles and the
|
||||
// test fails on where it landed rather than on a timeout.
|
||||
const firstInjectionGate = new Promise<void>((resolve) => {
|
||||
releaseFirstInjection = () => resolve()
|
||||
})
|
||||
firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate)
|
||||
registerPage('tab-swap', firstGuest)
|
||||
|
||||
const firstDone = browserManager.setAnnotationViewportBridge(
|
||||
'tab-swap',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-swap')
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
// A second request arrives while the first still holds the chain — at this moment the page is
|
||||
// still on the first guest, which is the guest a request-time resolution would capture.
|
||||
const secondDone = browserManager.setAnnotationViewportBridge(
|
||||
'tab-swap',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-swap')
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
// Only now does the page swap renderer processes, while the second op is still queued.
|
||||
const { guest: secondGuest } = makeGuest(5102)
|
||||
registerPage('tab-swap', secondGuest)
|
||||
|
||||
releaseFirstInjection()
|
||||
await expect(firstDone).resolves.toBe(true)
|
||||
await expect(secondDone).resolves.toBe(true)
|
||||
|
||||
expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
|
||||
// is the one that can see the guest is gone. The bridge only reports the refusal.
|
||||
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
|
||||
const { guest } = makeGuest(5103)
|
||||
registerPage('tab-dies', guest)
|
||||
expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103)
|
||||
|
||||
webContentsFromIdMock.mockReturnValue(null)
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge(
|
||||
'tab-dies',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-dies')
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
|
||||
// Why assert the registry and not just the answer: a stale guest has to clear every per-tab
|
||||
// entry, or the page keeps a dead WebContents id that later ops resolve against.
|
||||
expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull()
|
||||
})
|
||||
|
||||
// Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by
|
||||
// the wrong renderer names a page that is alive and on screen, and answering it with teardown
|
||||
// would cancel that page's in-flight downloads and grabs over a misaddressed message.
|
||||
it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => {
|
||||
const { guest } = makeGuest(5105)
|
||||
registerPage('tab-mismatch', guest)
|
||||
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () =>
|
||||
browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1)
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(unregisterGuest).not.toHaveBeenCalled()
|
||||
expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a destroyed guest without injecting into it', async () => {
|
||||
const { guest } = makeGuest(5104)
|
||||
registerPage('tab-destroyed', guest)
|
||||
;(guest.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true)
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge(
|
||||
'tab-destroyed',
|
||||
BRIDGE_OPTIONS,
|
||||
() => guest as never
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a document page owns no browsing state, so treating its refusal as a stale page would run
|
||||
// teardown against an id the browsing registry never held.
|
||||
it('does not run page teardown when a document page resolves to nothing', async () => {
|
||||
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null)
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(unregisterGuest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,32 @@ import {
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from './browser-manager-test-harness'
|
||||
import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
/**
|
||||
* A page the document half of the registry really holds. Built rather than named: membership is
|
||||
* what both doors refuse on now, so an id that merely looks like a preview's would be admitted.
|
||||
*/
|
||||
function registerWorkspaceDocPage(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() })
|
||||
}
|
||||
|
||||
const {
|
||||
guestOffMock,
|
||||
@@ -60,6 +86,7 @@ describe('browserManager', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -92,6 +119,77 @@ describe('browserManager', () => {
|
||||
expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work')
|
||||
})
|
||||
|
||||
// Why both doors: one id in both halves of the registry would make the tool door answer with a
|
||||
// document guest for a page the reader is browsing in.
|
||||
it.each(['registerGuest', 'registerOffscreenGuest'] as const)(
|
||||
'refuses %s for a page the document registry already holds',
|
||||
(entryPoint) => {
|
||||
const guest = {
|
||||
id: 129,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
const browserPageId = 'doc-page-1'
|
||||
registerWorkspaceDocPage(browserPageId)
|
||||
|
||||
if (entryPoint === 'registerGuest') {
|
||||
expect(
|
||||
browserManager.registerGuest({
|
||||
browserPageId,
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
).toBe(false)
|
||||
} else {
|
||||
expect(
|
||||
browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id })
|
||||
).toBe(false)
|
||||
}
|
||||
|
||||
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
// Why this answer is load-bearing: the headless backend destroys its window on false, so a true
|
||||
// for a guest that is already gone would leave a page id registered onto nothing.
|
||||
it.each(['missing', 'destroyed'] as const)(
|
||||
'refuses registerOffscreenGuest when the named guest is %s',
|
||||
(guestState) => {
|
||||
webContentsFromIdMock.mockReturnValue(
|
||||
guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) }
|
||||
)
|
||||
|
||||
expect(
|
||||
browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 })
|
||||
).toBe(false)
|
||||
|
||||
expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
|
||||
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
|
||||
it('refuses unregisterGuest for a page the document registry holds', () => {
|
||||
registerWorkspaceDocPage('doc-page-2')
|
||||
const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp')
|
||||
|
||||
browserManager.unregisterGuest('doc-page-2')
|
||||
|
||||
expect(cancelGrabOp).not.toHaveBeenCalled()
|
||||
|
||||
// The presence half: the same door does evict a browsing page's grab.
|
||||
browserManager.unregisterGuest('browser-page-1')
|
||||
expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted')
|
||||
cancelGrabOp.mockRestore()
|
||||
})
|
||||
|
||||
it('blocks non-web guest navigations after attach', () => {
|
||||
const guest = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
vi.mock('./popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
|
||||
import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
type GuestFake = {
|
||||
id: number
|
||||
url: string
|
||||
listeners: Map<string, ((...args: never[]) => void)[]>
|
||||
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
|
||||
webRtcPolicy: string | null
|
||||
isDestroyed: () => boolean
|
||||
isFocused: () => boolean
|
||||
getURL: () => string
|
||||
getType: () => string
|
||||
setBackgroundThrottling: (value: boolean) => void
|
||||
setWebRTCIPHandlingPolicy: (policy: string) => void
|
||||
setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
|
||||
executeJavaScriptInIsolatedWorld: ReturnType<typeof vi.fn>
|
||||
debugger: {
|
||||
isAttached: () => boolean
|
||||
attach: ReturnType<typeof vi.fn>
|
||||
sendCommand: ReturnType<typeof vi.fn>
|
||||
}
|
||||
on: (event: string, listener: (...args: never[]) => void) => void
|
||||
once: (event: string, listener: (...args: never[]) => void) => void
|
||||
off: (event: string, listener: (...args: never[]) => void) => void
|
||||
}
|
||||
|
||||
function createGuest(id: number, url: string): GuestFake {
|
||||
const listeners = new Map<string, ((...args: never[]) => void)[]>()
|
||||
const guest: GuestFake = {
|
||||
id,
|
||||
url,
|
||||
listeners,
|
||||
windowOpenHandler: null,
|
||||
webRtcPolicy: null,
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => true,
|
||||
getURL: () => guest.url,
|
||||
getType: () => 'webview',
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: (policy: string) => {
|
||||
guest.webRtcPolicy = policy
|
||||
},
|
||||
setWindowOpenHandler: (handler) => {
|
||||
guest.windowOpenHandler = handler
|
||||
},
|
||||
executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
|
||||
debugger: {
|
||||
isAttached: () => true,
|
||||
attach: vi.fn(),
|
||||
sendCommand: vi.fn(async () => undefined)
|
||||
},
|
||||
on: (event, listener) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener])
|
||||
},
|
||||
once: (event, listener) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener])
|
||||
},
|
||||
off: (event, listener) => {
|
||||
listeners.set(
|
||||
event,
|
||||
(listeners.get(event) ?? []).filter((entry) => entry !== listener)
|
||||
)
|
||||
}
|
||||
}
|
||||
return guest
|
||||
}
|
||||
|
||||
function listenerCount(guest: GuestFake, event: string): number {
|
||||
return guest.listeners.get(event)?.length ?? 0
|
||||
}
|
||||
|
||||
/** Drives the guest's own will-navigate listeners and reports whether they refused. */
|
||||
function navigateTo(guest: GuestFake, url: string): boolean {
|
||||
let prevented = false
|
||||
const event = { preventDefault: () => (prevented = true) } as never
|
||||
for (const listener of guest.listeners.get('will-navigate') ?? []) {
|
||||
;(listener as (event: unknown, url: string) => void)(event, url)
|
||||
}
|
||||
return prevented
|
||||
}
|
||||
|
||||
const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents
|
||||
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
revokeAllDocPreviewGrants()
|
||||
browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('guest policy profiles', () => {
|
||||
function attachPreviewGuest(id = 301): {
|
||||
guest: GuestFake
|
||||
grantId: string
|
||||
browserPageId: string
|
||||
} {
|
||||
const browserPageId = `doc-page-${id}`
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host })
|
||||
return { guest, grantId: grant.id, browserPageId }
|
||||
}
|
||||
|
||||
// The presence half of every absence below: a browsing guest observably takes all of it through
|
||||
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
|
||||
// anything at all — cannot pass these by being uniformly empty.
|
||||
it('gives a browsing guest link routing, popups and anti-detection', () => {
|
||||
const guest = createGuest(300, 'https://example.com/')
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(1)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(1)
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalled()
|
||||
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
|
||||
})
|
||||
|
||||
it('gives a workspace-document guest none of it', () => {
|
||||
const { guest } = attachPreviewGuest()
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(0)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(0)
|
||||
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds a workspace-document guest inside the grant it is showing', () => {
|
||||
const { guest, grantId } = attachPreviewGuest()
|
||||
|
||||
expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false)
|
||||
expect(navigateTo(guest, 'https://example.com/')).toBe(true)
|
||||
expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp')
|
||||
})
|
||||
|
||||
it('denies every window a workspace-document guest asks for', () => {
|
||||
const { guest } = attachPreviewGuest()
|
||||
|
||||
expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
})
|
||||
|
||||
// Why teardown and not just install: registration and teardown both key on the guest having been
|
||||
// policy-attached, so a profile that installs outside that bookkeeping leaves the id marked
|
||||
// attached forever and stays answerable to tools after the surface is gone.
|
||||
it('tears a workspace-document guest down through the same policy cleanup', () => {
|
||||
const { guest, browserPageId } = attachPreviewGuest(302)
|
||||
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never)
|
||||
|
||||
for (const listener of guest.listeners.get('destroyed') ?? []) {
|
||||
listener()
|
||||
}
|
||||
|
||||
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull()
|
||||
// The manager's own bookkeeping was reached too: a second attach is refused while the id is
|
||||
// still marked policy-attached, and accepted once its teardown has run.
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
})
|
||||
|
||||
// The seam the whole split rests on: one public door answers for both halves, and each page id
|
||||
// resolves in exactly one of them. Asserted against the real manager, because the IPC census
|
||||
// test has to mock it.
|
||||
describe('the one door across both halves of the registry', () => {
|
||||
const BROWSING_PAGE_ID = 'browser-page-1'
|
||||
|
||||
function registerBrowsingGuest(id = 400): GuestFake {
|
||||
const guest = createGuest(id, 'https://example.com/')
|
||||
browserMocks.webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: BROWSING_PAGE_ID,
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
webContentsId: id,
|
||||
rendererWebContentsId: host.id
|
||||
})
|
||||
return guest
|
||||
}
|
||||
|
||||
it('answers each page with the guest of its own half', () => {
|
||||
const browsing = registerBrowsingGuest()
|
||||
const { guest: document, browserPageId } = attachPreviewGuest()
|
||||
|
||||
expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never)
|
||||
expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never)
|
||||
})
|
||||
|
||||
// Why this is the containment claim and not a lookup detail: page management, agent commands,
|
||||
// download routing and certificate attribution all read the browsing map directly, so a
|
||||
// document page being absent from it is what fences them without a guard of their own.
|
||||
it('keeps a document page out of the browsing map entirely', () => {
|
||||
registerBrowsingGuest()
|
||||
const { browserPageId } = attachPreviewGuest()
|
||||
|
||||
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
|
||||
expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID])
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches a guest once whatever profile it was asked for', () => {
|
||||
const { guest } = attachPreviewGuest(303)
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'will-navigate')).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -882,34 +882,5 @@ describe('browserManager', () => {
|
||||
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installs annotation viewport bridge in an isolated world', async () => {
|
||||
const { guest } = makeGuest(4646)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-annotations',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
})
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,6 +63,11 @@ import {
|
||||
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
|
||||
buildBrowserAnnotationViewportBridgeScript
|
||||
} from '../../shared/browser-annotation-viewport-bridge'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy,
|
||||
isWorkspaceDocPageId
|
||||
} from './doc-preview-guest-policy'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
import {
|
||||
BrowserCertificateTrustController,
|
||||
@@ -157,6 +162,15 @@ type PopupOwnerContext = {
|
||||
browserTabId: string
|
||||
rootGuestWebContentsId: number
|
||||
}
|
||||
/**
|
||||
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
|
||||
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
|
||||
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
|
||||
*/
|
||||
export type BrowserGuestPolicy =
|
||||
| { profile: 'browsing' }
|
||||
| { profile: 'workspace-doc'; host: Electron.WebContents }
|
||||
const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
|
||||
type PendingMainFrameNavigation = {
|
||||
currentUrl: string
|
||||
supersededUrls: string[]
|
||||
@@ -671,12 +685,20 @@ export class BrowserManager {
|
||||
|
||||
attachGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null,
|
||||
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
|
||||
): void {
|
||||
if (this.policyAttachedGuestIds.has(guest.id)) {
|
||||
return
|
||||
}
|
||||
this.policyAttachedGuestIds.add(guest.id)
|
||||
// Why one door with a profile rather than a second installer beside it: whether a guest was
|
||||
// policy-attached at all is what registration and teardown both key on, so a guest that took
|
||||
// another path into the app is invisible to both.
|
||||
if (policy.profile === 'workspace-doc') {
|
||||
this.attachWorkspaceDocGuestPolicies(guest, policy.host)
|
||||
return
|
||||
}
|
||||
if (inheritedOwnerContext) {
|
||||
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
|
||||
}
|
||||
@@ -1016,6 +1038,30 @@ export class BrowserManager {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
|
||||
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
|
||||
* this method's teardown, so a retired preview drops its listeners on the same path.
|
||||
*/
|
||||
private attachWorkspaceDocGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
host: Electron.WebContents
|
||||
): void {
|
||||
const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
|
||||
const handleDestroyed = (): void => {
|
||||
this.cleanupGuestPolicyAttachment(guest.id)
|
||||
}
|
||||
guest.on('destroyed', handleDestroyed)
|
||||
this.policyCleanupByGuestId.set(guest.id, () => {
|
||||
disposeDocPolicy()
|
||||
try {
|
||||
guest.off('destroyed', handleDestroyed)
|
||||
} catch {
|
||||
// guest may already be destroyed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
|
||||
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
|
||||
// must be matched here per navigation or the two layers disagree — itself a bot tell.
|
||||
@@ -1318,7 +1364,9 @@ export class BrowserManager {
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration): boolean {
|
||||
const browserTabId = browserPageId ?? legacyBrowserTabId
|
||||
if (!browserTabId) {
|
||||
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
|
||||
// id resolves in both and the tool door silently prefers the document guest over the page.
|
||||
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
|
||||
return false
|
||||
}
|
||||
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
|
||||
@@ -1377,6 +1425,12 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
unregisterGuest(browserTabId: string): void {
|
||||
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
|
||||
// through here, so its id arriving is misaddressed — and the cancel below would evict that
|
||||
// preview's live grab on the strength of it.
|
||||
if (isWorkspaceDocPageId(browserTabId)) {
|
||||
return
|
||||
}
|
||||
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
|
||||
this.cancelGrabOp(browserTabId, 'evicted')
|
||||
|
||||
@@ -1444,10 +1498,15 @@ export class BrowserManager {
|
||||
sessionProfileId?: string | null
|
||||
userAgentMode?: BrowserSessionUserAgentMode
|
||||
webContentsId: number
|
||||
}): void {
|
||||
}): boolean {
|
||||
// Why the same check on both registration doors: one id resolving in both halves is the exact
|
||||
// confusion the split registries exist to prevent.
|
||||
if (isWorkspaceDocPageId(browserPageId)) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
|
||||
this.offscreenGuestIds.add(webContentsId)
|
||||
@@ -1468,6 +1527,7 @@ export class BrowserManager {
|
||||
this.worktreeIdByTabId.set(browserPageId, worktreeId)
|
||||
}
|
||||
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
|
||||
return true
|
||||
}
|
||||
|
||||
unregisterAll(): void {
|
||||
@@ -1843,12 +1903,13 @@ export class BrowserManager {
|
||||
|
||||
async setAnnotationViewportBridge(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<boolean> {
|
||||
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
|
||||
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
@@ -1859,18 +1920,22 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
|
||||
// documents, which live in different halves of the page registry.
|
||||
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
|
||||
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
|
||||
// injecting into the guest the request named would bridge a page nobody is looking at.
|
||||
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
|
||||
// taking an id it cannot act on would invite the next reader to act on it.
|
||||
private async doSetAnnotationViewportBridgeImpl(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<boolean> {
|
||||
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (!webContentsId) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
|
||||
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
|
||||
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
|
||||
const guest = resolveGuest()
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
|
||||
this.unregisterGuest(browserTabId)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1977,10 +2042,21 @@ export class BrowserManager {
|
||||
// --- Browser Context Grab — main-owned operations ---
|
||||
|
||||
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
|
||||
/**
|
||||
* The guest a request from `senderWebContentsId` may act on, across both halves of the page
|
||||
* registry. This is the only door taught about workspace-document guests: they are kept out of
|
||||
* the browsing maps entirely, so page management, agent commands, download routing and
|
||||
* certificate attribution all miss them without a guard of their own — and a reader who opens a
|
||||
* tool on the document in front of them still gets an answer.
|
||||
*/
|
||||
getAuthorizedGuest(
|
||||
browserTabId: string,
|
||||
senderWebContentsId: number
|
||||
): Electron.WebContents | null {
|
||||
const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
|
||||
if (docGuest) {
|
||||
return docGuest
|
||||
}
|
||||
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
|
||||
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserSessionProfile } from '../../shared/browser-workspace-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleGuestWillDownload: vi.fn(),
|
||||
noticeDocPreviewDownloadBlocked: vi.fn()
|
||||
}))
|
||||
|
||||
type WillDownloadListener = (
|
||||
event: { preventDefault: () => void },
|
||||
item: { id: string },
|
||||
webContents: { id: number }
|
||||
) => void
|
||||
|
||||
type FakeSession = {
|
||||
listeners: WillDownloadListener[]
|
||||
on: ReturnType<typeof vi.fn>
|
||||
removeListener: ReturnType<typeof vi.fn>
|
||||
getUserAgent: () => string
|
||||
setUserAgent: ReturnType<typeof vi.fn>
|
||||
setPermissionRequestHandler: ReturnType<typeof vi.fn>
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>
|
||||
setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const sessionsByPartition = new Map<string, FakeSession>()
|
||||
|
||||
function fakeSession(): FakeSession {
|
||||
const listeners: WillDownloadListener[] = []
|
||||
return {
|
||||
listeners,
|
||||
on: vi.fn((event: string, listener: WillDownloadListener) => {
|
||||
if (event === 'will-download') {
|
||||
listeners.push(listener)
|
||||
}
|
||||
}),
|
||||
removeListener: vi.fn((event: string, listener: WillDownloadListener) => {
|
||||
if (event !== 'will-download') {
|
||||
return
|
||||
}
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}),
|
||||
getUserAgent: () => 'Mozilla/5.0 Orca',
|
||||
setUserAgent: vi.fn(),
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
session: {
|
||||
fromPartition: (partition: string) => {
|
||||
const existing = sessionsByPartition.get(partition)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = fakeSession()
|
||||
sessionsByPartition.set(partition, created)
|
||||
return created
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./browser-manager', () => ({
|
||||
browserManager: {
|
||||
handleGuestWillDownload: mocks.handleGuestWillDownload,
|
||||
installCertificateRequestGuard: vi.fn(),
|
||||
removeCertificateRequestGuard: vi.fn(),
|
||||
notifyPermissionDenied: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('./doc-preview-download-block-notice', () => ({
|
||||
noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked
|
||||
}))
|
||||
vi.mock('./browser-media-access', () => ({
|
||||
hasSystemMediaAccess: () => false,
|
||||
requestSystemMediaAccess: async () => false
|
||||
}))
|
||||
vi.mock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: (userAgent: string) => userAgent,
|
||||
setupClientHintsOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-session-user-agent-mode', () => ({
|
||||
setBrowserSessionUserAgentMode: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-webauthn-access', () => ({
|
||||
allowsBrowserWebAuthnPermission: () => false,
|
||||
clearBrowserWebAuthnAccessHandlers: vi.fn(),
|
||||
installBrowserWebAuthnAccessHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
type PartitionPolicyInstaller = (
|
||||
profile: BrowserSessionProfile,
|
||||
options?: { downloads?: 'route' | 'deny' }
|
||||
) => void
|
||||
|
||||
// Why imported per test rather than at the top: the installer remembers which partitions it has
|
||||
// already configured in module state, so a shared import would make the second test's install a
|
||||
// no-op and leave it reading the first test's listener.
|
||||
async function loadInstaller(): Promise<PartitionPolicyInstaller> {
|
||||
const module = await import('./browser-session-partition-policies')
|
||||
return module.installBrowserSessionPartitionPolicies
|
||||
}
|
||||
|
||||
function profileFor(partition: string): BrowserSessionProfile {
|
||||
return {
|
||||
id: partition,
|
||||
scope: 'isolated',
|
||||
partition,
|
||||
label: partition,
|
||||
source: null,
|
||||
userAgentMode: 'clean'
|
||||
}
|
||||
}
|
||||
|
||||
/** Fires the partition's real `will-download` listener and reports what it decided. */
|
||||
function fireWillDownload(partition: string): { cancelled: boolean } {
|
||||
const sess = sessionsByPartition.get(partition)
|
||||
if (!sess || sess.listeners.length !== 1) {
|
||||
throw new Error(`expected exactly one will-download listener on ${partition}`)
|
||||
}
|
||||
let cancelled = false
|
||||
sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
|
||||
return { cancelled }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sessionsByPartition.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('partition download policy', () => {
|
||||
// The presence half: without it, a deny assertion passes for a partition that installed no
|
||||
// listener at all, and would keep passing if the whole download path were removed.
|
||||
it('routes a download on a partition that did not ask for the deny', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
|
||||
expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guestWebContentsId: 42 })
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
|
||||
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
|
||||
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why in the same run as the routing test above: a refusal the reader cannot see is a pressed
|
||||
// button that does nothing, and a notice on the routing partition would announce a download that
|
||||
// is about to arrive normally.
|
||||
it('tells the reader about the refusal, and only on the partition that refused', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
fireWillDownload('persist:browsing-1')
|
||||
expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
|
||||
|
||||
fireWillDownload('orca-doc-preview')
|
||||
expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 42 })
|
||||
)
|
||||
})
|
||||
|
||||
// Why both partitions in one run: the listener is module state shared across sessions, so a deny
|
||||
// installed for one partition must not follow the next partition that installs after it.
|
||||
it('keeps each partition on its own decision', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
|
||||
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
|
||||
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
clearBrowserWebAuthnAccessHandlers,
|
||||
installBrowserWebAuthnAccessHandlers
|
||||
} from './browser-webauthn-access'
|
||||
import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice'
|
||||
|
||||
// Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart.
|
||||
const configuredPartitions = new Set<string>()
|
||||
@@ -22,6 +23,23 @@ const handleWillDownload = (
|
||||
browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item })
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a second listener instead of a branch inside the shared one: `will-download` is a session
|
||||
* event that names no partition, so the only place the decision can be keyed by partition is which
|
||||
* listener that partition's session got. A workspace-document guest has no page of its own to
|
||||
* attribute a download to, so routing one lands it in this desktop's Downloads folder under a
|
||||
* remote-authored name that nothing in the UI accounts for.
|
||||
*/
|
||||
const handleDeniedWillDownload = (
|
||||
event: Electron.Event,
|
||||
_item: Electron.DownloadItem,
|
||||
webContents: Electron.WebContents
|
||||
): void => {
|
||||
event.preventDefault()
|
||||
// The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing.
|
||||
noticeDocPreviewDownloadBlocked(webContents)
|
||||
}
|
||||
|
||||
function resolvePermissionNoticeUrl(
|
||||
webContents: Electron.WebContents,
|
||||
details: Electron.PermissionRequest | undefined
|
||||
@@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl(
|
||||
}
|
||||
}
|
||||
|
||||
export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
|
||||
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
|
||||
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
|
||||
|
||||
export function installBrowserSessionPartitionPolicies(
|
||||
profile: BrowserSessionProfile,
|
||||
options?: { downloads?: BrowserPartitionDownloadPolicy }
|
||||
): void {
|
||||
const { partition } = profile
|
||||
const sess = session.fromPartition(partition)
|
||||
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
|
||||
@@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr
|
||||
callback({ video: undefined, audio: undefined })
|
||||
})
|
||||
sess.removeListener('will-download', handleWillDownload)
|
||||
sess.on('will-download', handleWillDownload)
|
||||
sess.removeListener('will-download', handleDeniedWillDownload)
|
||||
sess.on(
|
||||
'will-download',
|
||||
options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
|
||||
)
|
||||
configuredPartitions.add(partition)
|
||||
}
|
||||
|
||||
@@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se
|
||||
configuredPartitions.delete(partition)
|
||||
browserManager.removeCertificateRequestGuard(sess)
|
||||
sess.removeListener('will-download', handleWillDownload)
|
||||
sess.removeListener('will-download', handleDeniedWillDownload)
|
||||
clearBrowserWebAuthnAccessHandlers(sess)
|
||||
sess.setPermissionRequestHandler(null)
|
||||
sess.setPermissionCheckHandler(null)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishDocPreviewFailure: vi.fn(),
|
||||
boundGrantIdByGuest: new Map<object, string>()
|
||||
}))
|
||||
|
||||
vi.mock('./doc-preview-failure-notice', () => ({
|
||||
publishDocPreviewFailure: mocks.publishDocPreviewFailure
|
||||
}))
|
||||
vi.mock('./doc-preview-guest-policy', () => ({
|
||||
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
|
||||
}))
|
||||
|
||||
const GRANT_ID = 'a'.repeat(32)
|
||||
const OTHER_GRANT_ID = 'b'.repeat(32)
|
||||
|
||||
/** Only the identity matters: the module asks the guest registry what grant this contents holds. */
|
||||
function guestBoundTo(grantId: string | null): Electron.WebContents {
|
||||
const guest = {} as Electron.WebContents
|
||||
if (grantId !== null) {
|
||||
mocks.boundGrantIdByGuest.set(guest, grantId)
|
||||
}
|
||||
return guest
|
||||
}
|
||||
|
||||
async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> {
|
||||
const module = await import('./doc-preview-download-block-notice')
|
||||
return module.noticeDocPreviewDownloadBlocked
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.boundGrantIdByGuest.clear()
|
||||
// Why per test: the module remembers which grants it has already told the reader about.
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('noticeDocPreviewDownloadBlocked', () => {
|
||||
it('tells the shell which preview had a download refused', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(GRANT_ID))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({
|
||||
grantId: GRANT_ID,
|
||||
reason: 'download-blocked'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a document can ask in a loop. The reader learns nothing from the second notice, and every
|
||||
// attempt would otherwise cross the IPC boundary and re-render the strip.
|
||||
it('says it once however often the document asks', async () => {
|
||||
const notice = await loadNotifier()
|
||||
const guest = guestBoundTo(GRANT_ID)
|
||||
|
||||
notice(guest)
|
||||
notice(guest)
|
||||
vi.advanceTimersByTime(1_500)
|
||||
notice(guest)
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('says it again for an attempt long after the last one', async () => {
|
||||
const notice = await loadNotifier()
|
||||
const guest = guestBoundTo(GRANT_ID)
|
||||
|
||||
notice(guest)
|
||||
vi.advanceTimersByTime(2_500)
|
||||
notice(guest)
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Why not one throttle for the whole app: two previews are two readers, and silencing the second
|
||||
// because the first just refused something leaves that press unexplained.
|
||||
it('throttles each preview on its own', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(GRANT_ID))
|
||||
notice(guestBoundTo(OTHER_GRANT_ID))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({
|
||||
grantId: OTHER_GRANT_ID,
|
||||
reason: 'download-blocked'
|
||||
})
|
||||
})
|
||||
|
||||
// The absence half of the first test: no shell is showing this contents, so there is no preview
|
||||
// to put a notice on. Without the presence tests above, this would pass on a module that never
|
||||
// published anything at all.
|
||||
it('says nothing for a contents no preview is bound to', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(null))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
|
||||
import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
|
||||
|
||||
/**
|
||||
* Why the reader is told at all, when the point of the fence is that nothing happens: a press that
|
||||
* produces no file and no explanation reads as Orca being broken. The notice is chrome the document
|
||||
* cannot see or read back, so the refusal stays as silent to the page as it was.
|
||||
*
|
||||
* Why a floor between notices: a document can ask in a loop, and every attempt would otherwise
|
||||
* cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth.
|
||||
*/
|
||||
const NOTICE_MIN_INTERVAL_MS = 2_000
|
||||
const noticedAtByGrantId = new Map<string, number>()
|
||||
|
||||
export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void {
|
||||
const grantId = readDocPreviewGuestBoundGrantId(guest)
|
||||
// Nothing to route a notice to: no shell is showing this contents as a preview.
|
||||
if (grantId === null) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
// Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a
|
||||
// grant that stops downloading stops being remembered.
|
||||
for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) {
|
||||
if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) {
|
||||
noticedAtByGrantId.delete(noticedGrantId)
|
||||
}
|
||||
}
|
||||
if (noticedAtByGrantId.has(grantId)) {
|
||||
return
|
||||
}
|
||||
noticedAtByGrantId.set(grantId, now)
|
||||
publishDocPreviewFailure({ grantId, reason: 'download-blocked' })
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme'
|
||||
import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice'
|
||||
|
||||
afterEach(() => {
|
||||
setDocPreviewFailureSink(null)
|
||||
})
|
||||
|
||||
describe('publishDocPreviewFailure', () => {
|
||||
it('sends the grant, path, and reason on the failure channel', () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'a'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: 'a'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: reads can outlive the window that asked for them; a missing sink must not throw inside
|
||||
// the protocol handler.
|
||||
it('is a no-op with no sink registered', () => {
|
||||
expect(() =>
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'b'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('sends nothing into a destroyed window', () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send, isDestroyed: () => true })
|
||||
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'c'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a WebContents can be torn down between the liveness check and the send.
|
||||
it('swallows a throwing sink and stops using it', () => {
|
||||
const send = vi.fn(() => {
|
||||
throw new Error('Object has been destroyed')
|
||||
})
|
||||
setDocPreviewFailureSink({ send })
|
||||
const failure = {
|
||||
grantId: 'd'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable' as const
|
||||
}
|
||||
|
||||
expect(() => publishDocPreviewFailure(failure)).not.toThrow()
|
||||
publishDocPreviewFailure(failure)
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
|
||||
type DocPreviewFailure
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
|
||||
type DocPreviewFailureSink = {
|
||||
send: (channel: string, payload: DocPreviewFailure) => void
|
||||
isDestroyed?: () => boolean
|
||||
}
|
||||
|
||||
let failureSink: DocPreviewFailureSink | null = null
|
||||
|
||||
export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void {
|
||||
failureSink = sink
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview shell cannot read the guest's HTTP status, and a 4xx body renders as
|
||||
* if it were the document. Pushing the reason lets the shell replace that with a
|
||||
* localized notice for the failure the user actually hit.
|
||||
*/
|
||||
export function publishDocPreviewFailure(failure: DocPreviewFailure): void {
|
||||
const sink = failureSink
|
||||
// Why: a read can outlive the window that asked for it, and sending into torn-down
|
||||
// WebContents throws — an unreadable asset must not take the protocol handler with it.
|
||||
if (!sink || sink.isDestroyed?.()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure)
|
||||
} catch {
|
||||
failureSink = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
requireSshFilesystemProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
|
||||
}))
|
||||
|
||||
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
|
||||
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'env-1',
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/srv/repo'
|
||||
},
|
||||
root,
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
// Why: an unsymlinked host canonicalizes to the path it was given.
|
||||
mocks.realpath.mockImplementation((path: string) => Promise.resolve(path))
|
||||
mocks.requireSshFilesystemProvider.mockReturnValue({
|
||||
readFile: mocks.readFile,
|
||||
realpath: mocks.realpath
|
||||
})
|
||||
})
|
||||
|
||||
describe('docPreviewContentType', () => {
|
||||
it('maps document and asset extensions, defaulting to octet-stream', () => {
|
||||
expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8')
|
||||
expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8')
|
||||
expect(docPreviewContentType('assets/logo.png')).toBe('image/png')
|
||||
expect(docPreviewContentType('data.bin')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDocPreviewFile — ssh owner', () => {
|
||||
it('reads text through the SSH filesystem provider', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
|
||||
|
||||
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html')
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes a base64 binary asset', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
||||
mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
|
||||
|
||||
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
|
||||
})
|
||||
|
||||
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
|
||||
// always whole and needs no truncation flag.
|
||||
it('serves a whole SSH read that carries no truncation flag', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
// Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by
|
||||
// design — the failure must name the file type, not a stale server.
|
||||
it('reports a file type the host will not send as unsupported-asset', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '', isBinary: true })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
|
||||
})
|
||||
|
||||
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
|
||||
// reporting it as a refused format would be a failure the workspace never reported.
|
||||
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
|
||||
|
||||
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
|
||||
})
|
||||
|
||||
// Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a
|
||||
// symlink inside the grant would otherwise read anything the account can reach.
|
||||
it('404s a path that canonicalizes outside the grant root', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path)
|
||||
)
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'escape.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the canonical path once containment holds', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path)
|
||||
)
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>real</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true })
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html')
|
||||
})
|
||||
|
||||
// Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to.
|
||||
it('keeps serving a grant whose own root is a symlink', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs'))
|
||||
)
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html')
|
||||
})
|
||||
|
||||
it('404s when the host cannot canonicalize the path at all', async () => {
|
||||
mocks.realpath.mockRejectedValue(new Error('no such file'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a path outside the grant root without touching the provider', async () => {
|
||||
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
|
||||
mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 413
|
||||
})
|
||||
})
|
||||
|
||||
it('404s when the provider read fails', async () => {
|
||||
mocks.readFile.mockRejectedValue(new Error('no such file'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDocPreviewFile — paired runtime owner', () => {
|
||||
it('reads text over worktree-relative files.read', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>remote</h1>', truncated: false, byteLength: 15 }
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
|
||||
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'files.read',
|
||||
{ worktree: 'id:wt-1', relativePath: 'docs/index.html' },
|
||||
15_000
|
||||
)
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>remote</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the base64 preview RPC for a binary asset', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
content: png.toString('base64'),
|
||||
isBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
}
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')
|
||||
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'files.readPreview',
|
||||
{ worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' },
|
||||
15_000
|
||||
)
|
||||
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
|
||||
})
|
||||
|
||||
it('degrades on an old server whose empty binary preview carries no metadata', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } })
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset'
|
||||
})
|
||||
})
|
||||
|
||||
it('serves an empty paired asset the host typed rather than reporting a refusal', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.alloc(0),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: files.read clamps text at the host cap and only says so in `truncated`; serving the
|
||||
// clamped bytes renders a document that silently stops halfway.
|
||||
it('refuses a truncated text read instead of serving the clamped bytes', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>half of', truncated: true, byteLength: 40_000_000 }
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 413 })
|
||||
expect(outcome).not.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('serves a read the host reports as complete', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>all</h1>', truncated: false, byteLength: 12 }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
// Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error.
|
||||
it('reports the host rejecting an over-cap binary as too large', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'file_too_large' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({
|
||||
ok: false,
|
||||
status: 413
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat an unrelated RPC failure as a binary fallback', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'permission_denied' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('404s a document outside the worktree, which files.read cannot address', async () => {
|
||||
const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { extname } from 'node:path'
|
||||
import type {
|
||||
RuntimeFilePreviewResult,
|
||||
RuntimeFileReadResult
|
||||
} from '../../shared/runtime-file-contracts'
|
||||
import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { getCanonicalUserDataPath } from '../persistence'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
resolveCanonicalDocPreviewPath,
|
||||
resolveDocPreviewTargetPath,
|
||||
toRuntimeWorktreeRelativePath,
|
||||
type DocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
|
||||
|
||||
/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so
|
||||
* a font is refused there by design, not by version. Name the file type, not the host's age. */
|
||||
const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.'
|
||||
|
||||
/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would
|
||||
* render a silently half-finished document. */
|
||||
const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.'
|
||||
|
||||
/** The paired host rejects an over-cap asset outright instead of clamping it. */
|
||||
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
|
||||
|
||||
/** Both owners refuse an over-cap file; only their error shapes differ. */
|
||||
function isTooLargeReadError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof FileReadCapExceededError ||
|
||||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
|
||||
)
|
||||
}
|
||||
|
||||
export type DocPreviewReadOutcome =
|
||||
| { ok: true; bytes: Buffer; contentType: string }
|
||||
| { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string }
|
||||
|
||||
const DOC_PREVIEW_CONTENT_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
'.csv': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.pdf': 'application/pdf',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf'
|
||||
}
|
||||
|
||||
export function docPreviewContentType(relativePath: string): string {
|
||||
return (
|
||||
DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream'
|
||||
)
|
||||
}
|
||||
|
||||
type PreviewFileBytes = {
|
||||
content: string
|
||||
isBinary: boolean
|
||||
truncated?: boolean
|
||||
/** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome {
|
||||
if (source.truncated) {
|
||||
return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
|
||||
}
|
||||
if (!source.isBinary) {
|
||||
return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType }
|
||||
}
|
||||
if (source.content) {
|
||||
return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType }
|
||||
}
|
||||
// Why: an empty binary body is two different answers. A host that still named the file's type
|
||||
// read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined
|
||||
// the format outright and has nothing to send.
|
||||
return source.mimeType
|
||||
? { ok: true, bytes: Buffer.alloc(0), contentType }
|
||||
: {
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset',
|
||||
message: UNSERVABLE_ASSET_PREVIEW_MESSAGE
|
||||
}
|
||||
}
|
||||
|
||||
async function readRuntimeDocPreviewFile(
|
||||
environmentId: string,
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<PreviewFileBytes> {
|
||||
const userDataPath = getCanonicalUserDataPath()
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'files.read',
|
||||
{ worktree: worktreeSelector, relativePath },
|
||||
DOC_PREVIEW_READ_TIMEOUT_MS
|
||||
)
|
||||
if (response.ok) {
|
||||
const result = response.result as RuntimeFileReadResult
|
||||
return { content: result.content, isBinary: false, truncated: result.truncated === true }
|
||||
}
|
||||
// Why: files.read rejects binaries with a typed error; the base64 preview RPC serves
|
||||
// images and fonts the same way it does for markdown previews. Match the exact
|
||||
// message so an unrelated failure can't spoof the fallback.
|
||||
if (response.error.message !== 'binary_file') {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const previewResponse = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'files.readPreview',
|
||||
{ worktree: worktreeSelector, relativePath },
|
||||
DOC_PREVIEW_READ_TIMEOUT_MS
|
||||
)
|
||||
if (!previewResponse.ok) {
|
||||
throw new Error(previewResponse.error.message)
|
||||
}
|
||||
const preview = previewResponse.result as RuntimeFilePreviewResult
|
||||
// Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent.
|
||||
return {
|
||||
content: preview.content,
|
||||
isBinary: preview.isBinary,
|
||||
...(preview.mimeType ? { mimeType: preview.mimeType } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome {
|
||||
return { ok: false, status: 404, reason: 'unreadable', message }
|
||||
}
|
||||
|
||||
/** Reads one in-grant path over the same channel the editor uses for that owner. */
|
||||
export async function readDocPreviewFile(
|
||||
grant: DocPreviewGrant,
|
||||
relativePath: string
|
||||
): Promise<DocPreviewReadOutcome> {
|
||||
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
|
||||
if (!absolutePath) {
|
||||
return notFoundOutcome()
|
||||
}
|
||||
const contentType = docPreviewContentType(relativePath)
|
||||
try {
|
||||
if (grant.owner.kind === 'ssh') {
|
||||
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
|
||||
// Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink
|
||||
// before the read — the lexical check above only proves the requested path looked contained.
|
||||
const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) =>
|
||||
provider.realpath(path)
|
||||
)
|
||||
if (!canonicalPath) {
|
||||
return notFoundOutcome()
|
||||
}
|
||||
// Why: the SSH reader rejects an over-cap file outright, so its result is never partial.
|
||||
return toOutcome(await provider.readFile(canonicalPath), contentType)
|
||||
}
|
||||
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
|
||||
grant.owner.worktreeRoot,
|
||||
absolutePath
|
||||
)
|
||||
if (!worktreeRelativePath) {
|
||||
// Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel.
|
||||
return notFoundOutcome()
|
||||
}
|
||||
// Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which
|
||||
// canonicalizes and re-checks the worktree root server-side before reading.
|
||||
return toOutcome(
|
||||
await readRuntimeDocPreviewFile(
|
||||
grant.owner.environmentId,
|
||||
grant.owner.worktreeSelector,
|
||||
worktreeRelativePath
|
||||
),
|
||||
contentType
|
||||
)
|
||||
} catch (error) {
|
||||
return isTooLargeReadError(error)
|
||||
? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
|
||||
: notFoundOutcome(error instanceof Error ? error.message : undefined)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
mintDocPreviewGrant,
|
||||
resolveCanonicalDocPreviewPath,
|
||||
resolveDocPreviewTargetPath,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant,
|
||||
toRuntimeWorktreeRelativePath,
|
||||
type DocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
|
||||
|
||||
function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant {
|
||||
return mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root,
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
describe('doc preview grants', () => {
|
||||
it('mints unguessable ids and looks them up', () => {
|
||||
const first = mintPosixGrant()
|
||||
const second = mintPosixGrant()
|
||||
|
||||
expect(first.id).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(first.id).not.toBe(second.id)
|
||||
expect(getDocPreviewGrant(first.id)).toBe(first)
|
||||
})
|
||||
|
||||
it('returns nothing for an unknown or revoked grant', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(getDocPreviewGrant('0'.repeat(32))).toBeNull()
|
||||
expect(revokeDocPreviewGrant(grant.id)).toBe(true)
|
||||
expect(getDocPreviewGrant(grant.id)).toBeNull()
|
||||
expect(revokeDocPreviewGrant(grant.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDocPreviewTargetPath', () => {
|
||||
it('resolves paths inside the grant root', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
|
||||
'/srv/repo/docs/assets/logo.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses parent traversal, absolute escapes and empty paths', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, '')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses backslash and NUL segments that could re-split on the owning host', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is a traversal test and not a containment test: every request path that names a
|
||||
// sibling directory has to climb out of the root first, so the `..` segment guard answers it
|
||||
// before the prefix check runs. Sibling containment is exercised where it is reachable —
|
||||
// against a canonicalized path, below.
|
||||
it('refuses a sibling directory by refusing the traversal that reaches it', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a Windows drive root addressable instead of turning it drive-relative', () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: 'C:\\',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html')
|
||||
})
|
||||
|
||||
it('follows the owning host path flavor rather than this process platform', () => {
|
||||
const windowsGrant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: 'C:\\srv\\repo\\docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
|
||||
'C:\\srv\\repo\\docs\\assets\\logo.png'
|
||||
)
|
||||
expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes a trailing separator on the root', () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: '/srv/repo/docs/',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
|
||||
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCanonicalDocPreviewPath', () => {
|
||||
// Why here and not above: a canonical path is the one input that can name a sibling directory
|
||||
// without traversing — the host resolved a symlink to it — so this is where the prefix check
|
||||
// is the only thing standing between the grant and `/srv/repo/docs-private`.
|
||||
it('refuses a canonical path in a sibling directory that shares the root prefix', async () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
|
||||
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('answers the canonical path when it stays inside the canonical root', async () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
|
||||
).resolves.toBe('/srv/repo/docs/report.html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRuntimeWorktreeRelativePath', () => {
|
||||
it('produces a worktree-relative path for files inside the worktree', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe(
|
||||
'docs/index.html'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects paths outside the worktree, which files.read cannot address', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull()
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses Windows semantics for a Windows worktree root', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe(
|
||||
'docs/index.html'
|
||||
)
|
||||
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
|
||||
/**
|
||||
* A preview grant is the only authority that turns an `orca-preview://` request
|
||||
* into bytes: it names the host that owns the file and the single directory
|
||||
* subtree requests may resolve inside. No grant, no bytes.
|
||||
*/
|
||||
export type DocPreviewOwner =
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| {
|
||||
kind: 'runtime'
|
||||
environmentId: string
|
||||
/** Selector the runtime resolves `files.read` against. */
|
||||
worktreeSelector: string
|
||||
/** Worktree root on the runtime host; `files.read` only accepts paths inside it. */
|
||||
worktreeRoot: string
|
||||
}
|
||||
|
||||
export type DocPreviewGrant = {
|
||||
id: string
|
||||
owner: DocPreviewOwner
|
||||
/** Containing directory of the opened document, on the owning host. */
|
||||
root: string
|
||||
/** Path of the opened document relative to `root`. */
|
||||
entryRelativePath: string
|
||||
/**
|
||||
* Browser page the reader opened this document in. Main registers the guest under it once the
|
||||
* guest commits to the grant, so the surface a tool names is the page the reader is looking at
|
||||
* and not the grant, which a re-mint replaces underneath the same page.
|
||||
*/
|
||||
browserPageId: string
|
||||
}
|
||||
|
||||
const grantsById = new Map<string, DocPreviewGrant>()
|
||||
|
||||
function pathFlavorFor(root: string): typeof posix | typeof win32 {
|
||||
return isWindowsAbsolutePathLike(root) ? win32 : posix
|
||||
}
|
||||
|
||||
function normalizeRootPath(root: string): string {
|
||||
const flavor = pathFlavorFor(root)
|
||||
const normalized =
|
||||
flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root)
|
||||
// Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the
|
||||
// drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant.
|
||||
if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
// Why: a trailing separator would make the containment prefix check accept a sibling directory.
|
||||
return normalized.length > 1 && normalized.endsWith(flavor.sep)
|
||||
? normalized.slice(0, -1)
|
||||
: normalized
|
||||
}
|
||||
|
||||
export function mintDocPreviewGrant(params: {
|
||||
owner: DocPreviewOwner
|
||||
root: string
|
||||
entryRelativePath: string
|
||||
browserPageId: string
|
||||
}): DocPreviewGrant {
|
||||
const grant: DocPreviewGrant = {
|
||||
id: randomBytes(16).toString('hex'),
|
||||
owner: params.owner,
|
||||
root: normalizeRootPath(params.root),
|
||||
entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'),
|
||||
browserPageId: params.browserPageId
|
||||
}
|
||||
grantsById.set(grant.id, grant)
|
||||
return grant
|
||||
}
|
||||
|
||||
export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null {
|
||||
return grantsById.get(grantId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why anything listens at all: a grant is the only thing that names a preview's lifetime. State
|
||||
* elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no
|
||||
* other signal telling it the surface is gone, and would otherwise accrete one entry per grant
|
||||
* for the life of the process.
|
||||
*/
|
||||
const revocationListeners = new Set<(grant: DocPreviewGrant) => void>()
|
||||
|
||||
/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */
|
||||
export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void {
|
||||
revocationListeners.add(listener)
|
||||
return () => revocationListeners.delete(listener)
|
||||
}
|
||||
|
||||
function notifyRevoked(grant: DocPreviewGrant): void {
|
||||
for (const listener of revocationListeners) {
|
||||
listener(grant)
|
||||
}
|
||||
}
|
||||
|
||||
export function revokeDocPreviewGrant(grantId: string): boolean {
|
||||
canonicalRootByGrantId.delete(grantId)
|
||||
const grant = grantsById.get(grantId)
|
||||
if (!grant) {
|
||||
return false
|
||||
}
|
||||
grantsById.delete(grantId)
|
||||
notifyRevoked(grant)
|
||||
return true
|
||||
}
|
||||
|
||||
export function revokeAllDocPreviewGrants(): void {
|
||||
canonicalRootByGrantId.clear()
|
||||
const revoked = [...grantsById.values()]
|
||||
grantsById.clear()
|
||||
for (const grant of revoked) {
|
||||
notifyRevoked(grant)
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsafeSegment(segments: string[]): boolean {
|
||||
return segments.some(
|
||||
(segment) =>
|
||||
segment.length === 0 ||
|
||||
segment === '.' ||
|
||||
segment === '..' ||
|
||||
segment.includes('\0') ||
|
||||
segment.includes('\\')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a request path to an absolute path on the owning host, or null when
|
||||
* it would escape the grant's root. Path flavor follows the root (the owning
|
||||
* host may be Windows while this client is not), never `process.platform`.
|
||||
*/
|
||||
export function resolveDocPreviewTargetPath(
|
||||
grant: DocPreviewGrant,
|
||||
relativePath: string
|
||||
): string | null {
|
||||
const segments = relativePath.split('/').filter((segment, index, all) => {
|
||||
// Why: keep empty segments visible to the safety check except a single trailing one from `dir/`.
|
||||
return !(segment === '' && index === all.length - 1)
|
||||
})
|
||||
if (segments.length === 0 || hasUnsafeSegment(segments)) {
|
||||
return null
|
||||
}
|
||||
const flavor = pathFlavorFor(grant.root)
|
||||
const resolved = flavor.normalize(flavor.join(grant.root, ...segments))
|
||||
return isInsideRoot(grant.root, resolved, flavor) ? resolved : null
|
||||
}
|
||||
|
||||
function isInsideRoot(
|
||||
root: string,
|
||||
candidate: string,
|
||||
flavor: typeof posix | typeof win32
|
||||
): boolean {
|
||||
const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}`
|
||||
return candidate.startsWith(rootPrefix)
|
||||
}
|
||||
|
||||
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
|
||||
const canonicalRootByGrantId = new Map<string, Promise<string>>()
|
||||
|
||||
/**
|
||||
* Second containment pass for hosts where the lexical one is not enough: a symlink
|
||||
* inside the root can point anywhere, and the SSH read RPC applies no root of its
|
||||
* own. Both sides are canonicalized on the owning host before the prefix re-check;
|
||||
* a host that cannot canonicalize a path answers nothing.
|
||||
*/
|
||||
export async function resolveCanonicalDocPreviewPath(
|
||||
grant: DocPreviewGrant,
|
||||
absolutePath: string,
|
||||
realpath: (path: string) => Promise<string>
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
let canonicalRoot = canonicalRootByGrantId.get(grant.id)
|
||||
if (!canonicalRoot) {
|
||||
canonicalRoot = realpath(grant.root).then(normalizeRootPath)
|
||||
canonicalRootByGrantId.set(grant.id, canonicalRoot)
|
||||
}
|
||||
const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)])
|
||||
const flavor = pathFlavorFor(root)
|
||||
return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null
|
||||
} catch {
|
||||
// Why: a root that no longer canonicalizes must not fall back to the lexical answer.
|
||||
canonicalRootByGrantId.delete(grant.id)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path a runtime `files.read` can address, i.e. relative to the worktree root.
|
||||
* Returns null when the grant root sits outside the worktree — the runtime file
|
||||
* RPCs are worktree-scoped, so those documents are unreadable client-side.
|
||||
*/
|
||||
export function toRuntimeWorktreeRelativePath(
|
||||
worktreeRoot: string,
|
||||
absolutePath: string
|
||||
): string | null {
|
||||
const flavor = pathFlavorFor(worktreeRoot)
|
||||
const normalizedRoot = normalizeRootPath(worktreeRoot)
|
||||
const relative = flavor.relative(normalizedRoot, absolutePath)
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) {
|
||||
return null
|
||||
}
|
||||
if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) {
|
||||
return null
|
||||
}
|
||||
return relative.replace(/\\/g, '/')
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy,
|
||||
readDocPreviewGuestBoundGrantId,
|
||||
reportDocPreviewLinkClick
|
||||
} from './doc-preview-guest-policy'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
type GuestHandlers = Record<string, (...args: never[]) => void>
|
||||
|
||||
const HOST_RENDERER_ID = 42
|
||||
|
||||
function installOnFakeGuest(
|
||||
hostId: number = HOST_RENDERER_ID,
|
||||
/** What the guest is already showing when the embedder hands it over, as a real one usually is. */
|
||||
initialUrl = ''
|
||||
): {
|
||||
contents: object
|
||||
handlers: GuestHandlers
|
||||
hostId: number
|
||||
isFocused: ReturnType<typeof vi.fn>
|
||||
send: ReturnType<typeof vi.fn>
|
||||
setWebRTCIPHandlingPolicy: ReturnType<typeof vi.fn>
|
||||
windowOpenHandler: (details: { url: string }) => { action: string }
|
||||
/** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */
|
||||
markContentsDestroyed: () => void
|
||||
} {
|
||||
const handlers: GuestHandlers = {}
|
||||
let contentsDestroyed = false
|
||||
const send = vi.fn()
|
||||
const isFocused = vi.fn(() => true)
|
||||
const setWebRTCIPHandlingPolicy = vi.fn()
|
||||
let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({
|
||||
action: 'deny'
|
||||
})
|
||||
const register = (event: string, handler: (...args: never[]) => void): void => {
|
||||
handlers[event] = handler
|
||||
}
|
||||
const guest = {
|
||||
isFocused,
|
||||
isDestroyed: () => contentsDestroyed,
|
||||
getURL: () => initialUrl,
|
||||
on: vi.fn(register),
|
||||
once: vi.fn(register),
|
||||
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
|
||||
windowOpenHandler = handler
|
||||
}),
|
||||
setWebRTCIPHandlingPolicy
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: hostId, send })
|
||||
return {
|
||||
contents: guest,
|
||||
handlers,
|
||||
hostId,
|
||||
isFocused,
|
||||
send,
|
||||
setWebRTCIPHandlingPolicy,
|
||||
windowOpenHandler: (details) => windowOpenHandler(details),
|
||||
markContentsDestroyed: () => {
|
||||
contentsDestroyed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FakeGuest = ReturnType<typeof installOnFakeGuest>
|
||||
|
||||
function startMainFrameNavigation(guest: FakeGuest, url: string): void {
|
||||
guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never)
|
||||
}
|
||||
|
||||
/** A guest already showing a document, which is the only state a link can be pressed in. */
|
||||
function boundGuest(): { grant: ReturnType<typeof mintGrant>; guest: FakeGuest } {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
return { grant, guest }
|
||||
}
|
||||
|
||||
function reportClick(guest: FakeGuest, url: string): void {
|
||||
reportDocPreviewLinkClick(guest.contents as never, url)
|
||||
}
|
||||
|
||||
// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one
|
||||
// would have the second silently replace the first rather than stand beside it.
|
||||
let nextDocPageOrdinal = 0
|
||||
|
||||
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
nextDocPageOrdinal += 1
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: `doc-page-${nextDocPageOrdinal}`
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
describe('doc preview guest policy', () => {
|
||||
it('allows relative navigation within the bound grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'guide.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why an unlatched guest is refused rather than trusted: the renderer-set src is
|
||||
// browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is
|
||||
// one the guest started for itself.
|
||||
it('blocks a navigation the guest starts before a document has bound it', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks navigation into a different live grant once bound', () => {
|
||||
const { guest } = boundGuest()
|
||||
const otherGrant = mintGrant()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks a revoked grant even when it matches the bound id', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
revokeAllDocPreviewGrants()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'guide.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks an external navigation without offering it to the renderer', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks a file: navigation', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies the same rule to redirects', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies every popup and routes none of them', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: will-navigate never fires for a subframe, so an <iframe src="https://…"> would load
|
||||
// off-machine even though the top frame cannot.
|
||||
it('blocks a subframe navigating outside the grant, without opening a tab for it', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-frame-navigate']?.({
|
||||
preventDefault,
|
||||
url: 'https://tracker.test/pixel',
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a subframe load an in-grant asset', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-frame-navigate']?.({
|
||||
preventDefault,
|
||||
url: buildDocPreviewUrl(grant.id, 'chart.html'),
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why this group exists: the external-link route ends in a real browser tab with full network, so
|
||||
// a document that can read its grant and reach that route can exfiltrate it. Only a reader's own
|
||||
// press on a link may, and only the guest's preload can tell that a press was one.
|
||||
describe('the trusted-click route out of the preview', () => {
|
||||
// The headline. The earlier design read a recent-input timestamp, so a script that navigated
|
||||
// shortly after any genuine press was routed out as if the press had asked for it. Here the
|
||||
// navigation sinks route nothing at all, so when the input happened cannot matter.
|
||||
it('routes nothing for a scripted location change or window.open, whenever input happened', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
// Genuine input the guest would report, delivered immediately before the document acts.
|
||||
guest.handlers['input-event']?.({} as never, { type: 'mouseDown' } as never)
|
||||
guest.handlers['before-input-event']?.({} as never, { type: 'keyDown' } as never)
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
'https://attacker.test/?d=secret' as never
|
||||
)
|
||||
const popup = guest.windowOpenHandler({ url: 'https://attacker.test/?d=secret' })
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(popup).toEqual({ action: 'deny' })
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
// Why assert the absence of the listeners too: with them gone there is no timing a document
|
||||
// could hit, rather than a window it merely failed to hit in this test.
|
||||
expect(guest.handlers['input-event']).toBeUndefined()
|
||||
expect(guest.handlers['before-input-event']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes a reported click on an external link exactly once', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
|
||||
url: 'https://example.com/docs'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
|
||||
const { guest } = boundGuest()
|
||||
guest.isFocused.mockReturnValue(false)
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a click reported by a sender that is not a preview guest', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportDocPreviewLinkClick({ isFocused: () => true } as never, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a click reported by a guest that never bound a grant', () => {
|
||||
mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("drops a click once the guest's bound grant is revoked", () => {
|
||||
const { guest } = boundGuest()
|
||||
revokeAllDocPreviewGrants()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'file:///etc/passwd',
|
||||
'javascript:fetch("https://attacker.test")',
|
||||
'orca-preview://a/b',
|
||||
'/Users/alice/secrets.txt',
|
||||
''
|
||||
])('drops a reported click on %s, which is not the web', (url) => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportClick(guest, url)
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops routing for a guest that has been destroyed', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a peer connection is UDP straight off the network stack — the response CSP and the
|
||||
// session's request filter both miss it, so this is the only place it can be refused.
|
||||
it('denies the guest non-proxied WebRTC UDP at attach', () => {
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
expect(guest.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
|
||||
})
|
||||
|
||||
// Why: latching from a subframe would let an in-document iframe decide which grant the guest
|
||||
// belongs to, and every later main-frame check would be measured against that.
|
||||
it('binds the guest from the main frame only', () => {
|
||||
const grant = mintGrant()
|
||||
const otherGrant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
guest.handlers['did-start-navigation']?.({
|
||||
url: buildDocPreviewUrl(otherGrant.id, 'index.html'),
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('tool authorization', () => {
|
||||
it('answers the hosting renderer for a guest bound to a live grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
it('refuses a renderer that does not host the preview', () => {
|
||||
const { grant } = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID + 1)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is the ordinary case and not an edge one: the embedder hands the guest over after
|
||||
// it has already started loading its src, so the navigation that binds most previews to their
|
||||
// grant happens before any listener here exists. Missing it leaves the tools unable to name
|
||||
// the guest for as long as the reader stays on the page they opened.
|
||||
it('binds a guest that is already showing its document when the policy installs', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// The same miss, one event later: the load started before the listener and commits after it.
|
||||
it('binds a guest whose first navigation only reaches the commit', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
guest.handlers['did-navigate']?.(
|
||||
{} as never,
|
||||
buildDocPreviewUrl(grant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// Why: the src commit is what proves this guest is showing that grant. Before it, the id names
|
||||
// a document nothing has been asked to render.
|
||||
it('refuses a guest that has not committed a document yet', () => {
|
||||
const grant = mintGrant()
|
||||
installOnFakeGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why: revoking is how a closed tab withdraws its preview, and it happens while the guest is
|
||||
// still being torn down — a tool must stop answering at revoke, not at destroy.
|
||||
it('stops answering once the grant is revoked', () => {
|
||||
const { grant } = boundGuest()
|
||||
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the guest when it is destroyed', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is separate from the destroy event: the contents die before main runs that listener,
|
||||
// so in that window the registration still looks live and only this check refuses it.
|
||||
it('refuses a guest whose contents died before the destroy event arrived', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
guest.markContentsDestroyed()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing for a grant no preview ever rendered', () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is what makes three latch call sites safe: install, did-start-navigation and
|
||||
// did-navigate all feed the same latch, so without the no-rebind guard the second and third
|
||||
// would let a later navigation move a bound guest onto another grant — and a tool asking for
|
||||
// that grant would be handed a guest showing someone else's document.
|
||||
it('never rebinds a bound guest onto a second grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const other = mintGrant()
|
||||
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(other.id, 'index.html'))
|
||||
guest.handlers['did-navigate']?.(
|
||||
{} as never,
|
||||
buildDocPreviewUrl(other.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(other.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// Why the replacement is registered before the old guest's teardown runs: a re-mint attaches
|
||||
// the new guest first, and Chromium runs the outgoing guest's destroyed listener afterwards. A
|
||||
// teardown that deleted by page alone would unregister the guest the reader is now looking at.
|
||||
it('leaves a re-minted preview registered when the guest it replaced tears down', () => {
|
||||
const first = mintGrant()
|
||||
const outgoing = installOnFakeGuest(
|
||||
HOST_RENDERER_ID,
|
||||
buildDocPreviewUrl(first.id, 'index.html')
|
||||
)
|
||||
const remint = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: first.browserPageId
|
||||
})
|
||||
const incoming = installOnFakeGuest(
|
||||
HOST_RENDERER_ID,
|
||||
buildDocPreviewUrl(remint.id, 'index.html')
|
||||
)
|
||||
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
incoming.contents
|
||||
)
|
||||
|
||||
outgoing.handlers['destroyed']?.()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
incoming.contents
|
||||
)
|
||||
})
|
||||
|
||||
// Why the grant has to be live at the latch and not only at each later request: registering the
|
||||
// page a dead grant names would put a guest that can read nothing into the registry the tool
|
||||
// door answers from, under a page a live grant may later want.
|
||||
it('registers nothing for a guest showing a grant that is already revoked', () => {
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
// The presence half: the same install does register when the grant is still live.
|
||||
const live = mintGrant()
|
||||
installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(live.id, 'index.html'))
|
||||
expect(getWorkspaceDocPageGuest(live.browserPageId, HOST_RENDERER_ID)).not.toBeNull()
|
||||
expect(guest.contents).toBeDefined()
|
||||
})
|
||||
|
||||
// Why both directions: two previews open at once must not be able to drive each other's guest.
|
||||
it('keeps two live previews on their own guests', () => {
|
||||
const first = boundGuest()
|
||||
const second = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(first.grant.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
first.guest.contents
|
||||
)
|
||||
expect(getWorkspaceDocPageGuest(second.grant.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
second.guest.contents
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Session events name the contents and nothing else, so this is how a fence firing on the whole
|
||||
// partition works out which preview — if any — the reader should be told about.
|
||||
describe('naming the grant a contents is showing', () => {
|
||||
it('answers the bound grant for a live preview guest', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBe(grant.id)
|
||||
})
|
||||
|
||||
it('answers nothing for a contents that is not a preview guest', () => {
|
||||
boundGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId({} as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing for a preview guest that has committed no document', () => {
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing once the guest is gone', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
|
||||
parseDocPreviewUrl
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
|
||||
import { enforceBrowserRouteWebRtcPolicy } from './browser-route-webrtc-policy'
|
||||
import { getDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
|
||||
/** The trusted renderer hosting the preview: the sink for link reports and the only sender allowed to drive tools on it. */
|
||||
type PreviewHostRenderer = {
|
||||
id: number
|
||||
send: (channel: string, payload: { url: string }) => void
|
||||
}
|
||||
|
||||
type PreviewGuestRegistration = {
|
||||
host: PreviewHostRenderer
|
||||
readBoundGrantId: () => string | null
|
||||
isFocused: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a registry: the click report arrives on an IPC channel, where the only thing main holds is
|
||||
* the sender. Without this, "is this a live preview guest, and which grant is it bound to" has no
|
||||
* answer, and any WebContents that learned the channel name would be routed out.
|
||||
*/
|
||||
const previewGuests = new WeakMap<object, PreviewGuestRegistration>()
|
||||
|
||||
/**
|
||||
* The workspace-document half of the browser page registry, keyed by the page the reader opened
|
||||
* the document in. It is deliberately a separate map from the browsing one: page management,
|
||||
* agent commands, download routing and certificate attribution all read that map directly, in
|
||||
* more places than a guard could be remembered in, so a document guest is simply not in it. The
|
||||
* one door taught about both is `browserManager.getAuthorizedGuest`, because acting on the guest
|
||||
* the reader is looking at is the single operation that legitimately spans them.
|
||||
*/
|
||||
const docGuestsByPageId = new Map<
|
||||
string,
|
||||
{ guest: Electron.WebContents; hostId: number; grantId: string }
|
||||
>()
|
||||
|
||||
const registrationListeners = new Set<(browserPageId: string) => void>()
|
||||
|
||||
/** Why anything listens: a tool request can beat the guest's own attach, and the wait it parks in lives in the IPC layer. */
|
||||
export function onWorkspaceDocGuestRegistered(
|
||||
listener: (browserPageId: string) => void
|
||||
): () => void {
|
||||
registrationListeners.add(listener)
|
||||
return () => registrationListeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the document guest a browser tool should act on. Grants the guest nothing — it answers
|
||||
* only whether the trusted renderer asking is the one hosting a live, grant-bound preview on that
|
||||
* page. Reached only through `browserManager.getAuthorizedGuest`.
|
||||
*/
|
||||
export function getWorkspaceDocPageGuest(
|
||||
browserPageId: string,
|
||||
senderWebContentsId: number
|
||||
): Electron.WebContents | null {
|
||||
const registration = docGuestsByPageId.get(browserPageId)
|
||||
if (!registration || registration.hostId !== senderWebContentsId) {
|
||||
return null
|
||||
}
|
||||
// Why: revocation is how a closed tab withdraws its preview, and it happens before the guest is torn down.
|
||||
if (!getDocPreviewGrant(registration.grantId)) {
|
||||
return null
|
||||
}
|
||||
if (registration.guest.isDestroyed()) {
|
||||
docGuestsByPageId.delete(browserPageId)
|
||||
return null
|
||||
}
|
||||
return registration.guest
|
||||
}
|
||||
|
||||
/** True when the page renders a workspace document, so the browsing registry must never hold it. */
|
||||
export function isWorkspaceDocPageId(browserPageId: string): boolean {
|
||||
return docGuestsByPageId.has(browserPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The grant a live preview guest is bound to, or null for any other WebContents — the same identity
|
||||
* question `reportDocPreviewLinkClick` asks, for a session event that hands over only the contents.
|
||||
*/
|
||||
export function readDocPreviewGuestBoundGrantId(guest: Electron.WebContents): string | null {
|
||||
return previewGuests.get(guest)?.readBoundGrantId() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview guest renders a workspace document, not the web: it may only move within its own
|
||||
* grant, and nothing it does by itself leaves the preview. The one route out is
|
||||
* `reportDocPreviewLinkClick`, which answers for a click the reader really made.
|
||||
*
|
||||
* Reached only through `browserManager.attachGuestPolicies` under the workspace-doc profile, so
|
||||
* every guest in the app is policy-attached by one door whatever it renders. Returns the disposal
|
||||
* that door stores: without it a retired guest keeps its listeners and stays answerable through the
|
||||
* grant-keyed authority until the WebContents itself is collected.
|
||||
*/
|
||||
export function installDocPreviewGuestPolicy(
|
||||
guest: Electron.WebContents,
|
||||
host: PreviewHostRenderer
|
||||
): () => void {
|
||||
// Why: the first commit is the renderer-set src, already admitted by will-attach-webview;
|
||||
// latching it pins every later navigation to that one grant.
|
||||
let boundGrantId: string | null = null
|
||||
let boundPageId: string | null = null
|
||||
|
||||
const latchGrantFromUrl = (rawUrl: string): void => {
|
||||
if (boundGrantId !== null) {
|
||||
return
|
||||
}
|
||||
const grantId = parseDocPreviewUrl(rawUrl)?.grantId ?? null
|
||||
// Why the grant must still be live: registering under the page a dead grant names would put a
|
||||
// guest nothing can read into the registry the tool door answers from.
|
||||
const grant = grantId === null ? null : getDocPreviewGrant(grantId)
|
||||
if (!grant) {
|
||||
return
|
||||
}
|
||||
boundGrantId = grant.id
|
||||
boundPageId = grant.browserPageId
|
||||
docGuestsByPageId.set(boundPageId, { guest, hostId: host.id, grantId: grant.id })
|
||||
for (const listener of registrationListeners) {
|
||||
listener(boundPageId)
|
||||
}
|
||||
}
|
||||
|
||||
previewGuests.set(guest, {
|
||||
host,
|
||||
readBoundGrantId: () => boundGrantId,
|
||||
isFocused: () => guest.isFocused()
|
||||
})
|
||||
const forgetGuest = (): void => {
|
||||
previewGuests.delete(guest)
|
||||
// Why the identity check: a re-mint registers the replacement under the same page before this
|
||||
// guest's own teardown runs, and deleting by key alone would unregister the live one.
|
||||
if (boundPageId !== null && docGuestsByPageId.get(boundPageId)?.guest === guest) {
|
||||
docGuestsByPageId.delete(boundPageId)
|
||||
}
|
||||
}
|
||||
guest.once('destroyed', forgetGuest)
|
||||
|
||||
const isAllowedPreviewNavigation = (rawUrl: string): boolean => {
|
||||
const target = parseDocPreviewUrl(rawUrl)
|
||||
if (!target || !getDocPreviewGrant(target.grantId)) {
|
||||
return false
|
||||
}
|
||||
// Why the latch is required and not just consistent: the renderer-set src is browser-initiated,
|
||||
// so will-navigate never fires for it. Anything reaching here before the latch is the guest
|
||||
// moving itself, which no grant has admitted yet.
|
||||
return boundGrantId !== null && target.grantId === boundGrantId
|
||||
}
|
||||
|
||||
/**
|
||||
* Why deny-only, with nothing routed: a navigation the guest starts cannot be attributed to the
|
||||
* reader. The document may read its whole grant over `connect-src 'self'`, so routing an
|
||||
* unattributable URL to a real browser tab with full network is how those bytes would get out.
|
||||
*/
|
||||
const navigationGuard = (event: Electron.Event, url: string): void => {
|
||||
if (isAllowedPreviewNavigation(url)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Why here and not only on navigation events: the guest is already loading its src by the time
|
||||
// the embedder hands it to us, so the only navigation most previews ever make has already
|
||||
// started. Latching what it is on now is what binds the usual preview to its grant at all.
|
||||
latchGrantFromUrl(guest.getURL())
|
||||
const latchFromMainFrameNavigation = (details: { isMainFrame: boolean; url: string }): void => {
|
||||
// Why: only the top document defines which grant this guest belongs to. Latching from a
|
||||
// subframe would let an in-document iframe rebind the guest to another grant.
|
||||
if (!details.isMainFrame) {
|
||||
return
|
||||
}
|
||||
latchGrantFromUrl(details.url)
|
||||
}
|
||||
// Why a committed URL too: a navigation that started before this listener existed still commits
|
||||
// after it, and a preview that never navigates again would otherwise stay bound to nothing.
|
||||
const latchFromCommittedUrl = (_event: Electron.Event, url: string): void =>
|
||||
latchGrantFromUrl(url)
|
||||
// Why: will-navigate never fires for a subframe, so without this an <iframe src="https://…">
|
||||
// inside a previewed document would load off-machine even though the top frame cannot.
|
||||
const frameNavigationGuard = (details: {
|
||||
isMainFrame: boolean
|
||||
url: string
|
||||
preventDefault: () => void
|
||||
}): void => {
|
||||
if (details.isMainFrame || isAllowedPreviewNavigation(details.url)) {
|
||||
return
|
||||
}
|
||||
details.preventDefault()
|
||||
}
|
||||
guest.on('did-start-navigation', latchFromMainFrameNavigation)
|
||||
guest.on('did-navigate', latchFromCommittedUrl)
|
||||
guest.on('will-navigate', navigationGuard)
|
||||
guest.on('will-redirect', navigationGuard)
|
||||
guest.on('will-frame-navigate', frameNavigationGuard)
|
||||
// Why deny with nothing routed: previews own no native child windows, and a popup the document
|
||||
// asked for is the document asking, not the reader. A link the reader presses is intercepted
|
||||
// before Chromium ever considers a popup.
|
||||
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
// Why a second fence for one API: WebRTC opens UDP straight from the network stack, so neither the
|
||||
// response CSP nor the session's request filter ever sees it. This is the only layer that can.
|
||||
enforceBrowserRouteWebRtcPolicy(guest, () => {})
|
||||
|
||||
return () => {
|
||||
forgetGuest()
|
||||
if (guest.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
guest.off('destroyed', forgetGuest)
|
||||
guest.off('did-start-navigation', latchFromMainFrameNavigation)
|
||||
guest.off('did-navigate', latchFromCommittedUrl)
|
||||
guest.off('will-navigate', navigationGuard)
|
||||
guest.off('will-redirect', navigationGuard)
|
||||
guest.off('will-frame-navigate', frameNavigationGuard)
|
||||
}
|
||||
}
|
||||
|
||||
function isWebUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live
|
||||
* preview guest still bound to a grant, it must be the contents the reader is looking at, and the
|
||||
* target must be the web. Anything else is dropped without a trace the document could observe.
|
||||
*/
|
||||
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
|
||||
const registration = previewGuests.get(sender)
|
||||
if (!registration) {
|
||||
return
|
||||
}
|
||||
const boundGrantId = registration.readBoundGrantId()
|
||||
if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) {
|
||||
return
|
||||
}
|
||||
// Why focus and not just the preload's trusted-click check: that check runs inside the guest, so
|
||||
// it holds only while the guest renderer does. Focus is the half main can verify for itself.
|
||||
if (!registration.isFocused()) {
|
||||
return
|
||||
}
|
||||
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
|
||||
if (!externalUrl || !isWebUrl(externalUrl)) {
|
||||
return
|
||||
}
|
||||
registration.host.send(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, { url: externalUrl })
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readDocPreviewFile: vi.fn(),
|
||||
installBrowserSessionPartitionPolicies: vi.fn()
|
||||
}))
|
||||
|
||||
function createFakeSession(): {
|
||||
protocol: { isProtocolHandled: () => boolean; handle: ReturnType<typeof vi.fn> }
|
||||
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
|
||||
} {
|
||||
return {
|
||||
protocol: { isProtocolHandled: () => false, handle: vi.fn() },
|
||||
webRequest: { onBeforeRequest: vi.fn() }
|
||||
}
|
||||
}
|
||||
|
||||
// Why one session per partition and a distinct default: installing the handler on the default
|
||||
// session instead of the preview session is otherwise invisible — every read would come back
|
||||
// from the same object.
|
||||
const previewSession = createFakeSession()
|
||||
const defaultSession = createFakeSession()
|
||||
vi.mock('electron', () => ({
|
||||
protocol: { registerSchemesAsPrivileged: vi.fn() },
|
||||
session: {
|
||||
get defaultSession() {
|
||||
return defaultSession
|
||||
},
|
||||
fromPartition: (partition: string) => {
|
||||
if (partition !== 'orca-doc-preview') {
|
||||
throw new Error(`unexpected partition ${partition}`)
|
||||
}
|
||||
return previewSession
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./doc-preview-file-reader', () => ({ readDocPreviewFile: mocks.readDocPreviewFile }))
|
||||
vi.mock('./browser-session-partition-policies', () => ({
|
||||
installBrowserSessionPartitionPolicies: mocks.installBrowserSessionPartitionPolicies
|
||||
}))
|
||||
|
||||
import { protocol } from 'electron'
|
||||
import {
|
||||
getDocPreviewSession,
|
||||
handleDocPreviewRequest,
|
||||
installDocPreviewProtocolHandler,
|
||||
isAllowedDocPreviewRequestUrl,
|
||||
isDocPreviewSession,
|
||||
registerDocPreviewSchemePrivileges
|
||||
} from './doc-preview-protocol'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
import {
|
||||
buildDocPreviewUrl,
|
||||
DOC_PREVIEW_LOAD_FAILURE_CHANNEL
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { setDocPreviewFailureSink } from './doc-preview-failure-notice'
|
||||
|
||||
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
setDocPreviewFailureSink(null)
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDocPreviewRequest', () => {
|
||||
it('serves an in-grant document with no-store so reload re-reads the workspace', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
||||
expect(await response.text()).toBe('<h1>hi</h1>')
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
|
||||
})
|
||||
|
||||
it('falls back to the granted entry document for a root request', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(`orca-preview://${grant.id}/`))
|
||||
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
|
||||
})
|
||||
|
||||
it('decodes percent-encoded segments before resolving', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'a b/c#d.html')))
|
||||
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'a b/c#d.html')
|
||||
})
|
||||
|
||||
it('404s an unknown grant without reading anything', async () => {
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(`orca-preview://${'0'.repeat(32)}/index.html`)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a revoked grant', async () => {
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a malformed grant id', async () => {
|
||||
const response = await handleDocPreviewRequest(new Request('orca-preview://not-a-grant/x.html'))
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates the reader status for an unservable asset', async () => {
|
||||
const grant = mintGrant()
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset',
|
||||
message: 'cannot send this file type'
|
||||
})
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'a.woff2'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(415)
|
||||
expect(await response.text()).toBe('cannot send this file type')
|
||||
})
|
||||
|
||||
// Why: previewed documents are agent-authored, so an outbound request is an exfiltration channel
|
||||
// for everything else the grant can read.
|
||||
it('serves documents under a self-only content security policy', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
const policy = response.headers.get('Content-Security-Policy') ?? ''
|
||||
expect(policy).toContain("default-src 'self'")
|
||||
expect(policy).toContain("connect-src 'self'")
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(policy).toContain("img-src 'self' data:")
|
||||
expect(policy).not.toContain('https:')
|
||||
// Why assert an absence: `webrtc 'block'` is the obvious directive to reach for here and this
|
||||
// Chromium does not implement it — it logs "Unrecognized Content-Security-Policy directive
|
||||
// 'webrtc'" and gathers candidates regardless. Adding it back would document a fence that is
|
||||
// not there; the guest's IP-handling policy is what actually refuses.
|
||||
expect(policy).not.toContain('webrtc')
|
||||
})
|
||||
|
||||
// Why: the guest paints a 4xx body as if it were the document, so the shell only learns the
|
||||
// reason from this push.
|
||||
it('pushes the failure reason for the requested path', async () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
const grant = mintGrant()
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
reason: 'too-large',
|
||||
message: 'too large'
|
||||
})
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: grant.id,
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes nothing when the document is served', async () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a grant revoked underneath a live guest 404s like a missing file, and without this push
|
||||
// the shell leaves the handler's "Not found" body on screen as if it were the document.
|
||||
it('pushes a failure for a revoked grant so the shell can replace the 404 body', async () => {
|
||||
const send = vi.fn()
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
setDocPreviewFailureSink({ send })
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: grant.id,
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('installDocPreviewProtocolHandler', () => {
|
||||
// Why the default session is named here: it is the session every other Electron API reaches for
|
||||
// by default, and handling the scheme there would serve preview bytes to ordinary browsing.
|
||||
it('handles the scheme on the preview session and nowhere else', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(previewSession.protocol.handle).toHaveBeenCalledWith(
|
||||
'orca-preview',
|
||||
handleDocPreviewRequest
|
||||
)
|
||||
expect(previewSession.webRequest.onBeforeRequest).toHaveBeenCalled()
|
||||
expect(defaultSession.protocol.handle).not.toHaveBeenCalled()
|
||||
expect(defaultSession.webRequest.onBeforeRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels every request the preview session should never carry', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
const filter = previewSession.webRequest.onBeforeRequest.mock.calls.at(-1)?.[0] as (
|
||||
details: { url: string },
|
||||
callback: (response: { cancel: boolean }) => void
|
||||
) => void
|
||||
const cancelled = (url: string): boolean => {
|
||||
let response: { cancel: boolean } | null = null
|
||||
filter({ url }, (value) => {
|
||||
response = value
|
||||
})
|
||||
return (response as { cancel: boolean } | null)?.cancel === true
|
||||
}
|
||||
|
||||
expect(cancelled('https://cdn.example.com/tracker.js')).toBe(true)
|
||||
expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: preview guests are webviews like any other, so they must not skip the deny-by-default
|
||||
// permission and display-media policy every browser partition gets.
|
||||
it('applies the shared browser partition policies to the preview session', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ partition: 'orca-doc-preview', userAgentMode: 'clean' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
// Why downloads and nothing else: the browser download flow attributes a file to the page that
|
||||
// asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes
|
||||
// into this desktop's Downloads folder with no prompt and no tab to name as the source.
|
||||
it('asks for downloads to be denied on the preview partition', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
|
||||
downloads: 'deny'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerDocPreviewSchemePrivileges', () => {
|
||||
// Why the whole literal and not a subset: every privilege this scheme does not claim is one the
|
||||
// document cannot use to escape it. `allowServiceWorkers` would outlive the tab that was granted
|
||||
// the read, and `bypassCSP` would undo the self-only policy the handler serves with.
|
||||
it('claims exactly the privileges the preview document needs', () => {
|
||||
registerDocPreviewSchemePrivileges()
|
||||
|
||||
expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
|
||||
{
|
||||
scheme: 'orca-preview',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedDocPreviewRequestUrl', () => {
|
||||
// Why: the session refuses to carry the request at all, so a CSP bypass in one element type
|
||||
// still reaches nothing off-machine.
|
||||
it('admits in-document schemes and refuses everything that leaves the machine', () => {
|
||||
expect(isAllowedDocPreviewRequestUrl(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('devtools://devtools/bundled/inspector.html')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('data:image/png;base64,AAA')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('blob:orca-preview://abc/123')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('https://cdn.example.com/app.css')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('http://127.0.0.1:9999/exfil')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('ws://evil.example.com/socket')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('file:///etc/passwd')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDocPreviewSession', () => {
|
||||
it('claims no session until the preview session has been materialized', () => {
|
||||
expect(isDocPreviewSession({} as never)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches only the memoized preview session', () => {
|
||||
const created = getDocPreviewSession()
|
||||
|
||||
expect(isDocPreviewSession(created)).toBe(true)
|
||||
expect(isDocPreviewSession({} as never)).toBe(false)
|
||||
expect(getDocPreviewSession()).toBe(created)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { protocol, session } from 'electron'
|
||||
import {
|
||||
DOC_PREVIEW_PARTITION,
|
||||
DOC_PREVIEW_SCHEME,
|
||||
parseDocPreviewUrl
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { installBrowserSessionPartitionPolicies } from './browser-session-partition-policies'
|
||||
import { readDocPreviewFile } from './doc-preview-file-reader'
|
||||
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
|
||||
import { getDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
|
||||
/** Must run before `app.whenReady()`; Electron freezes the privileged scheme table at ready. */
|
||||
export function registerDocPreviewSchemePrivileges(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: DOC_PREVIEW_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
let docPreviewSession: Electron.Session | null = null
|
||||
|
||||
/** Non-persistent session, so preview bytes never land in a browsing profile's storage. */
|
||||
export function getDocPreviewSession(): Electron.Session {
|
||||
docPreviewSession ??= session.fromPartition(DOC_PREVIEW_PARTITION)
|
||||
return docPreviewSession
|
||||
}
|
||||
|
||||
/** Pure identity check: every guest attach consults it, and none should materialize a session. */
|
||||
export function isDocPreviewSession(candidate: Electron.Session): boolean {
|
||||
return docPreviewSession !== null && candidate === docPreviewSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Product decision, not a hardening default: previewed documents are agent-authored, so any
|
||||
* outbound request they can make is an exfiltration channel for whatever else the page can read.
|
||||
* Self-contained documents — inline CSS/JS/SVG and in-grant assets — render in full; a CDN
|
||||
* stylesheet, font, script, or analytics beacon deliberately does not load.
|
||||
*/
|
||||
const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'"
|
||||
// Why no `webrtc 'block'` here, though a peer connection is exactly the outbound channel this
|
||||
// policy is meant to close: Chromium 43-era answers that directive with "Unrecognized
|
||||
// Content-Security-Policy directive 'webrtc'" and gathers candidates anyway, so listing it would
|
||||
// read as a fence while fencing nothing. The guest's IP-handling policy is the one that holds —
|
||||
// see `installDocPreviewGuestPolicy`.
|
||||
].join('; ')
|
||||
|
||||
function notFound(message: string): Response {
|
||||
return new Response(message, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleDocPreviewRequest(request: Request): Promise<Response> {
|
||||
const target = parseDocPreviewUrl(request.url)
|
||||
if (!target) {
|
||||
return notFound('Not found')
|
||||
}
|
||||
const grant = getDocPreviewGrant(target.grantId)
|
||||
if (!grant) {
|
||||
// Why: a revoked or unknown grant is indistinguishable from a missing file by design — but the
|
||||
// shell still needs to know, or the guest paints this body where the document should be.
|
||||
publishDocPreviewFailure({
|
||||
grantId: target.grantId,
|
||||
relativePath: target.relativePath,
|
||||
reason: 'unreadable'
|
||||
})
|
||||
return notFound('Not found')
|
||||
}
|
||||
const relativePath = target.relativePath || grant.entryRelativePath
|
||||
const outcome = await readDocPreviewFile(grant, relativePath)
|
||||
if (!outcome.ok) {
|
||||
publishDocPreviewFailure({ grantId: target.grantId, relativePath, reason: outcome.reason })
|
||||
return new Response(outcome.message, {
|
||||
status: outcome.status,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
})
|
||||
}
|
||||
return new Response(new Uint8Array(outcome.bytes), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': outcome.contentType,
|
||||
'Content-Security-Policy': DOC_PREVIEW_CONTENT_SECURITY_POLICY,
|
||||
// Why: reload must re-read the workspace disk, so nothing may be cached.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** `data:`/`blob:` never leave the document; every other scheme would reach off-machine. */
|
||||
export function isAllowedDocPreviewRequestUrl(url: string): boolean {
|
||||
return (
|
||||
url.startsWith(`${DOC_PREVIEW_SCHEME}://`) ||
|
||||
url.startsWith('devtools://') ||
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('blob:')
|
||||
)
|
||||
}
|
||||
|
||||
export function installDocPreviewProtocolHandler(): void {
|
||||
const previewSession = getDocPreviewSession()
|
||||
if (previewSession.protocol.isProtocolHandled(DOC_PREVIEW_SCHEME)) {
|
||||
return
|
||||
}
|
||||
previewSession.protocol.handle(DOC_PREVIEW_SCHEME, handleDocPreviewRequest)
|
||||
// Why: the response CSP is the document's own promise to obey; this is the session refusing to
|
||||
// carry the request at all, so a CSP bypass in one element type still reaches nothing.
|
||||
previewSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) })
|
||||
})
|
||||
// Why: preview guests are webviews like any other, so they inherit the same deny-by-default
|
||||
// permission, display-media and user-agent policy every browser partition gets.
|
||||
installBrowserSessionPartitionPolicies(
|
||||
{
|
||||
id: DOC_PREVIEW_PARTITION,
|
||||
scope: 'isolated',
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
label: 'Document preview',
|
||||
source: null,
|
||||
userAgentMode: 'clean'
|
||||
},
|
||||
// Why downloads are the one policy that does not carry over: the browser download flow needs a
|
||||
// page to attribute the file to, and a previewed document is not one. Routed here it would
|
||||
// reserve a name in this desktop's Downloads folder and write remote-authored bytes into it
|
||||
// with nothing in the UI naming the tab that asked, and no prompt in front of it.
|
||||
{ downloads: 'deny' }
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
windows: [] as { webContents: MockWebContents }[],
|
||||
windows: [] as MockBrowserWindow[],
|
||||
BrowserWindow: vi.fn(),
|
||||
finishLoads: true
|
||||
}))
|
||||
@@ -50,6 +50,41 @@ vi.mock('./browser-session-registry', () => ({
|
||||
}))
|
||||
|
||||
import { OffscreenBrowserBackend } from './offscreen-browser-backend'
|
||||
import { installDocPreviewGuestPolicy, isWorkspaceDocPageId } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
/** The real door's answer, so the backend is tested against the refusal it will actually get. */
|
||||
function registerOffscreenGuestLikeBrowserManager({
|
||||
browserPageId
|
||||
}: {
|
||||
browserPageId: string
|
||||
}): boolean {
|
||||
return !isWorkspaceDocPageId(browserPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A page the document half of the registry really owns. Built rather than named, because the door
|
||||
* refuses on registry membership now — a made-up id would be admitted and prove nothing.
|
||||
*/
|
||||
function registerWorkspaceDocPage(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: 1, send: vi.fn() })
|
||||
}
|
||||
|
||||
describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
@@ -73,7 +108,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.finishLoads = false
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never)
|
||||
@@ -94,9 +129,36 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Why the unregister assertion and not just the destroy: a refused id is one the document half
|
||||
// of the registry owns, and the teardown hook would cancel that preview's work on the way out.
|
||||
it('destroys a window whose registration was refused without unregistering the id', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never)
|
||||
registerWorkspaceDocPage('doc-page-1')
|
||||
|
||||
await expect(
|
||||
backend.createTab({
|
||||
browserPageId: 'doc-page-1',
|
||||
url: 'https://example.com',
|
||||
worktreeId: 'wt'
|
||||
})
|
||||
).rejects.toThrow('was refused')
|
||||
|
||||
expect(mocks.windows[0].isDestroyed()).toBe(true)
|
||||
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
|
||||
|
||||
// Why shutdown and not the map: a refused id left behind is invisible until teardown walks it
|
||||
// and hands that id to the other authority after all.
|
||||
await backend.destroyAll()
|
||||
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unregisters a closing page before awaiting owner retirement', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -126,7 +188,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('preserves a replacement page when the old window finishes closing', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -152,7 +214,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const onPageClosed = vi.fn(async () => {})
|
||||
@@ -167,7 +229,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('cleans every helper owner during backend shutdown', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const onPageClosed = vi.fn(async () => {})
|
||||
@@ -186,7 +248,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -215,7 +277,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('joins owner retirement started by an unexpected renderer destroy', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -244,7 +306,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('bounds concurrent helper retirements during shutdown', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let activeRetirements = 0
|
||||
@@ -284,7 +346,10 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
})
|
||||
|
||||
it('closes the page even when daemon retirement throws', async () => {
|
||||
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({
|
||||
onPageClosed: vi.fn(async () => {
|
||||
|
||||
@@ -66,9 +66,33 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
|
||||
this.windowsByPageId.set(browserPageId, win)
|
||||
|
||||
// Why: if the offscreen window is destroyed out from under us (crash, app
|
||||
// teardown), drop the registry entry so commands fail cleanly instead of
|
||||
// resolving a dead WebContents.
|
||||
// Why: register the guest and return immediately so the new tab appears
|
||||
// without waiting for the page to finish loading. Previously createTab
|
||||
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
|
||||
// up to a second on real URLs. The page loads asynchronously and streams
|
||||
// once it paints; a failed load leaves the (usable) tab open, matching how a
|
||||
// normal browser tab survives a failed navigation.
|
||||
const registered = this.browserManager.registerOffscreenGuest({
|
||||
browserPageId,
|
||||
worktreeId: params.worktreeId,
|
||||
sessionProfileId: profile?.id ?? null,
|
||||
userAgentMode: profile?.userAgentMode,
|
||||
webContentsId: win.webContents.id
|
||||
})
|
||||
if (!registered) {
|
||||
// Why destroy rather than carry on: the window already exists but carries none of the guest
|
||||
// policies registration installs, so leaving it would navigate an unvalidated URL with no
|
||||
// policy on it and hand back a page id nothing can drive. The renderer door aborts its mount
|
||||
// the same way; this is that abort.
|
||||
this.windowsByPageId.delete(browserPageId)
|
||||
win.destroy()
|
||||
throw new Error(`Browser page ${browserPageId} was refused`)
|
||||
}
|
||||
|
||||
// Why only once registration took: this teardown unregisters the page id, and a refused id is
|
||||
// one the other authority may own — cancelling its work would be the confusion we just refused.
|
||||
// Why at all: if the window dies out from under us (crash, app teardown), drop the registry
|
||||
// entry so commands fail cleanly instead of resolving a dead WebContents.
|
||||
win.webContents.once('destroyed', () => {
|
||||
// Explicit close removes the page first and performs awaited cleanup;
|
||||
// only an unexpected destruction still owns the bridge retirement here.
|
||||
@@ -80,20 +104,6 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
this.browserManager.unregisterGuest(browserPageId)
|
||||
})
|
||||
|
||||
// Why: register the guest and return immediately so the new tab appears
|
||||
// without waiting for the page to finish loading. Previously createTab
|
||||
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
|
||||
// up to a second on real URLs. The page loads asynchronously and streams
|
||||
// once it paints; a failed load leaves the (usable) tab open, matching how a
|
||||
// normal browser tab survives a failed navigation.
|
||||
this.browserManager.registerOffscreenGuest({
|
||||
browserPageId,
|
||||
worktreeId: params.worktreeId,
|
||||
sessionProfileId: profile?.id ?? null,
|
||||
userAgentMode: profile?.userAgentMode,
|
||||
webContentsId: win.webContents.id
|
||||
})
|
||||
|
||||
const url = params.url || 'about:blank'
|
||||
void this.loadUrl(win, url).catch((error) => {
|
||||
console.warn(
|
||||
|
||||
@@ -319,6 +319,11 @@ import { browserCertificateTrustController, browserManager } from './browser/bro
|
||||
import { RpcDispatcher } from './runtime/rpc/dispatcher'
|
||||
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
|
||||
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
|
||||
import {
|
||||
installDocPreviewProtocolHandler,
|
||||
registerDocPreviewSchemePrivileges
|
||||
} from './browser/doc-preview-protocol'
|
||||
import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc'
|
||||
import { initializeBrowserClientHostId } from './browser/browser-client-host-id'
|
||||
import { setUnreadDockBadgeCount } from './dock/unread-badge'
|
||||
import { AutomationService } from './automations/service'
|
||||
@@ -962,6 +967,9 @@ if (hasSingleInstanceLock) {
|
||||
if (shouldApplyPreReadyAppName(devInstanceIdentity)) {
|
||||
app.setName(devInstanceIdentity.appName)
|
||||
}
|
||||
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
|
||||
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
|
||||
registerDocPreviewSchemePrivileges()
|
||||
// Why: must precede app.whenReady() so Crashpad is installed before the
|
||||
// first renderer spawns; a CHECK before this point is still exit-code-only.
|
||||
startCrashpadCapture()
|
||||
@@ -2437,6 +2445,9 @@ void app.whenReady().then(async () => {
|
||||
} catch {
|
||||
console.warn('[proxy] Failed to apply network proxy settings')
|
||||
}
|
||||
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
|
||||
installDocPreviewProtocolHandler()
|
||||
registerDocPreviewGrantHandlers()
|
||||
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
|
||||
initializeBrowserSessionsForApp({
|
||||
orcaProfileId: activeOrcaProfile.profile.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { onDocPreviewGrantRevoked } from '../browser/doc-preview-grant-registry'
|
||||
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
|
||||
import { waitForNextTabRegistration } from './browser-tab-registration-wait'
|
||||
import type {
|
||||
@@ -48,7 +49,25 @@ export function disposeGrabModeStateForPage(browserPageId: string): void {
|
||||
grabModeOperationByPageId.delete(browserPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why previews need their own disposal path: a browser page announces its own death on
|
||||
* `browser:unregisterGuest`, and a preview never does — it withdraws by revoking its grant, which
|
||||
* is also what a re-mint does. Without this, every grant leaves an intent entry behind forever.
|
||||
*/
|
||||
let disposePreviewGrantSubscription: (() => void) | null = null
|
||||
|
||||
function subscribeToPreviewGrantRevocation(): void {
|
||||
disposePreviewGrantSubscription?.()
|
||||
disposePreviewGrantSubscription = onDocPreviewGrantRevoked((grant) => {
|
||||
// Why cancel first: a grab still armed on that guest would otherwise leave the renderer's
|
||||
// await hanging on a surface the reader has already closed.
|
||||
browserManager.cancelGrabOp(grant.browserPageId, 'evicted')
|
||||
disposeGrabModeStateForPage(grant.browserPageId)
|
||||
})
|
||||
}
|
||||
|
||||
export function registerBrowserGrabHandlers(): void {
|
||||
subscribeToPreviewGrantRevocation()
|
||||
ipcMain.removeHandler('browser:setGrabMode')
|
||||
ipcMain.removeHandler('browser:awaitGrabSelection')
|
||||
ipcMain.removeHandler('browser:cancelGrab')
|
||||
|
||||
@@ -85,12 +85,26 @@ export function registerBrowserGuestViewHandlers(): void {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return browserManager.setAnnotationViewportBridge(args.browserPageId, {
|
||||
enabled: args.enabled,
|
||||
emitViewport: args.emitViewport,
|
||||
markers: args.markers,
|
||||
token: args.token
|
||||
})
|
||||
// Why resolve here: this is a tool acting on a guest the reader is looking at, so it answers
|
||||
// for a workspace document too — and routing through the authority also pins the request to
|
||||
// the renderer that owns the target, which page-id-only resolution never checked.
|
||||
const resolveGuest = (): Electron.WebContents | null =>
|
||||
browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
|
||||
if (!resolveGuest()) {
|
||||
return false
|
||||
}
|
||||
// Why hand over the resolver rather than that guest: the op is serialized per tab, and the
|
||||
// one it finally runs against must be the one on screen then, not the one this request saw.
|
||||
return browserManager.setAnnotationViewportBridge(
|
||||
args.browserPageId,
|
||||
{
|
||||
enabled: args.enabled,
|
||||
emitViewport: args.emitViewport,
|
||||
markers: args.markers,
|
||||
token: args.token
|
||||
},
|
||||
resolveGuest
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as DocPreviewGuestPolicyModule from '../browser/doc-preview-guest-policy'
|
||||
import type * as TabRegistrationWaitModule from './browser-tab-registration-wait'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
removeHandlerMock,
|
||||
getAuthorizedGuestMock,
|
||||
setGrabModeMock,
|
||||
awaitGrabSelectionMock,
|
||||
cancelGrabOpMock,
|
||||
captureSelectionScreenshotMock,
|
||||
extractHoverPayloadMock,
|
||||
setAnnotationViewportBridgeMock,
|
||||
openDevToolsMock,
|
||||
setViewportOverrideMock,
|
||||
previewAuthoritySpy,
|
||||
registrationWaitSpy
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn(),
|
||||
getAuthorizedGuestMock: vi.fn(),
|
||||
setGrabModeMock: vi.fn().mockResolvedValue(true),
|
||||
awaitGrabSelectionMock: vi.fn().mockResolvedValue({ opId: 'op', kind: 'cancelled' }),
|
||||
cancelGrabOpMock: vi.fn(),
|
||||
captureSelectionScreenshotMock: vi.fn().mockResolvedValue(null),
|
||||
extractHoverPayloadMock: vi.fn().mockResolvedValue(null),
|
||||
setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true),
|
||||
openDevToolsMock: vi.fn().mockResolvedValue(true),
|
||||
setViewportOverrideMock: vi.fn().mockResolvedValue(true),
|
||||
previewAuthoritySpy: vi.fn(),
|
||||
registrationWaitSpy: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromWebContents: vi.fn() },
|
||||
ipcMain: { removeHandler: removeHandlerMock, handle: handleMock },
|
||||
webContents: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }
|
||||
}))
|
||||
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserCertificateTrustController: { proceed: vi.fn(() => ({ ok: true })) },
|
||||
browserManager: {
|
||||
registerGuest: vi.fn(() => true),
|
||||
attachGuestPolicies: vi.fn(),
|
||||
unregisterGuest: vi.fn(),
|
||||
getGuestWebContentsId: vi.fn(),
|
||||
getWebContentsIdByTabId: vi.fn(() => new Map()),
|
||||
getWorktreeIdForTab: vi.fn(),
|
||||
getAuthorizedGuest: getAuthorizedGuestMock,
|
||||
setGrabMode: setGrabModeMock,
|
||||
awaitGrabSelection: awaitGrabSelectionMock,
|
||||
cancelGrabOp: cancelGrabOpMock,
|
||||
captureSelectionScreenshot: captureSelectionScreenshotMock,
|
||||
extractHoverPayload: extractHoverPayloadMock,
|
||||
setAnnotationViewportBridge: setAnnotationViewportBridgeMock,
|
||||
openDevTools: openDevToolsMock,
|
||||
setViewportOverride: setViewportOverrideMock,
|
||||
cancelDownload: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Why the real policy module behind a spy: the point of these tests is which half of the page
|
||||
// registry a channel reads, so a stub of it would prove nothing about what was consulted.
|
||||
vi.mock('../browser/doc-preview-guest-policy', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof DocPreviewGuestPolicyModule>()
|
||||
return {
|
||||
...actual,
|
||||
getWorkspaceDocPageGuest: (browserPageId: string, senderWebContentsId: number) => {
|
||||
previewAuthoritySpy(browserPageId, senderWebContentsId)
|
||||
return actual.getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Why spy rather than stub: the wait is real machinery the browser-page path still depends on;
|
||||
// only whether a preview target enters it is under test.
|
||||
vi.mock('./browser-tab-registration-wait', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TabRegistrationWaitModule>()
|
||||
return {
|
||||
...actual,
|
||||
waitForNextTabRegistration: (...args: Parameters<typeof actual.waitForNextTabRegistration>) => {
|
||||
registrationWaitSpy(args[0])
|
||||
return actual.waitForNextTabRegistration(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { registerBrowserHandlers } from './browser'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy
|
||||
} from '../browser/doc-preview-guest-policy'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
const HOST_RENDERER_ID = 91
|
||||
const OTHER_RENDERER_ID = 92
|
||||
/** Mirrors browser-grab-ipc's own wait, so elapsing it here settles the same parked request. */
|
||||
const GRAB_REGISTRATION_WAIT_MS = 1_000
|
||||
|
||||
/**
|
||||
* Every `browser:*` invoke channel, split by whether it acts on a guest the reader is looking at
|
||||
* (a tool) or manages browser-page, session and profile state. The split is asserted to be total,
|
||||
* so a new channel cannot be added without deciding which side of the preview seam it belongs on.
|
||||
*/
|
||||
const TOOL_CHANNELS = [
|
||||
'browser:setGrabMode',
|
||||
'browser:awaitGrabSelection',
|
||||
'browser:cancelGrab',
|
||||
'browser:captureSelectionScreenshot',
|
||||
'browser:extractHoverPayload',
|
||||
'browser:setAnnotationViewportBridge'
|
||||
]
|
||||
|
||||
const BROWSER_PAGE_CHANNELS = [
|
||||
'browser:registerGuest',
|
||||
'browser:prepareSshWorkspacePartition',
|
||||
'browser:repairGuestRegistration',
|
||||
'browser:isGuestRegistered',
|
||||
'browser:unregisterGuest',
|
||||
'browser:respondWebAuthnAccount',
|
||||
'browser:proceedCertificate',
|
||||
'browser:activeTabChanged',
|
||||
'browser:openDevTools',
|
||||
'browser:setViewportOverride',
|
||||
'browser:publishClientPageMetadata',
|
||||
'browser:cancelDownload',
|
||||
'browser:session:listProfiles',
|
||||
'browser:session:createProfile',
|
||||
'browser:session:deleteProfile',
|
||||
'browser:session:importCookies',
|
||||
'browser:session:resolvePartition',
|
||||
'browser:session:clearDefaultCookies',
|
||||
'browser:session:importFromBrowserForClientHost',
|
||||
'browser:session:clientRouteImportSources',
|
||||
'browser:session:detectBrowsers',
|
||||
'browser:session:detectBrowsersForClientHost',
|
||||
'browser:session:importFromBrowser'
|
||||
]
|
||||
|
||||
type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown
|
||||
|
||||
function registeredHandlers(): Map<string, Handler> {
|
||||
const handlers = new Map<string, Handler>()
|
||||
for (const [channel, handler] of handleMock.mock.calls as [string, Handler][]) {
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
function trustedSender(id: number): { sender: Electron.WebContents } {
|
||||
return {
|
||||
sender: {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///renderer/index.html'
|
||||
} as unknown as Electron.WebContents
|
||||
}
|
||||
}
|
||||
|
||||
let nextDocPageOrdinal = 0
|
||||
|
||||
function grantForNewDocPage(): { id: string; browserPageId: string } {
|
||||
nextDocPageOrdinal += 1
|
||||
const browserPageId = `doc-page-${nextDocPageOrdinal}`
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
return { id: grant.id, browserPageId }
|
||||
}
|
||||
|
||||
/** A preview guest already showing its document, which is the only state a tool can act in. */
|
||||
function renderPreviewForGrant(
|
||||
grant: { id: string; browserPageId: string },
|
||||
hostId: number = HOST_RENDERER_ID
|
||||
): {
|
||||
grantId: string
|
||||
browserPageId: string
|
||||
contents: object
|
||||
markContentsDestroyed: () => void
|
||||
} {
|
||||
const browserPageId = grant.browserPageId
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const register = (event: string, handler: (...args: never[]) => void): void => {
|
||||
handlers[event] = handler
|
||||
}
|
||||
// Why the guest already reports its URL: the embedder hands a preview over mid-load, so this is
|
||||
// the state the policy really installs into.
|
||||
const documentUrl = buildDocPreviewUrl(grant.id, 'index.html')
|
||||
let contentsDestroyed = false
|
||||
const guest = {
|
||||
isFocused: () => true,
|
||||
isDestroyed: () => contentsDestroyed,
|
||||
getURL: () => documentUrl,
|
||||
on: vi.fn(register),
|
||||
once: vi.fn(register),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: hostId, send: vi.fn() })
|
||||
handlers['did-start-navigation']?.({ url: documentUrl, isMainFrame: true } as never)
|
||||
return {
|
||||
grantId: grant.id,
|
||||
browserPageId,
|
||||
contents: guest,
|
||||
// Why without the `destroyed` event: Chromium tears the contents down before main runs that
|
||||
// listener, so this is the window the authority has to answer for on its own.
|
||||
markContentsDestroyed: () => {
|
||||
contentsDestroyed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function liveRenderedPreview(
|
||||
hostId: number = HOST_RENDERER_ID
|
||||
): ReturnType<typeof renderPreviewForGrant> {
|
||||
return renderPreviewForGrant(grantForNewDocPage(), hostId)
|
||||
}
|
||||
|
||||
/** Minimal well-formed args per channel, so a refusal is authorization and not shape validation. */
|
||||
function toolArgs(channel: string, browserPageId: string): Record<string, unknown> {
|
||||
switch (channel) {
|
||||
case 'browser:setGrabMode':
|
||||
return { browserPageId, enabled: true }
|
||||
case 'browser:awaitGrabSelection':
|
||||
return { browserPageId, opId: 'op-1' }
|
||||
case 'browser:captureSelectionScreenshot':
|
||||
return { browserPageId, rect: { x: 0, y: 0, width: 10, height: 10 } }
|
||||
case 'browser:setAnnotationViewportBridge':
|
||||
return {
|
||||
browserPageId,
|
||||
enabled: true,
|
||||
emitViewport: true,
|
||||
markers: [],
|
||||
token: 'annotation-bridge-token-1'
|
||||
}
|
||||
default:
|
||||
return { browserPageId }
|
||||
}
|
||||
}
|
||||
|
||||
/** The viewport bridge is handed a resolver rather than the contents, so unwrap one call argument. */
|
||||
function resolvesToGuest(argument: unknown, guest: object): boolean {
|
||||
return argument === guest || (typeof argument === 'function' && argument() === guest)
|
||||
}
|
||||
|
||||
const GUEST_RECEIVING_MOCKS = [
|
||||
setGrabModeMock,
|
||||
awaitGrabSelectionMock,
|
||||
captureSelectionScreenshotMock,
|
||||
extractHoverPayloadMock,
|
||||
setAnnotationViewportBridgeMock
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('ELECTRON_RENDERER_URL', '')
|
||||
// Why before clearing: revoking the previous test's grants disposes their page state through the
|
||||
// mocked manager, and those calls belong to that test, not this one.
|
||||
revokeAllDocPreviewGrants()
|
||||
vi.clearAllMocks()
|
||||
// Why the mocked manager still answers for documents: this file's subject is which channels may
|
||||
// reach a document guest, not how the manager splits its registry — that lives in
|
||||
// browser-manager-guest-policy-profile.test.ts, against the real manager.
|
||||
getAuthorizedGuestMock.mockImplementation((browserPageId: string, senderWebContentsId: number) =>
|
||||
getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
|
||||
)
|
||||
setGrabModeMock.mockResolvedValue(true)
|
||||
awaitGrabSelectionMock.mockResolvedValue({ opId: 'op-1', kind: 'cancelled' })
|
||||
captureSelectionScreenshotMock.mockResolvedValue(null)
|
||||
extractHoverPayloadMock.mockResolvedValue(null)
|
||||
setAnnotationViewportBridgeMock.mockResolvedValue(true)
|
||||
registerBrowserHandlers()
|
||||
// Why fake timers for the whole file: a tool asking for a page whose guest has not attached parks
|
||||
// in the registration wait, and several cases here are exactly the ones that never attach. Real
|
||||
// time would spend that wait for each of them.
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Settles a tool request, elapsing the registration wait it may be parked in. */
|
||||
async function settle<T>(pending: Promise<T> | T | undefined): Promise<T | undefined> {
|
||||
await vi.advanceTimersByTimeAsync(GRAB_REGISTRATION_WAIT_MS)
|
||||
return pending
|
||||
}
|
||||
|
||||
describe('doc preview tool authorization', () => {
|
||||
it('classifies every registered browser channel as a tool or a browser-page channel', () => {
|
||||
const registered = [...registeredHandlers().keys()].sort()
|
||||
const classified = [...TOOL_CHANNELS, ...BROWSER_PAGE_CHANNELS].sort()
|
||||
|
||||
expect(registered).toEqual(classified)
|
||||
})
|
||||
|
||||
it.each(TOOL_CHANNELS)('drives the preview guest from %s', async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
|
||||
// Why assert on the guest and not on a return value: the whole point of the seam is which
|
||||
// WebContents the tool ends up acting on.
|
||||
const receivedGuest = GUEST_RECEIVING_MOCKS.some((mock) =>
|
||||
mock.mock.calls.some((args) => args.some((arg) => resolvesToGuest(arg, preview.contents)))
|
||||
)
|
||||
expect(receivedGuest || cancelGrabOpMock.mock.calls.length > 0).toBe(true)
|
||||
})
|
||||
|
||||
// The load-bearing containment claim: page and session management never reads the document half
|
||||
// of the registry, so a document page can never be resolved as, or managed like, a browsing one.
|
||||
// Nothing here is a per-channel guard — a document guest is simply not in the map these read.
|
||||
it.each(BROWSER_PAGE_CHANNELS)('never reads the document registry from %s', async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
try {
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: preview.browserPageId,
|
||||
profileId: preview.browserPageId,
|
||||
environmentId: preview.browserPageId
|
||||
})
|
||||
} catch {
|
||||
// A malformed-for-this-channel payload may throw; the claim is about which registry was read.
|
||||
}
|
||||
|
||||
expect(previewAuthoritySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(TOOL_CHANNELS)(
|
||||
'refuses %s from a renderer that does not host the preview',
|
||||
async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(OTHER_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
)
|
||||
|
||||
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
|
||||
expect(mock).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(TOOL_CHANNELS)('refuses %s for a page no preview rendered', async (channel) => {
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, 'doc-page-unrendered'))
|
||||
)
|
||||
|
||||
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
|
||||
expect(mock).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
// Why the contents check has to be its own condition: the grant is still live and the sender is
|
||||
// still the host, so nothing else in the authority notices that the guest is gone.
|
||||
it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
preview.markContentsDestroyed()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
)
|
||||
|
||||
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
|
||||
expect(mock).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
// Why this inverts what the split registries used to assert: a document page now registers under
|
||||
// its own id, so the wait is reachable and useful — a tool opened while the guest is still
|
||||
// attaching parks here instead of answering not-ready at the reader.
|
||||
it('waits for a document page whose guest has not attached yet, and arms when it does', async () => {
|
||||
const grant = grantForNewDocPage()
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
|
||||
const pending = handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: grant.browserPageId,
|
||||
enabled: true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(registrationWaitSpy).toHaveBeenCalledWith(grant.browserPageId)
|
||||
|
||||
const preview = renderPreviewForGrant(grant)
|
||||
|
||||
await expect(pending).resolves.toEqual({ ok: true })
|
||||
expect(setGrabModeMock).toHaveBeenCalledWith(grant.browserPageId, true, preview.contents)
|
||||
})
|
||||
|
||||
// The absence half, with the same presence precondition: a page nothing ever renders still gives
|
||||
// the reader an answer rather than hanging on the full timeout.
|
||||
it('answers not-ready once the wait for an unrendered page elapses', async () => {
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
|
||||
await expect(
|
||||
settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: 'doc-page-never-rendered',
|
||||
enabled: true
|
||||
})
|
||||
)
|
||||
).resolves.toEqual({ ok: false, reason: 'not-ready' })
|
||||
})
|
||||
|
||||
it('still waits for a browser page whose registration may be in flight', async () => {
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: 'browser-page-1',
|
||||
enabled: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(registrationWaitSpy).toHaveBeenCalledWith('browser-page-1')
|
||||
})
|
||||
|
||||
// Why the grant and not the guest: a preview withdraws by revoking, which is also what a
|
||||
// re-mint does, and nothing else tells main that this tool target will never be used again.
|
||||
it('disposes the grab state a preview target accumulated when its grant is revoked', async () => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: preview.browserPageId,
|
||||
enabled: true
|
||||
})
|
||||
cancelGrabOpMock.mockClear()
|
||||
|
||||
revokeDocPreviewGrant(preview.grantId)
|
||||
|
||||
expect(cancelGrabOpMock).toHaveBeenCalledWith(preview.browserPageId, 'evicted')
|
||||
})
|
||||
|
||||
// Why both halves of this door: the manager refuses a preview id on its own, but the grab
|
||||
// disposal beside it takes a renderer-supplied id, and the intent it drops is compared by
|
||||
// identity — dropping it makes the grab settle ok without ever arming the guest.
|
||||
it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handlers = registeredHandlers()
|
||||
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: preview.browserPageId,
|
||||
enabled: true
|
||||
})
|
||||
|
||||
// Why synchronously here: the intent is recorded before the queued operation runs, so this is
|
||||
// the exact window in which a disposal at this door would be invisible to the caller.
|
||||
const unregistered = handlers.get('browser:unregisterGuest')?.(
|
||||
trustedSender(HOST_RENDERER_ID),
|
||||
{ browserPageId: preview.browserPageId }
|
||||
)
|
||||
|
||||
// Why the guest and not the result: a dropped intent settles as ok either way, so only the
|
||||
// guest actually being driven separates an armed grab from a silent no-op.
|
||||
await expect(pending).resolves.toEqual({ ok: true })
|
||||
expect(setGrabModeMock).toHaveBeenCalledWith(preview.browserPageId, true, preview.contents)
|
||||
expect(unregistered).toBe(false)
|
||||
expect(vi.mocked(browserManager.unregisterGuest)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The converse, so the guard above cannot be widened into a door that stops closing tabs.
|
||||
it('still disposes a browser page grab through the same door', async () => {
|
||||
getAuthorizedGuestMock.mockReturnValue({ isDestroyed: () => false })
|
||||
const handlers = registeredHandlers()
|
||||
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: 'browser-page-1',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
const unregistered = handlers.get('browser:unregisterGuest')?.(
|
||||
trustedSender(HOST_RENDERER_ID),
|
||||
{ browserPageId: 'browser-page-1' }
|
||||
)
|
||||
|
||||
expect(unregistered).toBe(true)
|
||||
await expect(pending).resolves.toEqual({ ok: true })
|
||||
expect(setGrabModeMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(browserManager.unregisterGuest)).toHaveBeenCalledWith('browser-page-1')
|
||||
})
|
||||
|
||||
// Why with a live preview standing beside it: the halves are looked up in one door now, so the
|
||||
// thing to prove is that a browsing id is not answered by whatever document happens to be open.
|
||||
it('never hands a browsing page id the guest of a document that is open', async () => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get('browser:extractHoverPayload')
|
||||
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: 'browser-page-1' })
|
||||
|
||||
expect(getAuthorizedGuestMock).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
|
||||
expect(previewAuthoritySpy).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
|
||||
expect(extractHoverPayloadMock).not.toHaveBeenCalled()
|
||||
// The presence half: the same channel does reach that guest under the page it really renders.
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId })
|
||||
expect(extractHoverPayloadMock).toHaveBeenCalledWith(preview.browserPageId, preview.contents)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getWebContentsIdByTabId: vi.fn(() => new Map<string, number>()),
|
||||
getWorktreeIdForTab: vi.fn(() => undefined as string | undefined),
|
||||
getGuestWebContentsId: vi.fn(() => null as number | null),
|
||||
webContentsFromId: vi.fn(() => null as unknown)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ webContents: { fromId: mocks.webContentsFromId } }))
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserManager: {
|
||||
getWebContentsIdByTabId: mocks.getWebContentsIdByTabId,
|
||||
getWorktreeIdForTab: mocks.getWorktreeIdForTab,
|
||||
getGuestWebContentsId: mocks.getGuestWebContentsId
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
waitForAnyTabRegistration,
|
||||
waitForNextTabRegistration,
|
||||
waitForWorktreeTabRegistration
|
||||
} from './browser-tab-registration-wait'
|
||||
import { installDocPreviewGuestPolicy } from '../browser/doc-preview-guest-policy'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
const WAIT_MS = 1_000
|
||||
|
||||
/** Registers a document guest the way the attach door does, which is what notifies the waiters. */
|
||||
function attachDocumentGuest(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: 91, send: vi.fn() })
|
||||
}
|
||||
|
||||
/** Whether a wait has settled, without letting its rejection escape as an unhandled one. */
|
||||
function track(pending: Promise<void>): () => 'pending' | 'resolved' | 'rejected' {
|
||||
let state: 'pending' | 'resolved' | 'rejected' = 'pending'
|
||||
pending.then(
|
||||
() => {
|
||||
state = 'resolved'
|
||||
},
|
||||
() => {
|
||||
state = 'rejected'
|
||||
}
|
||||
)
|
||||
return () => state
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
revokeAllDocPreviewGrants()
|
||||
vi.clearAllMocks()
|
||||
mocks.getWebContentsIdByTabId.mockReturnValue(new Map())
|
||||
mocks.getGuestWebContentsId.mockReturnValue(null)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('a workspace document registering', () => {
|
||||
it('settles a wait that already names its page', async () => {
|
||||
const settled = track(waitForNextTabRegistration('doc-page-1', WAIT_MS))
|
||||
|
||||
attachDocumentGuest('doc-page-1')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(settled()).toBe('resolved')
|
||||
})
|
||||
|
||||
// Why these two stay pending: they are how the CLI and agents ask for a browser tab to drive, and
|
||||
// a preview is neither drivable by them nor visible to them. Satisfying either would hand the
|
||||
// caller a surface that answers nothing, instead of letting it keep waiting for a real tab.
|
||||
it('leaves the waits that ask for any browser tab still waiting', async () => {
|
||||
const worktreeWait = track(waitForWorktreeTabRegistration('wt-1', WAIT_MS))
|
||||
const anyWait = track(waitForAnyTabRegistration(WAIT_MS))
|
||||
|
||||
attachDocumentGuest('doc-page-2')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(worktreeWait()).toBe('pending')
|
||||
expect(anyWait()).toBe('pending')
|
||||
|
||||
// The presence half: those waits do settle, so a mutant that never resolves them would pass the
|
||||
// assertions above by being uniformly stuck.
|
||||
await vi.advanceTimersByTimeAsync(WAIT_MS)
|
||||
expect(worktreeWait()).toBe('rejected')
|
||||
expect(anyWait()).toBe('rejected')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { webContents } from 'electron'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { onWorkspaceDocGuestRegistered } from '../browser/doc-preview-guest-policy'
|
||||
|
||||
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
|
||||
// and calls registerGuest, so the tab has a webContentsId and is operable by
|
||||
@@ -109,6 +110,18 @@ export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise<void> {
|
||||
return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a document page resolves only its own waiters: the worktree-wide and any-tab waits are how
|
||||
* the CLI and agents ask for a browser tab to drive, and a preview is neither drivable by them nor
|
||||
* visible to them. Only a request already naming this page — a tool the reader opened on the
|
||||
* document — is waiting for this.
|
||||
*/
|
||||
onWorkspaceDocGuestRegistered((browserPageId) => {
|
||||
const pendingResolves = pendingTabRegistrations.get(browserPageId)
|
||||
pendingTabRegistrations.delete(browserPageId)
|
||||
resolvePendingRegistrations(pendingResolves)
|
||||
})
|
||||
|
||||
export function resolveTabRegistrationWaiters(browserPageId: string, worktreeId: string): void {
|
||||
const pendingResolves = pendingTabRegistrations.get(browserPageId)
|
||||
pendingTabRegistrations.delete(browserPageId)
|
||||
|
||||
@@ -794,6 +794,8 @@ describe('registerBrowserHandlers', () => {
|
||||
|
||||
it('validates annotation viewport bridge requests before syncing to the guest', async () => {
|
||||
registerBrowserHandlers()
|
||||
const guest = { isDestroyed: () => false } as Electron.WebContents
|
||||
getAuthorizedGuestMock.mockReturnValue(guest)
|
||||
|
||||
const syncHandler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'browser:setAnnotationViewportBridge'
|
||||
@@ -818,12 +820,52 @@ describe('registerBrowserHandlers', () => {
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith('page-1', {
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
})
|
||||
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
{
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
},
|
||||
// Why a resolver and not the guest: the op is serialized per page, so it has to read the
|
||||
// registry when it runs — a navigation while it waited may have swapped the contents.
|
||||
// Which guest it then resolves is pinned in browser-manager-annotation-bridge.test.ts.
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
// Why this is new: the channel used to hand a page id straight to the manager, so any trusted
|
||||
// renderer could drive any page's guest. It now resolves through the same authority the grab
|
||||
// channels use, which pins the request to the renderer that registered the page.
|
||||
it('refuses an annotation viewport bridge request from a renderer that does not own the page', async () => {
|
||||
registerBrowserHandlers()
|
||||
getAuthorizedGuestMock.mockReturnValue(null)
|
||||
|
||||
const syncHandler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'browser:setAnnotationViewportBridge'
|
||||
)?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => Promise<boolean> | boolean
|
||||
|
||||
const result = await syncHandler(
|
||||
{
|
||||
sender: {
|
||||
id: 91,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///renderer/index.html'
|
||||
} as Electron.WebContents
|
||||
},
|
||||
{
|
||||
browserPageId: 'page-1',
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid annotation viewport bridge requests', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ipcMain, webContents } from 'electron'
|
||||
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
import { isWorkspaceDocPageId } from '../browser/doc-preview-guest-policy'
|
||||
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
|
||||
import {
|
||||
isLiveBrowserWebContentsId,
|
||||
@@ -160,6 +161,13 @@ export function registerBrowserHandlers(): void {
|
||||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
return false
|
||||
}
|
||||
// Why the whole door and not just the manager call: a document page shares this renderer, and
|
||||
// the grab disposal below drops the intent an in-flight preview grab compares by identity —
|
||||
// that grab would then answer ok without ever arming. A document page withdraws by revoking
|
||||
// its grant, so its id arriving here is misaddressed however it got here.
|
||||
if (typeof args?.browserPageId !== 'string' || isWorkspaceDocPageId(args.browserPageId)) {
|
||||
return false
|
||||
}
|
||||
// Why: notify bridge before unregistering so it can destroy the session
|
||||
// process and proxy. Must happen before unregisterGuest clears the mapping.
|
||||
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
|
||||
listeners: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
|
||||
isTrustedBrowserRenderer: vi.fn(),
|
||||
reportDocPreviewLinkClick: vi.fn(),
|
||||
getGuestWebContentsId: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
mocks.handlers.set(channel, handler)
|
||||
},
|
||||
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
mocks.listeners.set(channel, listener)
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./browser-renderer-trust', () => ({
|
||||
isTrustedBrowserRenderer: mocks.isTrustedBrowserRenderer
|
||||
}))
|
||||
vi.mock('../browser/doc-preview-guest-policy', () => ({
|
||||
reportDocPreviewLinkClick: mocks.reportDocPreviewLinkClick
|
||||
}))
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserManager: { getGuestWebContentsId: mocks.getGuestWebContentsId }
|
||||
}))
|
||||
|
||||
import {
|
||||
registerDocPreviewGrantHandlers,
|
||||
type DocPreviewGrantRequest
|
||||
} from './doc-preview-grant-ipc'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import {
|
||||
DOC_PREVIEW_LINK_CLICK_CHANNEL,
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
|
||||
parseDocPreviewUrl
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
|
||||
const REQUEST: DocPreviewGrantRequest = {
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'doc-page-1'
|
||||
}
|
||||
|
||||
const sender = { id: 7 }
|
||||
|
||||
function mint(request: DocPreviewGrantRequest = REQUEST): { grantId: string; url: string } {
|
||||
const handler = mocks.handlers.get(DOC_PREVIEW_MINT_GRANT_CHANNEL)
|
||||
if (!handler) {
|
||||
throw new Error('mint handler not registered')
|
||||
}
|
||||
return handler({ sender }, request) as { grantId: string; url: string }
|
||||
}
|
||||
|
||||
function revoke(grantId: string): boolean {
|
||||
const handler = mocks.handlers.get(DOC_PREVIEW_REVOKE_GRANT_CHANNEL)
|
||||
if (!handler) {
|
||||
throw new Error('revoke handler not registered')
|
||||
}
|
||||
return handler({ sender }, grantId) as boolean
|
||||
}
|
||||
|
||||
function reportLinkClick(url: unknown): void {
|
||||
const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL)
|
||||
if (!listener) {
|
||||
throw new Error('link click listener not registered')
|
||||
}
|
||||
listener({ sender }, url)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.handlers.clear()
|
||||
mocks.listeners.clear()
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(true)
|
||||
mocks.getGuestWebContentsId.mockReturnValue(null)
|
||||
registerDocPreviewGrantHandlers()
|
||||
})
|
||||
|
||||
describe('document preview grant handlers', () => {
|
||||
it('mints a grant addressable by the URL it returns', () => {
|
||||
const result = mint()
|
||||
|
||||
expect(parseDocPreviewUrl(result.url)).toEqual({
|
||||
grantId: result.grantId,
|
||||
relativePath: 'index.html'
|
||||
})
|
||||
expect(getDocPreviewGrant(result.grantId)?.root).toBe('/home/alice/docs')
|
||||
expect(mocks.isTrustedBrowserRenderer).toHaveBeenCalledWith(sender)
|
||||
})
|
||||
|
||||
it('revokes a grant it minted', () => {
|
||||
const result = mint()
|
||||
|
||||
expect(revoke(result.grantId)).toBe(true)
|
||||
expect(getDocPreviewGrant(result.grantId)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a request that names no root, entry document or page', () => {
|
||||
expect(() => mint({ ...REQUEST, root: ' ' })).toThrow(/Invalid/)
|
||||
expect(() => mint({ ...REQUEST, entryRelativePath: '' })).toThrow(/Invalid/)
|
||||
expect(() => mint({ ...REQUEST, browserPageId: ' ' })).toThrow(/Invalid/)
|
||||
})
|
||||
|
||||
// Why this is refused here and not left to registration: this is where a page becomes a document
|
||||
// page, and the two halves of the page registry have to stay disjoint. A page already hosting a
|
||||
// browsing guest would otherwise resolve in both, and the tool door prefers the document one.
|
||||
it('refuses to make a page that already hosts a browsing guest into a document page', () => {
|
||||
mocks.getGuestWebContentsId.mockReturnValue(42)
|
||||
|
||||
expect(() => mint()).toThrow(/browsing page/)
|
||||
expect(mocks.getGuestWebContentsId).toHaveBeenCalledWith('doc-page-1')
|
||||
})
|
||||
|
||||
// Why: this channel hands out filesystem-read authority, so an untrusted sender must leave with
|
||||
// nothing rather than with a grant id the scheme handler would honor.
|
||||
it('mints nothing for a sender that is not the trusted renderer', () => {
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
|
||||
|
||||
expect(() => mint()).toThrow(/Untrusted/)
|
||||
})
|
||||
|
||||
it('refuses to revoke on behalf of a sender that is not the trusted renderer', () => {
|
||||
const result = mint()
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
|
||||
|
||||
expect(revoke(result.grantId)).toBe(false)
|
||||
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
|
||||
})
|
||||
|
||||
// Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a
|
||||
// workspace document, which is the untrusted side by construction. The guest policy holds the
|
||||
// gate, so all this listener owes is the sender and a string.
|
||||
it('hands a reported link click to the guest policy with the sender that reported it', () => {
|
||||
reportLinkClick('https://example.com/docs')
|
||||
|
||||
expect(mocks.reportDocPreviewLinkClick).toHaveBeenCalledExactlyOnceWith(
|
||||
sender,
|
||||
'https://example.com/docs'
|
||||
)
|
||||
expect(mocks.isTrustedBrowserRenderer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a reported click that is not a string at all', () => {
|
||||
reportLinkClick({ url: 'https://example.com/docs' })
|
||||
reportLinkClick(undefined)
|
||||
|
||||
expect(mocks.reportDocPreviewLinkClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
buildDocPreviewUrl,
|
||||
DOC_PREVIEW_LINK_CLICK_CHANNEL,
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeDocPreviewGrant,
|
||||
type DocPreviewOwner
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
|
||||
|
||||
export type DocPreviewGrantRequest = {
|
||||
owner: DocPreviewOwner
|
||||
/** Containing directory of the opened document, on the owning host. */
|
||||
root: string
|
||||
/** Opened document, relative to `root`. */
|
||||
entryRelativePath: string
|
||||
/** Browser page the reader is opening the document in; main registers the guest under it. */
|
||||
browserPageId: string
|
||||
}
|
||||
|
||||
export type DocPreviewGrantResult = { grantId: string; url: string }
|
||||
|
||||
function isValidGrantRequest(request: DocPreviewGrantRequest): boolean {
|
||||
if (!request.root.trim() || !request.entryRelativePath.trim()) {
|
||||
return false
|
||||
}
|
||||
if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) {
|
||||
return false
|
||||
}
|
||||
if (request.owner.kind === 'ssh') {
|
||||
return Boolean(request.owner.connectionId.trim())
|
||||
}
|
||||
return Boolean(
|
||||
request.owner.environmentId.trim() &&
|
||||
request.owner.worktreeSelector.trim() &&
|
||||
request.owner.worktreeRoot.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minting never widens what the renderer can already read: an SSH grant reads
|
||||
* through the same provider as `fs:readFile`, and a runtime grant through the
|
||||
* same worktree-scoped `files.read` RPC the renderer can call directly.
|
||||
*/
|
||||
export function registerDocPreviewGrantHandlers(): void {
|
||||
ipcMain.handle(
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
(event, request: DocPreviewGrantRequest): DocPreviewGrantResult => {
|
||||
// Why gate a channel guests cannot reach today: this one hands out filesystem-read
|
||||
// authority, so it holds the same sender check its sibling browser channels do rather than
|
||||
// relying on guests never gaining an ipcRenderer.
|
||||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
throw new Error('Untrusted document preview grant request')
|
||||
}
|
||||
if (!isValidGrantRequest(request)) {
|
||||
throw new Error('Invalid document preview grant request')
|
||||
}
|
||||
// Why the other half of the registry is consulted here: this is where a page first becomes a
|
||||
// document page, and the two halves must stay disjoint. Naming a page that already hosts a
|
||||
// browsing guest would make one id resolve in both.
|
||||
if (browserManager.getGuestWebContentsId(request.browserPageId) !== null) {
|
||||
throw new Error('Document preview grant names a browsing page')
|
||||
}
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: request.owner,
|
||||
root: request.root,
|
||||
entryRelativePath: request.entryRelativePath,
|
||||
browserPageId: request.browserPageId
|
||||
})
|
||||
return {
|
||||
grantId: grant.id,
|
||||
url: buildDocPreviewUrl(grant.id, grant.entryRelativePath)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, (event, grantId: string): boolean =>
|
||||
isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false
|
||||
)
|
||||
|
||||
// Why no trusted-renderer check here: the sender is a preview guest rendering a workspace
|
||||
// document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a
|
||||
// live bound grant, a focused guest, a web URL — and it drops everything else silently.
|
||||
ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => {
|
||||
if (typeof url === 'string') {
|
||||
reportDocPreviewLinkClick(event.sender, url)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
|
||||
attachGuestPolicies: vi.fn(),
|
||||
installNavigationPolicy: vi.fn(),
|
||||
isAllowedPartition: vi.fn(),
|
||||
attachRouteGuest: vi.fn(),
|
||||
registerPluginGuard: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -20,20 +21,61 @@ vi.mock('../plugins/plugin-panel-navigation-guard', () => ({
|
||||
vi.mock('./privileged-window-navigation', () => ({
|
||||
installPrivilegedWindowNavigationPolicy: mocks.installNavigationPolicy
|
||||
}))
|
||||
vi.mock('../browser/browser-route-session-runtime', () => ({
|
||||
browserRouteSessionRegistry: { isAllowedPartition: () => false },
|
||||
browserRouteWebContentsRegistry: { attachGuest: mocks.attachRouteGuest }
|
||||
}))
|
||||
vi.mock('../browser/local-ssh-browser-partitions', () => ({
|
||||
isLocalSshBrowserPartition: () => false,
|
||||
enforceLocalSshWebRtcPolicyForGuest: vi.fn()
|
||||
}))
|
||||
vi.mock('../browser/doc-preview-protocol', () => ({
|
||||
isDocPreviewSession: (candidate: unknown) => candidate === 'doc-preview-session'
|
||||
}))
|
||||
|
||||
import { installMainWindowWebviewSecurity } from './main-window-webview-security'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import {
|
||||
publishDocPreviewFailure,
|
||||
setDocPreviewFailureSink
|
||||
} from '../browser/doc-preview-failure-notice'
|
||||
import { buildDocPreviewUrl, DOC_PREVIEW_PARTITION } from '../../shared/doc-preview-scheme'
|
||||
|
||||
function installOnFakeWindow(): {
|
||||
handlers: Record<string, (...args: never[]) => void>
|
||||
webContents: { on: ReturnType<typeof vi.fn> }
|
||||
} {
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
|
||||
handlers[event] = handler
|
||||
})
|
||||
}
|
||||
installMainWindowWebviewSecurity({ webContents } as never)
|
||||
return { handlers, webContents }
|
||||
}
|
||||
|
||||
function mintPreviewGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
describe('main window webview security', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
it('fails closed before applying hardened guest preferences', () => {
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
|
||||
handlers[event] = handler
|
||||
})
|
||||
}
|
||||
installMainWindowWebviewSecurity({ webContents } as never)
|
||||
const { handlers, webContents } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
@@ -49,13 +91,7 @@ describe('main window webview security', () => {
|
||||
})
|
||||
|
||||
it('removes renderer preload input and restores every hardened preference', () => {
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
|
||||
handlers[event] = handler
|
||||
})
|
||||
}
|
||||
installMainWindowWebviewSecurity({ webContents } as never)
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(true)
|
||||
const params = { src: 'https://example.com', preload: 'attacker.js' }
|
||||
const preferences: Record<string, unknown> = {
|
||||
@@ -85,3 +121,198 @@ describe('main window webview security', () => {
|
||||
expect(String(preferences.preload)).toMatch(/browser-window-close-preload\.js$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca-preview scheme admission', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
it('admits a preview URL only on the preview partition and only with a live grant', () => {
|
||||
// Install first, as the window does: installation itself clears the registry.
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
const preferences: Record<string, unknown> = {
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
preload: 'attacker.js',
|
||||
sandbox: false
|
||||
}
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
preferences as never,
|
||||
{ src: buildDocPreviewUrl(grant.id, 'index.html'), preload: 'attacker.js' } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(preferences).toMatchObject({
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
})
|
||||
expect(preferences).not.toHaveProperty('preloadURL')
|
||||
})
|
||||
|
||||
// Why both directions: the preview preload is the only script that can turn a press into a
|
||||
// browser tab, so a browsing guest must never receive it — and a preview must never receive
|
||||
// anything else, least of all a value the renderer supplied.
|
||||
it('pins the preview preload onto a preview attach, replacing whatever the renderer asked for', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const params = { src: buildDocPreviewUrl(grant.id, 'index.html'), preload: 'attacker.js' }
|
||||
const preferences: Record<string, unknown> = {
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
preload: 'attacker.js'
|
||||
}
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
preferences as never,
|
||||
params as never
|
||||
)
|
||||
|
||||
expect(params).not.toHaveProperty('preload')
|
||||
expect(String(preferences.preload)).toMatch(/doc-preview-link-preload\.js$/)
|
||||
})
|
||||
|
||||
it('keeps the preview preload off a browsing attach', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(true)
|
||||
const preferences: Record<string, unknown> = { partition: 'persist:orca-browser' }
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
preferences as never,
|
||||
{ src: 'https://example.com' } as never
|
||||
)
|
||||
|
||||
expect(String(preferences.preload)).toMatch(/browser-window-close-preload\.js$/)
|
||||
})
|
||||
|
||||
it('denies a preview URL whose grant is unknown or revoked', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
{ partition: DOC_PREVIEW_PARTITION } as never,
|
||||
{ src: `orca-preview://${'0'.repeat(32)}/index.html` } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('denies a preview URL smuggled onto a browsing partition', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
// Even an allowlisted browsing partition must not load the preview scheme.
|
||||
mocks.isAllowedPartition.mockReturnValue(true)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
{ partition: 'persist:orca-browser' } as never,
|
||||
{ src: buildDocPreviewUrl(grant.id, 'index.html') } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('denies a web URL on the preview partition', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
{ partition: DOC_PREVIEW_PARTITION } as never,
|
||||
{ src: 'https://example.com' } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Why the host is asserted and not just the profile: it is the renderer that minted the grant,
|
||||
// and it is the only sink a link the reader presses can be reported to. A preview attached
|
||||
// against another window's contents would report its clicks to a reader who is not there.
|
||||
it('attaches a preview guest under the workspace-doc profile, hosted by this window', () => {
|
||||
const { handlers, webContents } = installOnFakeWindow()
|
||||
|
||||
handlers['did-attach-webview']?.({} as never, { session: 'doc-preview-session' } as never)
|
||||
|
||||
expect(mocks.attachGuestPolicies).toHaveBeenCalledWith(
|
||||
{ session: 'doc-preview-session' },
|
||||
null,
|
||||
{
|
||||
profile: 'workspace-doc',
|
||||
host: webContents
|
||||
}
|
||||
)
|
||||
expect(mocks.attachRouteGuest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps browser guests on the browsing profile and its route registration', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
|
||||
handlers['did-attach-webview']?.({} as never, { session: 'browser-session' } as never)
|
||||
|
||||
expect(mocks.attachGuestPolicies).toHaveBeenCalledOnce()
|
||||
expect(mocks.attachGuestPolicies.mock.calls[0]?.[2]).toBeUndefined()
|
||||
expect(mocks.attachRouteGuest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Why: every live preview belongs to this window, so its teardown is the one moment no grant can
|
||||
// still have a reader — and the failure sink must stop pointing at dead WebContents.
|
||||
it('drops the failure sink and every grant when the window contents are destroyed', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
expect(getDocPreviewGrant(grant.id)).not.toBeNull()
|
||||
|
||||
handlers['destroyed']?.()
|
||||
publishDocPreviewFailure({
|
||||
grantId: grant.id,
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
|
||||
expect(getDocPreviewGrant(grant.id)).toBeNull()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the renderer is the only side that remembers which preview owns which grant, so a grant
|
||||
// that outlives its renderer is a read authority nobody can release.
|
||||
it('clears grants a previous renderer left behind when the window is created', () => {
|
||||
const stranded = mintPreviewGrant()
|
||||
|
||||
installOnFakeWindow()
|
||||
|
||||
expect(getDocPreviewGrant(stranded.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('clears grants the renderer forgot across a reload', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
|
||||
handlers['did-start-navigation']?.({ isMainFrame: true, isSameDocument: false } as never)
|
||||
|
||||
expect(getDocPreviewGrant(grant.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps grants across an in-document or subframe navigation, which keeps the renderer', () => {
|
||||
const { handlers } = installOnFakeWindow()
|
||||
const grant = mintPreviewGrant()
|
||||
|
||||
handlers['did-start-navigation']?.({ isMainFrame: true, isSameDocument: true } as never)
|
||||
handlers['did-start-navigation']?.({ isMainFrame: false, isSameDocument: false } as never)
|
||||
|
||||
expect(getDocPreviewGrant(grant.id)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,16 +13,56 @@ import {
|
||||
browserRouteWebContentsRegistry
|
||||
} from '../browser/browser-route-session-runtime'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants'
|
||||
import { DOC_PREVIEW_PARTITION, parseDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
import { setDocPreviewFailureSink } from '../browser/doc-preview-failure-notice'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import { isDocPreviewSession } from '../browser/doc-preview-protocol'
|
||||
import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard'
|
||||
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
|
||||
|
||||
/**
|
||||
* Why a separate admission rule: `normalizeBrowserNavigationUrl` answers only for
|
||||
* http(s) and `file:`, so `orca-preview://` can only ever attach here — and only
|
||||
* on the doc-preview partition, carrying a grant the main process minted for a
|
||||
* deliberate user preview action. Web content has no way to reach either.
|
||||
*/
|
||||
function isAdmissibleDocPreviewAttach(partition: string, src: string): boolean {
|
||||
if (partition !== DOC_PREVIEW_PARTITION) {
|
||||
return false
|
||||
}
|
||||
const target = parseDocPreviewUrl(src)
|
||||
return target !== null && getDocPreviewGrant(target.grantId) !== null
|
||||
}
|
||||
|
||||
export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): void {
|
||||
installPrivilegedWindowNavigationPolicy(mainWindow.webContents)
|
||||
// Why here and on every fresh shell document: the renderer holds the only record of which
|
||||
// preview owns which grant, and a reload throws that record away. Grants it can no longer
|
||||
// release would stay live read authorities for the rest of the process.
|
||||
revokeAllDocPreviewGrants()
|
||||
mainWindow.webContents.on('did-start-navigation', (details) => {
|
||||
if (details.isMainFrame && !details.isSameDocument) {
|
||||
revokeAllDocPreviewGrants()
|
||||
}
|
||||
})
|
||||
// Why these contents and not the window: every live preview is a guest of this WebContents, and
|
||||
// it is also the failure sink itself — once it is destroyed no grant it minted has a reader left.
|
||||
mainWindow.webContents.on('destroyed', () => {
|
||||
setDocPreviewFailureSink(null)
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
// Why: containment must be listening before any plugin panel frame is created,
|
||||
// so register it with the window's other navigation policy.
|
||||
registerPluginPanelNavigationGuard(mainWindow.webContents)
|
||||
|
||||
const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js')
|
||||
// Why a preview gets a preload at all: it is our own editor surface, not a browsing guest. This
|
||||
// one only decides what a click on a link means, and it is pinned here so no renderer-supplied
|
||||
// value can reach a preview guest and no other attach path can acquire it.
|
||||
const docPreviewLinkPreload = join(__dirname, 'doc-preview-link-preload.js')
|
||||
mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => {
|
||||
const src = typeof params.src === 'string' ? params.src : ''
|
||||
const normalizedSrc = normalizeBrowserNavigationUrl(src)
|
||||
@@ -33,12 +73,14 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
|
||||
// so admission here can never race an unproxied session. They navigate like
|
||||
// profile partitions — the renderer owns their URLs, no main-side grants.
|
||||
const isLocalSshPartition = isLocalSshBrowserPartition(partition)
|
||||
const isDocPreviewAttach = isAdmissibleDocPreviewAttach(partition, src)
|
||||
|
||||
// Why: fail closed — deny any src or partition not in the registry allowlist so a renderer bug can't smuggle preload/Node into an unprivileged guest.
|
||||
if (
|
||||
!normalizedSrc ||
|
||||
(!isProfilePartition && !isRoutePartition && !isLocalSshPartition) ||
|
||||
(isRoutePartition && normalizedSrc !== ORCA_BROWSER_BLANK_URL)
|
||||
!isDocPreviewAttach &&
|
||||
(!normalizedSrc ||
|
||||
(!isProfilePartition && !isRoutePartition && !isLocalSshPartition) ||
|
||||
(isRoutePartition && normalizedSrc !== ORCA_BROWSER_BLANK_URL))
|
||||
) {
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -46,7 +88,7 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
|
||||
|
||||
delete params.preload
|
||||
// Why: preload runs in the page's main world before inline scripts can call window.close().
|
||||
webPreferences.preload = browserWindowClosePreload
|
||||
webPreferences.preload = isDocPreviewAttach ? docPreviewLinkPreload : browserWindowClosePreload
|
||||
// Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge.
|
||||
delete (webPreferences as Record<string, unknown>).preloadURL
|
||||
// Why delete something Electron does not set: 43 does not pass the embedder's
|
||||
@@ -68,6 +110,17 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('did-attach-webview', (_event, guest) => {
|
||||
if (isDocPreviewSession(guest.session)) {
|
||||
// Why: preview guests never join browser-tab routing, popups or anti-detection; the
|
||||
// workspace-doc profile is what refuses all three. The attach is also the point a live window
|
||||
// exists to receive read failures for that guest.
|
||||
setDocPreviewFailureSink(mainWindow.webContents)
|
||||
browserManager.attachGuestPolicies(guest, null, {
|
||||
profile: 'workspace-doc',
|
||||
host: mainWindow.webContents
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: attach guest popup/nav policy at creation; waiting for renderer registration races target=_blank/early redirects past it.
|
||||
browserManager.attachGuestPolicies(guest)
|
||||
// Why: route guests override the generic popup fallback and stay blank until exact main-owned registration.
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { BrowserApi } from './api/browser-api'
|
||||
import type { CliApi } from './api/cli-install-api'
|
||||
import type { CrashReportsApi, FeedbackApi } from './api/crash-report-api'
|
||||
import type { DashboardApi, TerminalPreviewApi } from './api/dashboard-api'
|
||||
import type { DocPreviewApi } from './api/doc-preview-api'
|
||||
import type { EmulatorApi } from './api/emulator-api'
|
||||
import type { EphemeralVmApi } from './api/ephemeral-vm-api'
|
||||
import type { ExportApi, FilesystemApi } from './api/filesystem-api'
|
||||
@@ -125,6 +126,7 @@ export type PreloadApi = {
|
||||
remoteWorkspace: WorkspaceSessionApi['remoteWorkspace']
|
||||
updater: UpdaterApi
|
||||
notebook: FilesystemApi['notebook']
|
||||
docPreview: DocPreviewApi['docPreview']
|
||||
stats: StatsApi
|
||||
memory: MemoryApi
|
||||
claudeUsage: ClaudeUsageApi
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { DocPreviewFailure } from '../../shared/doc-preview-scheme'
|
||||
|
||||
export type DocPreviewGrantOwner =
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| {
|
||||
kind: 'runtime'
|
||||
environmentId: string
|
||||
worktreeSelector: string
|
||||
worktreeRoot: string
|
||||
}
|
||||
|
||||
export type DocPreviewGrantRequest = {
|
||||
owner: DocPreviewGrantOwner
|
||||
root: string
|
||||
entryRelativePath: string
|
||||
/** Browser page the document is being opened in; main registers its guest under this id. */
|
||||
browserPageId: string
|
||||
}
|
||||
|
||||
export type DocPreviewApi = {
|
||||
docPreview: {
|
||||
mintGrant: (request: DocPreviewGrantRequest) => Promise<{ grantId: string; url: string }>
|
||||
revokeGrant: (grantId: string) => Promise<boolean>
|
||||
/** External link the preview guest tried to open; the renderer turns it into a browser tab. */
|
||||
onExternalLink: (callback: (payload: { url: string }) => void) => () => void
|
||||
/** Why the guest is showing an error body instead of the document. */
|
||||
onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => () => void
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DOC_PREVIEW_LINK_CLICK_CHANNEL } from '../shared/doc-preview-scheme'
|
||||
import {
|
||||
handleDocPreviewLinkAuxClick,
|
||||
handleDocPreviewLinkClick,
|
||||
installDocPreviewLinkInterception,
|
||||
PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL
|
||||
} from './doc-preview-link-interception'
|
||||
|
||||
const PREVIEW_GRANT_ID = 'a'.repeat(32)
|
||||
|
||||
let report: ReturnType<typeof vi.fn<(url: string) => void>>
|
||||
|
||||
/** The URL the guest is actually on, which is what decides whether an href is a pure fragment. */
|
||||
function documentUrl(): string {
|
||||
return window.location.href
|
||||
}
|
||||
|
||||
function loadPreviewDocument(body: string): void {
|
||||
document.body.innerHTML = body
|
||||
report = vi.fn<(url: string) => void>()
|
||||
}
|
||||
|
||||
/**
|
||||
* An SVG anchor around a hit target, built rather than parsed: the href is an SVGAnimatedString
|
||||
* carrying the raw attribute, which is what a plain string read misses and the environment does
|
||||
* not model.
|
||||
*/
|
||||
function loadSvgAnchorDocument(href: string): void {
|
||||
loadPreviewDocument('<div id="host"></div>')
|
||||
const svgNamespace = 'http://www.w3.org/2000/svg'
|
||||
const anchor = document.createElementNS(svgNamespace, 'a')
|
||||
anchor.setAttribute('href', href)
|
||||
Object.defineProperty(anchor, 'href', { value: { baseVal: href } })
|
||||
const hit = document.createElementNS(svgNamespace, 'rect')
|
||||
hit.setAttribute('id', 'svg-hit')
|
||||
anchor.appendChild(hit)
|
||||
const svg = document.createElementNS(svgNamespace, 'svg')
|
||||
svg.appendChild(anchor)
|
||||
document.getElementById('host')!.appendChild(svg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches through the same capture-phase listener the preload installs, but registered for one
|
||||
* press only — a document-level listener left behind would answer every later test's clicks too.
|
||||
*/
|
||||
function dispatch(selector: string, event: MouseEvent): MouseEvent {
|
||||
const onClick = (candidate: Event): void => handleDocPreviewLinkClick(candidate, report)
|
||||
const onAuxClick = (candidate: Event): void =>
|
||||
handleDocPreviewLinkAuxClick(candidate as MouseEvent)
|
||||
document.addEventListener('click', onClick, true)
|
||||
document.addEventListener('auxclick', onAuxClick, true)
|
||||
try {
|
||||
document.querySelector(selector)!.dispatchEvent(event)
|
||||
} finally {
|
||||
document.removeEventListener('click', onClick, true)
|
||||
document.removeEventListener('auxclick', onAuxClick, true)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/** Chromium's own press. Nothing a document can dispatch carries this. */
|
||||
function pressTrusted(selector: string, init: MouseEventInit = {}): MouseEvent {
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...init })
|
||||
Object.defineProperty(event, 'isTrusted', { configurable: true, value: true })
|
||||
return dispatch(selector, event)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Why: the module decides "sibling document" and "pure fragment" against the URL the guest is
|
||||
// really on, which in a preview is always the preview scheme.
|
||||
;(window as never as { happyDOM: { setURL: (url: string) => void } }).happyDOM.setURL(
|
||||
`orca-preview://${PREVIEW_GRANT_ID}/index.html`
|
||||
)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('doc preview link interception', () => {
|
||||
it('sends on exactly the channel main listens on', () => {
|
||||
expect(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL).toBe(DOC_PREVIEW_LINK_CLICK_CHANNEL)
|
||||
})
|
||||
|
||||
it('listens for both press kinds in the capture phase, so the document cannot consume them first', () => {
|
||||
// Why not let it register: a document-level listener would survive into every later press here.
|
||||
const addEventListener = vi.spyOn(document, 'addEventListener').mockImplementation(() => {})
|
||||
|
||||
installDocPreviewLinkInterception(vi.fn())
|
||||
|
||||
expect(addEventListener.mock.calls.map(([type, , options]) => [type, options])).toEqual([
|
||||
['click', true],
|
||||
['auxclick', true]
|
||||
])
|
||||
})
|
||||
|
||||
// Why the headline: this is the whole point of the design. A document that can read its grant
|
||||
// must not be able to hand it to a browser tab by synthesizing the reader's click.
|
||||
it('routes nothing for a click the document dispatched itself', () => {
|
||||
loadPreviewDocument('<a id="external" href="https://attacker.test/?d=secret">go</a>')
|
||||
|
||||
const scripted = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
// Why set it: this is what Chromium stamps on anything a document dispatches, and the test
|
||||
// environment leaves the property off entirely.
|
||||
Object.defineProperty(scripted, 'isTrusted', { configurable: true, value: false })
|
||||
|
||||
const event = dispatch('#external', scripted)
|
||||
|
||||
expect(event.isTrusted).toBe(false)
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a trusted press on an external anchor exactly once and stops the navigation', () => {
|
||||
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
|
||||
|
||||
const event = pressTrusted('#external')
|
||||
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/docs')
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('reports the anchor even when the press landed on what it wraps', () => {
|
||||
loadPreviewDocument('<a href="https://example.com/docs"><span id="inner">go</span></a>')
|
||||
|
||||
pressTrusted('#inner')
|
||||
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/docs')
|
||||
})
|
||||
|
||||
// Why: an SVG anchor's href is an SVGAnimatedString, so reading it as a string finds nothing and
|
||||
// the link would fall through to the guest's navigation guard and die there.
|
||||
it('reports an SVG anchor by its animated href', () => {
|
||||
loadSvgAnchorDocument('https://example.com/chart')
|
||||
|
||||
pressTrusted('#svg-hit')
|
||||
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/chart')
|
||||
})
|
||||
|
||||
// Why these three: baseVal is the raw attribute, so an unresolved read sends a relative sibling
|
||||
// out to a browser tab, drops SVG fragment links on the floor, and turns a rooted path into a
|
||||
// file URL the guest refuses — each one a divergence from the identical HTML anchor.
|
||||
it('leaves a relative SVG link to the guest, the same as the HTML anchor beside it', () => {
|
||||
loadSvgAnchorDocument('guide.html')
|
||||
|
||||
const event = pressTrusted('#svg-hit')
|
||||
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('scrolls to an SVG fragment target instead of routing it', () => {
|
||||
loadSvgAnchorDocument('#section-2')
|
||||
const target = document.createElement('h2')
|
||||
target.id = 'section-2'
|
||||
document.body.appendChild(target)
|
||||
const scrollIntoView = vi.spyOn(target, 'scrollIntoView')
|
||||
|
||||
const event = pressTrusted('#svg-hit')
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledOnce()
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('scrolls to the top for a bare # on an SVG anchor', () => {
|
||||
loadSvgAnchorDocument('#')
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
|
||||
pressTrusted('#svg-hit')
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a rooted SVG link inside the preview rather than reading it as a filesystem path', () => {
|
||||
loadSvgAnchorDocument('/assets/a.html')
|
||||
|
||||
const event = pressTrusted('#svg-hit')
|
||||
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a sibling preview document to the guest, which its policy already permits', () => {
|
||||
loadPreviewDocument(
|
||||
`<a id="sibling" href="orca-preview://${PREVIEW_GRANT_ID}/guide.html">guide</a>`
|
||||
)
|
||||
|
||||
const event = pressTrusted('#sibling')
|
||||
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('scrolls to a fragment target in the document instead of routing it', () => {
|
||||
loadPreviewDocument(
|
||||
`<a id="jump" href="${documentUrl()}#section">jump</a><h2 id="section">s</h2>`
|
||||
)
|
||||
const scrollIntoView = vi.spyOn(document.getElementById('section')!, 'scrollIntoView')
|
||||
|
||||
const event = pressTrusted('#jump')
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledOnce()
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('finds a fragment target whose id the href percent-encoded', () => {
|
||||
loadPreviewDocument(`<a id="jump" href="${documentUrl()}#a%20b">jump</a><h2 id="a b">s</h2>`)
|
||||
const scrollIntoView = vi.spyOn(document.getElementById('a b')!, 'scrollIntoView')
|
||||
|
||||
pressTrusted('#jump')
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledOnce()
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scrolls to the top for a bare # and for #top with nothing carrying that id', () => {
|
||||
loadPreviewDocument(
|
||||
`<a id="hash" href="#">top</a><a id="named" href="${documentUrl()}#top">top</a>`
|
||||
)
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
|
||||
pressTrusted('#hash')
|
||||
pressTrusted('#named')
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledTimes(2)
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a fragment href that belongs to another document', () => {
|
||||
loadPreviewDocument('<a id="other" href="https://example.com/page#section">go</a>')
|
||||
|
||||
pressTrusted('#other')
|
||||
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/page#section')
|
||||
})
|
||||
|
||||
it('opens nothing for a middle click on a link', () => {
|
||||
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
|
||||
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
|
||||
Object.defineProperty(event, 'isTrusted', { configurable: true, value: true })
|
||||
|
||||
dispatch('#external', event)
|
||||
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a middle click the document dispatched itself alone', () => {
|
||||
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
|
||||
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
|
||||
Object.defineProperty(event, 'isTrusted', { configurable: true, value: false })
|
||||
|
||||
dispatch('#external', event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a press that is not on a link at all', () => {
|
||||
loadPreviewDocument('<p id="text">nothing here</p>')
|
||||
|
||||
const event = pressTrusted('#text')
|
||||
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Decides what a click inside a preview guest means. This is the only way a URL leaves the
|
||||
* preview, so it answers for a click the reader really made: navigation the document starts by
|
||||
* itself never reaches here, and the guest's navigation policy refuses it outright.
|
||||
*
|
||||
* Runs in the guest's isolated world and exposes nothing to page script — the document cannot see
|
||||
* these listeners, remove them, or call what they call.
|
||||
*/
|
||||
export type DocPreviewExternalLinkReporter = (url: string) => void
|
||||
|
||||
/**
|
||||
* Why the channel name is written out rather than imported: this module is bundled into a
|
||||
* sandboxed preload, whose `require` resolves only 'electron', so an import of shared code emits a
|
||||
* chunk require the guest cannot load. The test pins it to the constant main listens on.
|
||||
*/
|
||||
export const PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL = 'docPreview:linkClick'
|
||||
|
||||
/** Why not `instanceof HTMLAnchorElement`: an SVG `<a>` is an anchor too, and carries an SVGAnimatedString href. */
|
||||
function readAnchorHref(node: EventTarget): string | null {
|
||||
const element = node as { tagName?: unknown; href?: unknown }
|
||||
if (typeof element.tagName !== 'string' || element.tagName.toUpperCase() !== 'A') {
|
||||
return null
|
||||
}
|
||||
if (typeof element.href === 'string') {
|
||||
return element.href.length > 0 ? element.href : null
|
||||
}
|
||||
const baseVal = (element.href as { baseVal?: unknown } | null | undefined)?.baseVal
|
||||
if (typeof baseVal !== 'string' || baseVal.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why resolved here: baseVal is the raw attribute, unlike an HTML anchor's absolute href, so
|
||||
// every branch below would read an SVG link differently from the identical HTML one.
|
||||
try {
|
||||
return new URL(baseVal, window.location.href).toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Why the composed path and not `event.target`: the press lands on whatever the anchor wraps, shadow roots included. */
|
||||
function findClickedAnchor(event: Event): { element: EventTarget; href: string } | null {
|
||||
for (const node of event.composedPath()) {
|
||||
const href = readAnchorHref(node)
|
||||
if (href !== null) {
|
||||
return { element: node, href }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function scrollToDocumentTop(): void {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
function readAttribute(element: EventTarget, name: string): string | null {
|
||||
const candidate = element as { getAttribute?: (attribute: string) => string | null }
|
||||
return typeof candidate.getAttribute === 'function' ? candidate.getAttribute(name) : null
|
||||
}
|
||||
|
||||
function scrollToFragment(fragment: string): void {
|
||||
let target = document.getElementById(fragment)
|
||||
if (!target) {
|
||||
try {
|
||||
target = document.getElementById(decodeURIComponent(fragment))
|
||||
} catch {
|
||||
target = null
|
||||
}
|
||||
}
|
||||
if (target) {
|
||||
target.scrollIntoView()
|
||||
return
|
||||
}
|
||||
// Why: `#top` names the top of the document even when nothing carries that id.
|
||||
if (fragment === 'top') {
|
||||
scrollToDocumentTop()
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the href only moves within the document already on screen, which this handles itself. */
|
||||
function handleInDocumentFragment(element: EventTarget, href: string): boolean {
|
||||
if (readAttribute(element, 'href') === '#') {
|
||||
scrollToDocumentTop()
|
||||
return true
|
||||
}
|
||||
const hashIndex = href.indexOf('#')
|
||||
if (hashIndex === -1) {
|
||||
return false
|
||||
}
|
||||
const currentHref = window.location.href
|
||||
const currentHashIndex = currentHref.indexOf('#')
|
||||
const currentBase = currentHashIndex === -1 ? currentHref : currentHref.slice(0, currentHashIndex)
|
||||
if (href.slice(0, hashIndex) !== currentBase) {
|
||||
return false
|
||||
}
|
||||
scrollToFragment(href.slice(hashIndex + 1))
|
||||
return true
|
||||
}
|
||||
|
||||
function isSameSchemeAsDocument(href: string): boolean {
|
||||
try {
|
||||
return new URL(href).protocol === window.location.protocol
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function handleDocPreviewLinkClick(
|
||||
event: Event,
|
||||
report: DocPreviewExternalLinkReporter
|
||||
): void {
|
||||
// Why first: a click the page dispatched is the document asking to leave, not the reader.
|
||||
if (!event.isTrusted) {
|
||||
return
|
||||
}
|
||||
const anchor = findClickedAnchor(event)
|
||||
if (!anchor) {
|
||||
return
|
||||
}
|
||||
if (handleInDocumentFragment(anchor.element, anchor.href)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
// Why left alone: a link served the way this document was is a sibling preview document, which
|
||||
// the guest policy already answers for — it navigates natively and the preview keeps its history.
|
||||
if (isSameSchemeAsDocument(anchor.href)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
report(anchor.href)
|
||||
}
|
||||
|
||||
export function handleDocPreviewLinkAuxClick(event: MouseEvent): void {
|
||||
if (!event.isTrusted || event.button !== 1) {
|
||||
return
|
||||
}
|
||||
if (!findClickedAnchor(event)) {
|
||||
return
|
||||
}
|
||||
// Why swallowed rather than routed: a middle click asks for a background tab, and honouring it
|
||||
// would let one press the reader barely registered open a browser tab.
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
export function installDocPreviewLinkInterception(report: DocPreviewExternalLinkReporter): void {
|
||||
// Why capture: the document's own handlers must not be able to consume the press first.
|
||||
document.addEventListener('click', (event) => handleDocPreviewLinkClick(event, report), true)
|
||||
document.addEventListener(
|
||||
'auxclick',
|
||||
(event) => handleDocPreviewLinkAuxClick(event as MouseEvent),
|
||||
true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
installDocPreviewLinkInterception,
|
||||
PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL
|
||||
} from './doc-preview-link-interception'
|
||||
|
||||
// Why: raw require keeps the sandboxed preload standalone in the main-process CJS build.
|
||||
const { ipcRenderer } = require('electron') as {
|
||||
ipcRenderer: { send: (channel: string, ...args: unknown[]) => void }
|
||||
}
|
||||
|
||||
// Why no contextBridge: the document must not be able to call this. The listeners live in the
|
||||
// isolated world, and main still refuses any report that does not come from a focused preview guest.
|
||||
installDocPreviewLinkInterception((url) => {
|
||||
ipcRenderer.send(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL, url)
|
||||
})
|
||||
@@ -8,6 +8,14 @@ import type {
|
||||
SkillDeleteRequest,
|
||||
SkillDeleteResult
|
||||
} from '../shared/skill-delete-contract'
|
||||
import {
|
||||
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
|
||||
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
|
||||
type DocPreviewFailure
|
||||
} from '../shared/doc-preview-scheme'
|
||||
import type { DocPreviewGrantRequest } from './api/doc-preview-api'
|
||||
import type { AppIdentity } from '../shared/app-identity'
|
||||
import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys'
|
||||
import type { ComputerAwakeStatus } from '../shared/computer-awake-mode'
|
||||
@@ -3322,6 +3330,25 @@ const api = {
|
||||
}
|
||||
} satisfies PreloadApi['updater'],
|
||||
|
||||
docPreview: {
|
||||
mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> =>
|
||||
ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request),
|
||||
revokeGrant: (grantId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId),
|
||||
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void =>
|
||||
callback(payload)
|
||||
ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener)
|
||||
return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener)
|
||||
},
|
||||
onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void =>
|
||||
callback(payload)
|
||||
ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
|
||||
return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
|
||||
}
|
||||
},
|
||||
|
||||
notebook: {
|
||||
runPythonCell: (args: {
|
||||
filePath: string
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ClientHostedBrowserUnavailableNotice } from './client-hosted-browser-un
|
||||
import { useRestoredClientHostedRecoveryWindow } from './restored-client-hosted-recovery-window'
|
||||
import BrowserFind from './assemble-chrome/BrowserFind'
|
||||
import { BrowserNavigationControlRow } from './assemble-chrome/browser-navigation-control-row'
|
||||
import BrowserAddressBar from './assemble-chrome/BrowserAddressBar'
|
||||
import { BrowserPageContextMenu } from './assemble-chrome/browser-page-context-menu'
|
||||
import { useBrowserPageChromeFocus } from './assemble-chrome/use-browser-page-chrome-focus'
|
||||
import { useBrowserAddressBarEditSession } from './assemble-chrome/use-browser-address-bar-edit-session'
|
||||
@@ -165,7 +166,7 @@ export function ClientHostedBrowserPagePane({
|
||||
|
||||
const navigateToUrl = useCallback(
|
||||
(value: string) => {
|
||||
const submission = resolveBrowserAddressBarSubmission(value)
|
||||
const submission = resolveBrowserAddressBarSubmission(value, { allowFileUrls: false })
|
||||
if (submission.status === 'invalid') {
|
||||
onUpdatePageState(browserTab.id, { loadError: submission.loadError })
|
||||
return
|
||||
@@ -367,18 +368,23 @@ export function ClientHostedBrowserPagePane({
|
||||
reload: () => reload.runReloadTrigger('button'),
|
||||
navigate: navigateToUrl
|
||||
}}
|
||||
addressBarValue={addressBarValue}
|
||||
onAddressBarChange={setAddressBarValue}
|
||||
onSubmitAddressBar={() => navigateToUrl(addressBarValue)}
|
||||
addressBarInputRef={addressBarInputRef}
|
||||
addressBarEditSession={addressBarEditSession}
|
||||
reloadLabel={reload.reloadButtonLabel}
|
||||
addressBarLeadingIcon={
|
||||
<RemoteRuntimeEgressIndicator
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
presentation="client-hosted"
|
||||
addressSlot={
|
||||
<BrowserAddressBar
|
||||
value={addressBarValue}
|
||||
onChange={setAddressBarValue}
|
||||
onSubmit={() => navigateToUrl(addressBarValue)}
|
||||
onNavigate={navigateToUrl}
|
||||
inputRef={addressBarInputRef}
|
||||
editSession={addressBarEditSession}
|
||||
leadingIcon={
|
||||
<RemoteRuntimeEgressIndicator
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
presentation="client-hosted"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
reloadLabel={reload.reloadButtonLabel}
|
||||
/>
|
||||
</div>
|
||||
<div ref={viewportRef} className="relative min-h-0 flex-1 overflow-hidden bg-background">
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import type { MutableRefObject, RefObject } from 'react'
|
||||
import { Copy, Image } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { MarkupOverlay } from './MarkupOverlay'
|
||||
import type { MarkupModeController } from './useMarkupMode'
|
||||
import type { GrabModeHook } from './useGrabMode'
|
||||
import {
|
||||
getBrowserOverlayAnchor,
|
||||
type BrowserOverlayViewport
|
||||
} from '../describe-page/browser-annotation-geometry'
|
||||
import { BrowserPageAnnotationTray } from './browser-page-annotation-tray'
|
||||
import { BrowserPageGrabToast } from './browser-page-grab-toast'
|
||||
import { PendingBrowserAnnotationCard } from './pending-browser-annotation-card'
|
||||
import type { useBrowserPageAnnotationSend } from './use-browser-page-annotation-send'
|
||||
import type { useBrowserPageGrabAnnotations } from './use-browser-page-grab-annotations'
|
||||
|
||||
/**
|
||||
* Everything the annotate and markup tools paint over a guest: the draw surface, the pending
|
||||
* comment card, the annotation tray, the right-click grab menu and the inline confirmation toast.
|
||||
*
|
||||
* Shared because these overlays are the other half of the toolbar's tool cluster — a surface that
|
||||
* offers the tools but not these would arm a picker whose result the reader could never see.
|
||||
*/
|
||||
export function BrowserGuestAnnotateOverlays({
|
||||
markup,
|
||||
grab,
|
||||
annotationSend,
|
||||
grabAnnotations,
|
||||
containerRef,
|
||||
webviewRef,
|
||||
browserOverlayViewport,
|
||||
worktreeId
|
||||
}: {
|
||||
markup: MarkupModeController
|
||||
grab: GrabModeHook
|
||||
annotationSend: ReturnType<typeof useBrowserPageAnnotationSend>
|
||||
grabAnnotations: ReturnType<typeof useBrowserPageGrabAnnotations>
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
webviewRef: MutableRefObject<Electron.WebviewTag | null>
|
||||
browserOverlayViewport: BrowserOverlayViewport
|
||||
worktreeId: string
|
||||
}): React.JSX.Element {
|
||||
const {
|
||||
pendingAnnotationPayload,
|
||||
handleAddBrowserAnnotation,
|
||||
handleCancelPendingBrowserAnnotation,
|
||||
grabIntent,
|
||||
grabMenuActionTakenRef,
|
||||
handleGrabCopy,
|
||||
handleGrabCopyScreenshot,
|
||||
grabToast,
|
||||
grabToastTimerRef,
|
||||
dismissGrabToast,
|
||||
setGrabToast
|
||||
} = grabAnnotations
|
||||
const {
|
||||
browserAnnotations,
|
||||
browserAnnotationTrayOpen,
|
||||
annotationTraySendOpen,
|
||||
handleAnnotationTraySendOpenChange,
|
||||
activeGroupId,
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleCopyBrowserAnnotations,
|
||||
browserAnnotationsCopied,
|
||||
handleClearBrowserAnnotations,
|
||||
handleDeleteBrowserAnnotation
|
||||
} = annotationSend
|
||||
|
||||
return (
|
||||
<>
|
||||
{markup.isActive && markup.baseImage ? (
|
||||
<MarkupOverlay
|
||||
baseImage={markup.baseImage}
|
||||
busy={markup.state === 'composing'}
|
||||
onComplete={(input) => void markup.complete(input)}
|
||||
onCancel={markup.cancel}
|
||||
/>
|
||||
) : null}
|
||||
{pendingAnnotationPayload ? (
|
||||
<PendingBrowserAnnotationCard
|
||||
payload={pendingAnnotationPayload}
|
||||
anchor={getBrowserOverlayAnchor(
|
||||
pendingAnnotationPayload,
|
||||
containerRef.current,
|
||||
webviewRef.current,
|
||||
browserOverlayViewport
|
||||
)}
|
||||
portalContainer={containerRef.current}
|
||||
onAdd={handleAddBrowserAnnotation}
|
||||
onCancel={handleCancelPendingBrowserAnnotation}
|
||||
/>
|
||||
) : null}
|
||||
{browserAnnotations.length > 0 && browserAnnotationTrayOpen ? (
|
||||
<BrowserPageAnnotationTray
|
||||
browserAnnotations={browserAnnotations}
|
||||
annotationTraySendOpen={annotationTraySendOpen}
|
||||
handleAnnotationTraySendOpenChange={handleAnnotationTraySendOpenChange}
|
||||
worktreeId={worktreeId}
|
||||
activeGroupId={activeGroupId}
|
||||
browserAnnotationsPrompt={browserAnnotationsPrompt}
|
||||
handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent}
|
||||
handleCopyBrowserAnnotations={handleCopyBrowserAnnotations}
|
||||
browserAnnotationsCopied={browserAnnotationsCopied}
|
||||
handleClearBrowserAnnotations={handleClearBrowserAnnotations}
|
||||
handleDeleteBrowserAnnotation={handleDeleteBrowserAnnotation}
|
||||
/>
|
||||
) : null}
|
||||
{/* Right-click context dropdown, positioned at the grabbed element's center. */}
|
||||
<DropdownMenu
|
||||
open={grab.state === 'confirming' && grab.contextMenu && grabIntent === 'copy'}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && grab.state === 'confirming') {
|
||||
// Why: skip rearm if a menu action already handled it — see grabMenuActionTakenRef.
|
||||
if (grabMenuActionTakenRef.current) {
|
||||
grabMenuActionTakenRef.current = false
|
||||
return
|
||||
}
|
||||
grab.rearm()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none absolute size-px opacity-0"
|
||||
style={(() => {
|
||||
if (!grab.payload) {
|
||||
return { left: 0, top: 0 }
|
||||
}
|
||||
const rect = grab.payload.target.rectViewport
|
||||
const webview = webviewRef.current
|
||||
const webviewRect = webview?.getBoundingClientRect()
|
||||
const cRect = containerRef.current?.getBoundingClientRect()
|
||||
const offsetX = (webviewRect?.left ?? 0) - (cRect?.left ?? 0)
|
||||
const offsetY = (webviewRect?.top ?? 0) - (cRect?.top ?? 0)
|
||||
return {
|
||||
left: offsetX + rect.x + rect.width / 2,
|
||||
top: offsetY + rect.y + rect.height / 2
|
||||
}
|
||||
})()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4}>
|
||||
<DropdownMenuItem onSelect={handleGrabCopy}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.c2ef0359b9', 'Copy Contents')}
|
||||
<DropdownMenuShortcut>C</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{grab.payload?.screenshot?.dataUrl?.startsWith('data:image/png;base64,') ? (
|
||||
<DropdownMenuItem onSelect={handleGrabCopyScreenshot}>
|
||||
<Image className="size-3.5" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.1ded0d3168', 'Copy Screenshot')}
|
||||
<DropdownMenuShortcut>S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
grabMenuActionTakenRef.current = true
|
||||
grab.cancel()
|
||||
}}
|
||||
>
|
||||
{translate('auto.components.browser.pane.BrowserPane.fa6ea61de3', 'Cancel')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Inline toast bubble; flips above the element when near the viewport bottom so it doesn't occlude it. */}
|
||||
{grabToast ? (
|
||||
<BrowserPageGrabToast
|
||||
grabToast={grabToast}
|
||||
grabToastTimerRef={grabToastTimerRef}
|
||||
dismissGrabToast={dismissGrabToast}
|
||||
setGrabToast={setGrabToast}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function runBrowserGrabActionShortcut({
|
||||
grabIntent,
|
||||
grab,
|
||||
grabPayloadRef,
|
||||
browserTabIdRef,
|
||||
toolTargetIdRef,
|
||||
recordFeatureInteraction,
|
||||
showGrabToast
|
||||
}: {
|
||||
@@ -20,7 +20,7 @@ export function runBrowserGrabActionShortcut({
|
||||
grabIntent: GrabIntent
|
||||
grab: GrabModeHook
|
||||
grabPayloadRef: MutableRefObject<BrowserGrabPayload | null>
|
||||
browserTabIdRef: MutableRefObject<string>
|
||||
toolTargetIdRef: MutableRefObject<string>
|
||||
recordFeatureInteraction: (feature: 'browser-grab') => void | Promise<void>
|
||||
showGrabToast: (
|
||||
message: string,
|
||||
@@ -70,7 +70,7 @@ export function runBrowserGrabActionShortcut({
|
||||
let result: Awaited<ReturnType<typeof window.api.browser.extractHoverPayload>>
|
||||
try {
|
||||
result = await window.api.browser.extractHoverPayload({
|
||||
browserPageId: browserTabIdRef.current
|
||||
browserPageId: toolTargetIdRef.current
|
||||
})
|
||||
} catch {
|
||||
// Why: the guest can be destroyed or the IPC channel torn down mid-shortcut; surface it like a miss instead of an unhandled rejection.
|
||||
@@ -86,7 +86,7 @@ export function runBrowserGrabActionShortcut({
|
||||
if (key === 's') {
|
||||
try {
|
||||
const ssResult = await window.api.browser.captureSelectionScreenshot({
|
||||
browserPageId: browserTabIdRef.current,
|
||||
browserPageId: toolTargetIdRef.current,
|
||||
rect: payload.target.rectViewport
|
||||
})
|
||||
if (ssResult.ok) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
BrowserGrabPayload,
|
||||
BrowserPageAnnotation
|
||||
} from '../../../../../shared/browser-grab-types'
|
||||
|
||||
/**
|
||||
* Push the current annotation set into the guest, where badges render in-page so they track scroll
|
||||
* without a message per frame. Shared by every surface that annotates a guest — the payload is
|
||||
* derived only from the annotations themselves, so the two surfaces cannot disagree about it.
|
||||
*/
|
||||
export function syncGuestAnnotationViewportBridge({
|
||||
toolTargetId,
|
||||
annotations,
|
||||
pendingPayload,
|
||||
surfaceActive,
|
||||
token
|
||||
}: {
|
||||
toolTargetId: string
|
||||
annotations: BrowserPageAnnotation[]
|
||||
pendingPayload: BrowserGrabPayload | null
|
||||
surfaceActive: boolean
|
||||
token: string
|
||||
}): void {
|
||||
// Why: existing badges render in-guest for smooth scroll; only the pending dialog needs viewport messages.
|
||||
const markers = annotations.map((annotation, index) => ({
|
||||
id: annotation.id,
|
||||
index,
|
||||
isFixed: annotation.payload.target.isFixed === true,
|
||||
rectPage: annotation.payload.target.rectPage,
|
||||
rectViewport: annotation.payload.target.rectViewport
|
||||
}))
|
||||
void window.api.browser
|
||||
.setAnnotationViewportBridge({
|
||||
browserPageId: toolTargetId,
|
||||
emitViewport: pendingPayload !== null,
|
||||
enabled: surfaceActive && (pendingPayload !== null || markers.length > 0),
|
||||
markers,
|
||||
token
|
||||
})
|
||||
.catch(() => {
|
||||
// The viewport bridge is visual-only; stale markers beat breaking the surface on a destroyed guest.
|
||||
})
|
||||
}
|
||||
+12
-4
@@ -43,6 +43,7 @@ const annotationAddedGrabToastMessage = (): string =>
|
||||
|
||||
export function useBrowserPageGrabAnnotations({
|
||||
browserTabId,
|
||||
toolTargetId = browserTabId,
|
||||
isActive,
|
||||
grab,
|
||||
containerRef,
|
||||
@@ -51,7 +52,14 @@ export function useBrowserPageGrabAnnotations({
|
||||
browserAnnotationsLength,
|
||||
setBrowserAnnotationTrayOpen
|
||||
}: {
|
||||
/** Scopes the stored annotations. Stable for the life of the surface. */
|
||||
browserTabId: string
|
||||
/**
|
||||
* The id main resolves to a guest. Defaults to the annotation scope, which is the same string
|
||||
* for a browser page — a preview re-mints this on recovery, and its annotations must not be
|
||||
* orphaned when it does.
|
||||
*/
|
||||
toolTargetId?: string
|
||||
isActive: boolean
|
||||
grab: GrabModeHook
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>
|
||||
@@ -75,7 +83,7 @@ export function useBrowserPageGrabAnnotations({
|
||||
handleCancelPendingBrowserAnnotation: () => void
|
||||
handleGrabActionShortcut: (key: 'c' | 's') => void
|
||||
} {
|
||||
const browserTabIdRef = useRef(browserTabId)
|
||||
const toolTargetIdRef = useRef(toolTargetId)
|
||||
const grabToastTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const [grabIntent, setGrabIntent] = useState<GrabIntent>('copy')
|
||||
const grabIntentRef = useRef(grabIntent)
|
||||
@@ -88,12 +96,12 @@ export function useBrowserPageGrabAnnotations({
|
||||
const grabPayloadRef = useRef(grab.payload)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
browserTabIdRef.current = browserTabId
|
||||
toolTargetIdRef.current = toolTargetId
|
||||
grabIntentRef.current = grabIntent
|
||||
pendingAnnotationPayloadRef.current = pendingAnnotationPayload
|
||||
grabRef.current = grab
|
||||
grabPayloadRef.current = grab.payload
|
||||
}, [browserTabId, grab, grabIntent, pendingAnnotationPayload])
|
||||
}, [grab, grabIntent, pendingAnnotationPayload, toolTargetId])
|
||||
// Why: Radix fires onOpenChange(false) before onSelect, so this flag lets onOpenChange skip the rearm that would clear the payload first.
|
||||
const grabMenuActionTakenRef = useRef(false)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
@@ -225,7 +233,7 @@ export function useBrowserPageGrabAnnotations({
|
||||
grabIntent,
|
||||
grab,
|
||||
grabPayloadRef,
|
||||
browserTabIdRef,
|
||||
toolTargetIdRef,
|
||||
recordFeatureInteraction,
|
||||
showGrabToast
|
||||
})
|
||||
|
||||
@@ -393,7 +393,9 @@ export default function BrowserAddressBar({
|
||||
// Why: min-w-11 keeps the leading globe a real hit target once the toolbar
|
||||
// squeezes the bar away — without it neighbouring buttons overlap the only
|
||||
// affordance for reopening the URL field.
|
||||
<div ref={slotRef} className="flex min-w-11 flex-1 items-center">
|
||||
// Why stretch: the toolbar row pins the address slot's height, and the bar must fill it rather
|
||||
// than size itself — otherwise it and the document chip drift apart again.
|
||||
<div ref={slotRef} className="flex min-w-11 flex-1 items-stretch">
|
||||
<Popover
|
||||
modal={false}
|
||||
open={open}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The height every identity widget in the toolbar's address slot is stretched to.
|
||||
*
|
||||
* Why the row owns it rather than each widget: an address bar is a text input and a document chip
|
||||
* is a line of text, so left to themselves they come out 8px apart and the whole toolbar changes
|
||||
* height when the reader switches between a web tab and a document tab. 2.375rem is the address
|
||||
* bar's own natural box (its input's line box plus the frame padding), so pinning to it keeps the
|
||||
* browser chrome exactly as it looks today and brings every other slot up to match.
|
||||
*/
|
||||
export const BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS = 'h-9.5'
|
||||
|
||||
/** Marks the slot wrapper so tests can prove both surfaces share one height contract. */
|
||||
export const BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE = 'data-browser-chrome-address-slot'
|
||||
@@ -0,0 +1,206 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Crosshair, ExternalLink, MessageSquarePlus, SquareCode } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
BrowserNavigationControlRow,
|
||||
type BrowserNavigationControls
|
||||
} from './browser-navigation-control-row'
|
||||
import { MarkupDrawButton } from '../annotate/MarkupDrawButton'
|
||||
import type { GrabIntent } from '../describe-page/browser-page-types'
|
||||
|
||||
/** The in-guest element picker, driving both Grab (copy) and Annotate (comment). */
|
||||
export type BrowserChromeElementTools = {
|
||||
/** The intent the picker is armed for right now, or null when it is idle. */
|
||||
activeIntent: GrabIntent | null
|
||||
onStartIntent: (intent: GrabIntent) => void
|
||||
disabled: boolean
|
||||
grabShortcutLabel: string
|
||||
annotationCount: number
|
||||
}
|
||||
|
||||
export type BrowserChromeMarkupTool = {
|
||||
active: boolean
|
||||
disabled: boolean
|
||||
onToggle: () => void
|
||||
/**
|
||||
* Whether this surface may spend the draw tool's one-per-install discovery popover. False on a
|
||||
* hidden pane (a portaled layer would anchor to a zero-size trigger) and false on any surface
|
||||
* that does not own the nudge — a preview consuming it would burn the single view on a reader
|
||||
* who came for a document, before the browsing pane ever offered it.
|
||||
*/
|
||||
canShowDiscoveryHint: boolean
|
||||
}
|
||||
|
||||
export type BrowserChromeToolAction = {
|
||||
onSelect: () => void
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole browser chrome bar — history, identity, tools — for every surface that renders guest
|
||||
* content: the browsing pane and the workspace document preview.
|
||||
*
|
||||
* Why one component and not a shared row plus two tool clusters: a tool added here has to appear
|
||||
* on both surfaces to stay honest, and a per-surface cluster is exactly how they drift apart. A
|
||||
* surface omits a tool only by passing null, and each null below says why that tool cannot apply.
|
||||
*/
|
||||
export function BrowserChromeToolbar({
|
||||
controls,
|
||||
addressSlot,
|
||||
reloadControl,
|
||||
reloadLabel,
|
||||
importControl,
|
||||
elementTools,
|
||||
markup,
|
||||
shareControl,
|
||||
viewSource,
|
||||
openExternal,
|
||||
overflowMenu,
|
||||
showTourAnchors = false
|
||||
}: {
|
||||
controls: BrowserNavigationControls
|
||||
addressSlot: React.ReactNode
|
||||
reloadControl?: React.ReactNode
|
||||
reloadLabel?: string
|
||||
/** Cookie import — a browsing session concept; null where there is no session to import into. */
|
||||
importControl?: React.ReactNode
|
||||
elementTools: BrowserChromeElementTools | null
|
||||
markup: BrowserChromeMarkupTool
|
||||
shareControl?: React.ReactNode
|
||||
viewSource: BrowserChromeToolAction | null
|
||||
openExternal: BrowserChromeToolAction | null
|
||||
overflowMenu?: React.ReactNode
|
||||
/** Only the browsing pane anchors the contextual tour; a second anchor would steal its steps. */
|
||||
showTourAnchors?: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<BrowserNavigationControlRow
|
||||
controls={controls}
|
||||
addressSlot={addressSlot}
|
||||
reloadControl={reloadControl}
|
||||
reloadLabel={reloadLabel}
|
||||
showTourAnchors={showTourAnchors}
|
||||
>
|
||||
{importControl}
|
||||
|
||||
{elementTools ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={elementTools.activeIntent === 'copy' ? 'default' : 'ghost'}
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
elementTools.activeIntent === 'copy' &&
|
||||
'bg-foreground/80 text-background hover:bg-foreground/90'
|
||||
)}
|
||||
onClick={() => elementTools.onStartIntent('copy')}
|
||||
disabled={elementTools.disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
|
||||
'Grab page element'
|
||||
)}
|
||||
{...(showTourAnchors
|
||||
? { 'data-contextual-tour-target': 'browser-grab-control' }
|
||||
: {})}
|
||||
>
|
||||
<Crosshair className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.acbe79fd01',
|
||||
'Grab page element ({{value0}})',
|
||||
{ value0: elementTools.grabShortcutLabel }
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Why: disabled <button> drops hover events, so wrap in a span so the tooltip trigger still fires. */}
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={elementTools.activeIntent === 'annotate' ? 'default' : 'ghost'}
|
||||
className={cn(
|
||||
'relative h-8 w-8',
|
||||
elementTools.activeIntent === 'annotate' &&
|
||||
'bg-foreground/80 text-background hover:bg-foreground/90'
|
||||
)}
|
||||
onClick={() => elementTools.onStartIntent('annotate')}
|
||||
disabled={elementTools.disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
)}
|
||||
{...(showTourAnchors
|
||||
? { 'data-contextual-tour-target': 'browser-annotation-control' }
|
||||
: {})}
|
||||
>
|
||||
<MessageSquarePlus className="size-4" />
|
||||
{elementTools.annotationCount > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
|
||||
{elementTools.annotationCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<MarkupDrawButton
|
||||
onClick={markup.onToggle}
|
||||
disabled={markup.disabled}
|
||||
active={markup.active}
|
||||
surfaceActive={markup.canShowDiscoveryHint}
|
||||
/>
|
||||
|
||||
{shareControl}
|
||||
|
||||
{viewSource ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={viewSource.onSelect}
|
||||
title={viewSource.label}
|
||||
aria-label={viewSource.label}
|
||||
disabled={viewSource.disabled}
|
||||
>
|
||||
<SquareCode className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{openExternal ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={openExternal.onSelect}
|
||||
title={openExternal.label}
|
||||
aria-label={openExternal.label}
|
||||
disabled={openExternal.disabled}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{overflowMenu}
|
||||
</BrowserNavigationControlRow>
|
||||
)
|
||||
}
|
||||
+62
-4
@@ -16,6 +16,11 @@ vi.mock('./BrowserAddressBar', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
import BrowserAddressBar from './BrowserAddressBar'
|
||||
import {
|
||||
BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE,
|
||||
BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS
|
||||
} from './browser-chrome-address-slot'
|
||||
import {
|
||||
BrowserNavigationControlRow,
|
||||
type BrowserNavigationControls
|
||||
@@ -37,10 +42,15 @@ function renderRow(overrides: Partial<BrowserNavigationControls> = {}): BrowserN
|
||||
return (
|
||||
<BrowserNavigationControlRow
|
||||
controls={controls}
|
||||
addressBarValue="https://example.com/"
|
||||
onAddressBarChange={vi.fn()}
|
||||
onSubmitAddressBar={vi.fn()}
|
||||
addressBarInputRef={inputRef}
|
||||
addressSlot={
|
||||
<BrowserAddressBar
|
||||
value="https://example.com/"
|
||||
onChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onNavigate={controls.navigate}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +58,24 @@ function renderRow(overrides: Partial<BrowserNavigationControls> = {}): BrowserN
|
||||
return controls
|
||||
}
|
||||
|
||||
/** The read-only counterpart the document preview passes, standing in for any non-URL identity. */
|
||||
function renderWithIdentityChip(): void {
|
||||
render(
|
||||
<BrowserNavigationControlRow
|
||||
controls={{
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loading: false,
|
||||
goBack: vi.fn(),
|
||||
goForward: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
navigate: vi.fn()
|
||||
}}
|
||||
addressSlot={<span>docs/report.html</span>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BrowserNavigationControlRow', () => {
|
||||
afterEach(() => cleanup())
|
||||
|
||||
@@ -77,4 +105,34 @@ describe('BrowserNavigationControlRow', () => {
|
||||
renderRow()
|
||||
expect(document.querySelector('[data-contextual-tour-target="browser-toolbar"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
// Why: the row must not assume its middle is an address bar — a surface with no URL to type
|
||||
// still gets the same history controls in the same chrome.
|
||||
it('renders a non-address identity widget in the same slot', () => {
|
||||
renderWithIdentityChip()
|
||||
expect(screen.getByText('docs/report.html')).not.toBeNull()
|
||||
expect(screen.queryByLabelText('Address')).toBeNull()
|
||||
expect(screen.getByLabelText('Back')).not.toBeNull()
|
||||
expect(screen.getByLabelText('Reload')).not.toBeNull()
|
||||
})
|
||||
|
||||
// Why the row and not each widget: a text input and a line of text come out different heights,
|
||||
// and the whole toolbar would change size when a document tab replaces a web tab.
|
||||
it('gives every identity widget the same slot height, and stretches it to fill', () => {
|
||||
renderRow()
|
||||
const addressSlot = document.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
|
||||
const addressSlotClass = addressSlot?.className ?? ''
|
||||
const addressWidgetParent = screen.getByLabelText('Address').parentElement
|
||||
cleanup()
|
||||
|
||||
renderWithIdentityChip()
|
||||
const chipSlot = document.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
|
||||
|
||||
expect(addressSlotClass).toContain(BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS)
|
||||
expect(chipSlot?.className).toBe(addressSlotClass)
|
||||
expect(addressSlotClass).toContain('items-stretch')
|
||||
// Both widgets are direct children, so the slot's height reaches them instead of a wrapper.
|
||||
expect(addressWidgetParent).toBe(addressSlot)
|
||||
expect(screen.getByText('docs/report.html').parentElement).toBe(chipSlot)
|
||||
})
|
||||
})
|
||||
|
||||
+28
-33
@@ -1,8 +1,11 @@
|
||||
import { ArrowLeft, ArrowRight, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import BrowserAddressBar from './BrowserAddressBar'
|
||||
import type { BrowserAddressBarEditSessionBinding } from './use-browser-address-bar-edit-session'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE,
|
||||
BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS
|
||||
} from './browser-chrome-address-slot'
|
||||
|
||||
/**
|
||||
* The history/reload/navigate surface a browser backend must provide to be driven by
|
||||
@@ -20,42 +23,35 @@ export type BrowserNavigationControls = {
|
||||
}
|
||||
|
||||
/**
|
||||
* The toolbar row every browser pane shares: back, forward, reload and the address bar.
|
||||
* Panes with a richer reload affordance pass `reloadControl`; pane-specific tools
|
||||
* (annotations, downloads, find) render as trailing children.
|
||||
* The toolbar row every browser surface shares: back, forward, reload, then whatever names the
|
||||
* thing on screen, then that surface's tools.
|
||||
*
|
||||
* Why the middle is a slot rather than the address bar: a web page is named by a URL you may
|
||||
* retype, a workspace document by a path you may not. Both still sit in the same place, at the
|
||||
* same size, between the same controls — so the identity widget is what varies, not the row.
|
||||
*/
|
||||
export function BrowserNavigationControlRow({
|
||||
controls,
|
||||
addressBarValue,
|
||||
onAddressBarChange,
|
||||
onSubmitAddressBar,
|
||||
addressBarInputRef,
|
||||
dismissSuggestionsRef,
|
||||
addressBarEditSession,
|
||||
addressSlot,
|
||||
reloadControl,
|
||||
reloadLabel,
|
||||
addressBarLeadingIcon,
|
||||
showTourAnchors = true,
|
||||
children
|
||||
}: {
|
||||
controls: BrowserNavigationControls
|
||||
addressBarValue: string
|
||||
onAddressBarChange: (value: string) => void
|
||||
onSubmitAddressBar: () => void
|
||||
addressBarInputRef: React.RefObject<HTMLInputElement | null>
|
||||
dismissSuggestionsRef?: React.MutableRefObject<(() => void) | null>
|
||||
/** Set by panes React remounts mid-edit; see BrowserAddressBar's `editSession`. */
|
||||
addressBarEditSession?: BrowserAddressBarEditSessionBinding | null
|
||||
/** The surface's identity widget: an editable address bar, or a read-only document chip. */
|
||||
addressSlot: React.ReactNode
|
||||
reloadControl?: React.ReactNode
|
||||
/** Accessible name for the default reload button, which doubles as Stop and Retry. */
|
||||
reloadLabel?: string
|
||||
/** Replaces the address bar's leading globe (e.g. the SSH egress indicator). */
|
||||
addressBarLeadingIcon?: React.ReactNode
|
||||
/** Off for surfaces the browsing tour does not cover — a second anchor would steal its steps. */
|
||||
showTourAnchors?: boolean
|
||||
children?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="relative z-10 flex items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5"
|
||||
data-contextual-tour-target="browser-toolbar"
|
||||
className="relative z-10 flex shrink-0 items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5"
|
||||
{...(showTourAnchors ? { 'data-contextual-tour-target': 'browser-toolbar' } : {})}
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -93,16 +89,15 @@ export function BrowserNavigationControlRow({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<BrowserAddressBar
|
||||
value={addressBarValue}
|
||||
onChange={onAddressBarChange}
|
||||
onSubmit={onSubmitAddressBar}
|
||||
onNavigate={controls.navigate}
|
||||
inputRef={addressBarInputRef}
|
||||
dismissSuggestionsRef={dismissSuggestionsRef}
|
||||
editSession={addressBarEditSession}
|
||||
leadingIcon={addressBarLeadingIcon}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-stretch',
|
||||
BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS
|
||||
)}
|
||||
{...{ [BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE]: 'true' }}
|
||||
>
|
||||
{addressSlot}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
+76
-206
@@ -1,36 +1,20 @@
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Crosshair,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MessageSquarePlus,
|
||||
RefreshCw,
|
||||
SquareCode
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { BrowserReloadTrigger } from '../navigate/browser-reload-action'
|
||||
import { BrowserNavigationControlRow } from './browser-navigation-control-row'
|
||||
import BrowserAddressBar from './BrowserAddressBar'
|
||||
import { BrowserChromeToolbar } from './browser-chrome-toolbar'
|
||||
import { BrowserImportHintButton } from './BrowserImportHintButton'
|
||||
import { BrowserReloadControl } from './browser-reload-control'
|
||||
import { BrowserToolbarMenu } from './BrowserToolbarMenu'
|
||||
import { SshEgressIndicator } from './browser-egress-indicator'
|
||||
import { MarkupDrawButton } from '../annotate/MarkupDrawButton'
|
||||
import { destroyPersistentWebview } from '../host-guest/webview-registry'
|
||||
import { readBrowserHtmlArtifactRequest } from '../describe-page/browser-artifact-upload'
|
||||
import type { GrabModeHook } from '../annotate/useGrabMode'
|
||||
import type { BrowserViewportPresetId } from '../../../../../shared/browser-workspace-types'
|
||||
import type { GrabIntent } from '../describe-page/browser-page-types'
|
||||
|
||||
/** Binds the shared browser chrome to a browsing page: an editable address bar and session tools. */
|
||||
export function BrowserPageToolbar({
|
||||
browserPageId,
|
||||
workspaceId,
|
||||
@@ -105,7 +89,8 @@ export function BrowserPageToolbar({
|
||||
externalUrl: string | null
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<BrowserNavigationControlRow
|
||||
<BrowserChromeToolbar
|
||||
showTourAnchors
|
||||
controls={{
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
@@ -115,199 +100,84 @@ export function BrowserPageToolbar({
|
||||
reload: () => runReloadTrigger('button'),
|
||||
navigate: navigateToUrl
|
||||
}}
|
||||
addressBarValue={addressBarValue}
|
||||
onAddressBarChange={setAddressBarValue}
|
||||
onSubmitAddressBar={submitAddressBar}
|
||||
addressBarInputRef={addressBarInputRef}
|
||||
dismissSuggestionsRef={dismissAddressBarSuggestionsRef}
|
||||
addressBarLeadingIcon={<SshEgressIndicator worktreeId={worktreeId} />}
|
||||
reloadControl={
|
||||
<DropdownMenu modal={false} open={reloadMenuOpen} onOpenChange={setReloadMenuOpen}>
|
||||
{/* Why: suppress the tooltip while the menu is open — both anchor below the button and would overlap. */}
|
||||
<Tooltip open={reloadMenuOpen ? false : undefined}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
aria-label={reloadButtonLabel}
|
||||
// Why: preventDefault suppresses Radix's open-on-left-click (composeEventHandlers skips its
|
||||
// handler once defaultPrevented), keeping left-click on the primary action and the menu on right-click.
|
||||
onPointerDown={(e) => {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
// Why: same trick for Radix's open-on-Enter/Space, which would otherwise preventDefault the
|
||||
// synthesized click and strand keyboard users. ArrowDown still falls through to open the menu.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
runReloadTrigger('button')
|
||||
}
|
||||
}}
|
||||
onClick={() => runReloadTrigger('button')}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setReloadMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{reloadButtonLabel}
|
||||
{/* Why: the chord maps to plain reload(), which is not what Stop or Retry do — only hint when they match. */}
|
||||
{reloadShortcut && reloadButtonLabelKind === 'reload' ? ` · ${reloadShortcut}` : ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" alignOffset={-4}>
|
||||
<DropdownMenuItem onClick={() => runReloadTrigger('reload')}>
|
||||
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
|
||||
<DropdownMenuShortcut>{reloadShortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => runReloadTrigger('hard-reload')}>
|
||||
{translate('auto.components.browser.pane.BrowserPane.a1f3c2e4b5', 'Hard Reload')}
|
||||
<DropdownMenuShortcut>{hardReloadShortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<BrowserImportHintButton profileId={sessionProfileId} />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={grab.state !== 'idle' && grabIntent === 'copy' ? 'default' : 'ghost'}
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
grab.state !== 'idle' &&
|
||||
grabIntent === 'copy' &&
|
||||
'bg-foreground/80 text-background hover:bg-foreground/90'
|
||||
)}
|
||||
onClick={() => startGrabIntent('copy')}
|
||||
disabled={isBlankTab || markupIsActive}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
|
||||
'Grab page element'
|
||||
)}
|
||||
data-contextual-tour-target="browser-grab-control"
|
||||
>
|
||||
<Crosshair className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.acbe79fd01',
|
||||
'Grab page element ({{value0}})',
|
||||
{ value0: grabElementShortcut }
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Why: disabled <button> drops hover events, so wrap in a span so the tooltip trigger still fires. */}
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={grab.state !== 'idle' && grabIntent === 'annotate' ? 'default' : 'ghost'}
|
||||
className={cn(
|
||||
'relative h-8 w-8',
|
||||
grab.state !== 'idle' &&
|
||||
grabIntent === 'annotate' &&
|
||||
'bg-foreground/80 text-background hover:bg-foreground/90'
|
||||
)}
|
||||
onClick={() => startGrabIntent('annotate')}
|
||||
disabled={isBlankTab || markupIsActive}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
)}
|
||||
data-contextual-tour-target="browser-annotation-control"
|
||||
>
|
||||
<MessageSquarePlus className="size-4" />
|
||||
{browserAnnotationsLength > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
|
||||
{browserAnnotationsLength}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<MarkupDrawButton
|
||||
onClick={() => (markupIsActive ? markupCancel() : void markupStart())}
|
||||
disabled={isBlankTab || grab.state !== 'idle'}
|
||||
active={markupIsActive}
|
||||
surfaceActive={isActive}
|
||||
/>
|
||||
|
||||
{shareableArtifactFile ? (
|
||||
<ArtifactPublishButton
|
||||
sourceKey={shareableArtifactFile.filePath}
|
||||
className="h-7 w-7"
|
||||
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
|
||||
addressSlot={
|
||||
<BrowserAddressBar
|
||||
value={addressBarValue}
|
||||
onChange={setAddressBarValue}
|
||||
onSubmit={submitAddressBar}
|
||||
onNavigate={navigateToUrl}
|
||||
inputRef={addressBarInputRef}
|
||||
dismissSuggestionsRef={dismissAddressBarSuggestionsRef}
|
||||
leadingIcon={<SshEgressIndicator worktreeId={worktreeId} />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => void window.api.browser.openDevTools({ browserPageId })}
|
||||
title={translate(
|
||||
}
|
||||
reloadControl={
|
||||
<BrowserReloadControl
|
||||
menuOpen={reloadMenuOpen}
|
||||
onMenuOpenChange={setReloadMenuOpen}
|
||||
label={reloadButtonLabel}
|
||||
loading={loading}
|
||||
showShortcutHint={reloadButtonLabelKind === 'reload'}
|
||||
reloadShortcut={reloadShortcut}
|
||||
hardReloadShortcut={hardReloadShortcut}
|
||||
onPrimary={() => runReloadTrigger('button')}
|
||||
onReload={() => runReloadTrigger('reload')}
|
||||
onHardReload={() => runReloadTrigger('hard-reload')}
|
||||
/>
|
||||
}
|
||||
importControl={<BrowserImportHintButton profileId={sessionProfileId} />}
|
||||
elementTools={{
|
||||
activeIntent: grab.state !== 'idle' ? grabIntent : null,
|
||||
onStartIntent: startGrabIntent,
|
||||
disabled: isBlankTab || markupIsActive,
|
||||
grabShortcutLabel: grabElementShortcut,
|
||||
annotationCount: browserAnnotationsLength
|
||||
}}
|
||||
markup={{
|
||||
active: markupIsActive,
|
||||
disabled: isBlankTab || grab.state !== 'idle',
|
||||
onToggle: () => (markupIsActive ? markupCancel() : void markupStart()),
|
||||
canShowDiscoveryHint: isActive
|
||||
}}
|
||||
shareControl={
|
||||
shareableArtifactFile ? (
|
||||
<ArtifactPublishButton
|
||||
sourceKey={shareableArtifactFile.filePath}
|
||||
className="h-7 w-7"
|
||||
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
viewSource={{
|
||||
onSelect: () => void window.api.browser.openDevTools({ browserPageId }),
|
||||
label: translate(
|
||||
'auto.components.browser.pane.BrowserPane.ec75d0c412',
|
||||
'Open browser devtools'
|
||||
)}
|
||||
>
|
||||
<SquareCode className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
)
|
||||
}}
|
||||
openExternal={{
|
||||
onSelect: () => {
|
||||
if (!externalUrl) {
|
||||
return
|
||||
}
|
||||
void window.api.shell.openUrl(externalUrl)
|
||||
}}
|
||||
title={translate(
|
||||
},
|
||||
label: translate(
|
||||
'auto.components.browser.pane.BrowserPane.0f41bf80c7',
|
||||
'Open in default browser'
|
||||
)}
|
||||
disabled={!externalUrl}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
|
||||
<BrowserToolbarMenu
|
||||
currentProfileId={sessionProfileId}
|
||||
workspaceId={workspaceId}
|
||||
browserPageId={browserPageId}
|
||||
viewportPresetId={viewportPresetId}
|
||||
onDestroyWebview={() => destroyPersistentWebview(browserPageId)}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</BrowserNavigationControlRow>
|
||||
),
|
||||
disabled: !externalUrl
|
||||
}}
|
||||
overflowMenu={
|
||||
<BrowserToolbarMenu
|
||||
currentProfileId={sessionProfileId}
|
||||
workspaceId={workspaceId}
|
||||
browserPageId={browserPageId}
|
||||
viewportPresetId={viewportPresetId}
|
||||
onDestroyWebview={() => destroyPersistentWebview(browserPageId)}
|
||||
isActive={isActive}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-150
@@ -1,14 +1,6 @@
|
||||
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Copy, Globe, Image } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { toHttpsRecoveryUrl } from '../../../../../shared/browser-url'
|
||||
import type {
|
||||
@@ -19,17 +11,11 @@ import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-gu
|
||||
import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-overlay'
|
||||
import { useSshWorkspaceProbeSkipRecheck } from '../use-ssh-workspace-browser-route'
|
||||
import BrowserFind from './BrowserFind'
|
||||
import { MarkupOverlay } from '../annotate/MarkupOverlay'
|
||||
import { BrowserGuestAnnotateOverlays } from '../annotate/browser-guest-annotate-overlays'
|
||||
import type { MarkupModeController } from '../annotate/useMarkupMode'
|
||||
import type { GrabModeHook } from '../annotate/useGrabMode'
|
||||
import {
|
||||
getBrowserOverlayAnchor,
|
||||
type BrowserOverlayViewport
|
||||
} from '../describe-page/browser-annotation-geometry'
|
||||
import { BrowserPageAnnotationTray } from '../annotate/browser-page-annotation-tray'
|
||||
import { BrowserPageGrabToast } from '../annotate/browser-page-grab-toast'
|
||||
import type { BrowserOverlayViewport } from '../describe-page/browser-annotation-geometry'
|
||||
import { retryBrowserTabLoad, toDisplayUrl } from '../describe-page/browser-page-url-display'
|
||||
import { PendingBrowserAnnotationCard } from '../annotate/pending-browser-annotation-card'
|
||||
import type { BrowserTabPageState } from '../describe-page/browser-page-types'
|
||||
import type { useBrowserPageAnnotationSend } from '../annotate/use-browser-page-annotation-send'
|
||||
import type { useBrowserPageGrabAnnotations } from '../annotate/use-browser-page-grab-annotations'
|
||||
@@ -84,42 +70,18 @@ export function BrowserPageViewportOverlays({
|
||||
grabAnnotations: ReturnType<typeof useBrowserPageGrabAnnotations>
|
||||
}): React.JSX.Element {
|
||||
const recheckSshRoute = useSshWorkspaceProbeSkipRecheck(worktreeId)
|
||||
const {
|
||||
pendingAnnotationPayload,
|
||||
handleAddBrowserAnnotation,
|
||||
handleCancelPendingBrowserAnnotation,
|
||||
grabIntent,
|
||||
grabMenuActionTakenRef,
|
||||
handleGrabCopy,
|
||||
handleGrabCopyScreenshot,
|
||||
grabToast,
|
||||
grabToastTimerRef,
|
||||
dismissGrabToast,
|
||||
setGrabToast
|
||||
} = grabAnnotations
|
||||
const {
|
||||
browserAnnotations,
|
||||
browserAnnotationTrayOpen,
|
||||
annotationTraySendOpen,
|
||||
handleAnnotationTraySendOpenChange,
|
||||
activeGroupId,
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleCopyBrowserAnnotations,
|
||||
browserAnnotationsCopied,
|
||||
handleClearBrowserAnnotations,
|
||||
handleDeleteBrowserAnnotation
|
||||
} = annotationSend
|
||||
return (
|
||||
<>
|
||||
{markup.isActive && markup.baseImage ? (
|
||||
<MarkupOverlay
|
||||
baseImage={markup.baseImage}
|
||||
busy={markup.state === 'composing'}
|
||||
onComplete={(input) => void markup.complete(input)}
|
||||
onCancel={markup.cancel}
|
||||
/>
|
||||
) : null}
|
||||
<BrowserGuestAnnotateOverlays
|
||||
markup={markup}
|
||||
grab={grab}
|
||||
annotationSend={annotationSend}
|
||||
grabAnnotations={grabAnnotations}
|
||||
containerRef={containerRef}
|
||||
webviewRef={webviewRef}
|
||||
browserOverlayViewport={browserOverlayViewport}
|
||||
worktreeId={worktreeId}
|
||||
/>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -190,105 +152,6 @@ export function BrowserPageViewportOverlays({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingAnnotationPayload ? (
|
||||
<PendingBrowserAnnotationCard
|
||||
payload={pendingAnnotationPayload}
|
||||
anchor={getBrowserOverlayAnchor(
|
||||
pendingAnnotationPayload,
|
||||
containerRef.current,
|
||||
webviewRef.current,
|
||||
browserOverlayViewport
|
||||
)}
|
||||
portalContainer={containerRef.current}
|
||||
onAdd={handleAddBrowserAnnotation}
|
||||
onCancel={handleCancelPendingBrowserAnnotation}
|
||||
/>
|
||||
) : null}
|
||||
{browserAnnotations.length > 0 && browserAnnotationTrayOpen ? (
|
||||
<BrowserPageAnnotationTray
|
||||
browserAnnotations={browserAnnotations}
|
||||
annotationTraySendOpen={annotationTraySendOpen}
|
||||
handleAnnotationTraySendOpenChange={handleAnnotationTraySendOpenChange}
|
||||
worktreeId={worktreeId}
|
||||
activeGroupId={activeGroupId}
|
||||
browserAnnotationsPrompt={browserAnnotationsPrompt}
|
||||
handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent}
|
||||
handleCopyBrowserAnnotations={handleCopyBrowserAnnotations}
|
||||
browserAnnotationsCopied={browserAnnotationsCopied}
|
||||
handleClearBrowserAnnotations={handleClearBrowserAnnotations}
|
||||
handleDeleteBrowserAnnotation={handleDeleteBrowserAnnotation}
|
||||
/>
|
||||
) : null}
|
||||
{/* Right-click context dropdown, positioned at the grabbed element's center. */}
|
||||
<DropdownMenu
|
||||
open={grab.state === 'confirming' && grab.contextMenu && grabIntent === 'copy'}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && grab.state === 'confirming') {
|
||||
// Why: skip rearm if a menu action already handled it — see grabMenuActionTakenRef.
|
||||
if (grabMenuActionTakenRef.current) {
|
||||
grabMenuActionTakenRef.current = false
|
||||
return
|
||||
}
|
||||
grab.rearm()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none absolute size-px opacity-0"
|
||||
style={(() => {
|
||||
if (!grab.payload) {
|
||||
return { left: 0, top: 0 }
|
||||
}
|
||||
const rect = grab.payload.target.rectViewport
|
||||
const webview = webviewRef.current
|
||||
const webviewRect = webview?.getBoundingClientRect()
|
||||
const cRect = containerRef.current?.getBoundingClientRect()
|
||||
const offsetX = (webviewRect?.left ?? 0) - (cRect?.left ?? 0)
|
||||
const offsetY = (webviewRect?.top ?? 0) - (cRect?.top ?? 0)
|
||||
return {
|
||||
left: offsetX + rect.x + rect.width / 2,
|
||||
top: offsetY + rect.y + rect.height / 2
|
||||
}
|
||||
})()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4}>
|
||||
<DropdownMenuItem onSelect={handleGrabCopy}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.c2ef0359b9', 'Copy Contents')}
|
||||
<DropdownMenuShortcut>C</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{grab.payload?.screenshot?.dataUrl?.startsWith('data:image/png;base64,') ? (
|
||||
<DropdownMenuItem onSelect={handleGrabCopyScreenshot}>
|
||||
<Image className="size-3.5" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.1ded0d3168', 'Copy Screenshot')}
|
||||
<DropdownMenuShortcut>S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
grabMenuActionTakenRef.current = true
|
||||
grab.cancel()
|
||||
}}
|
||||
>
|
||||
{translate('auto.components.browser.pane.BrowserPane.fa6ea61de3', 'Cancel')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Inline toast bubble; flips above the element when near the viewport bottom so it doesn't occlude it. */}
|
||||
{grabToast ? (
|
||||
<BrowserPageGrabToast
|
||||
grabToast={grabToast}
|
||||
grabToastTimerRef={grabToastTimerRef}
|
||||
dismissGrabToast={dismissGrabToast}
|
||||
setGrabToast={setGrabToast}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
* Reload as browsers do it: left-click reloads, right-click offers the harder variant. Shared so
|
||||
* both guest surfaces get the same gesture — what "hard" means is the caller's to define.
|
||||
*/
|
||||
export function BrowserReloadControl({
|
||||
menuOpen,
|
||||
onMenuOpenChange,
|
||||
label,
|
||||
loading,
|
||||
showShortcutHint,
|
||||
reloadShortcut,
|
||||
hardReloadShortcut,
|
||||
onPrimary,
|
||||
onReload,
|
||||
onHardReload
|
||||
}: {
|
||||
menuOpen: boolean
|
||||
onMenuOpenChange: Dispatch<SetStateAction<boolean>>
|
||||
label: string
|
||||
loading: boolean
|
||||
/** The chord maps to plain reload, which is not what Stop or Retry do — hint only when they match. */
|
||||
showShortcutHint: boolean
|
||||
reloadShortcut: string
|
||||
hardReloadShortcut: string
|
||||
onPrimary: () => void
|
||||
onReload: () => void
|
||||
onHardReload: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={onMenuOpenChange}>
|
||||
{/* Why: suppress the tooltip while the menu is open — both anchor below the button and would overlap. */}
|
||||
<Tooltip open={menuOpen ? false : undefined}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
aria-label={label}
|
||||
// Why: preventDefault suppresses Radix's open-on-left-click (composeEventHandlers skips its
|
||||
// handler once defaultPrevented), keeping left-click on the primary action and the menu on right-click.
|
||||
onPointerDown={(e) => {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
// Why: same trick for Radix's open-on-Enter/Space, which would otherwise preventDefault the
|
||||
// synthesized click and strand keyboard users. ArrowDown still falls through to open the menu.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onPrimary()
|
||||
}
|
||||
}}
|
||||
onClick={onPrimary}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
onMenuOpenChange(true)
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{label}
|
||||
{reloadShortcut && showShortcutHint ? ` · ${reloadShortcut}` : ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" alignOffset={-4}>
|
||||
<DropdownMenuItem onClick={onReload}>
|
||||
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
|
||||
<DropdownMenuShortcut>{reloadShortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onHardReload}>
|
||||
{translate('auto.components.browser.pane.BrowserPane.a1f3c2e4b5', 'Hard Reload')}
|
||||
<DropdownMenuShortcut>{hardReloadShortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
+29
-20
@@ -18,6 +18,7 @@ import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-t
|
||||
import { RemoteBrowserPagePane } from '../stream-remote/remote-browser-page-pane'
|
||||
import { ClientHostedBrowserPagePane } from '../ClientHostedBrowserPagePane'
|
||||
import { BrowserPagePane } from './browser-page-pane'
|
||||
import { WorkspaceDocPagePane } from '../workspace-doc/workspace-doc-page-pane'
|
||||
import { SshRoutedBrowserPageGate } from './ssh-routed-browser-page-gate'
|
||||
|
||||
export default function BrowserPane({
|
||||
@@ -156,26 +157,34 @@ export default function BrowserPane({
|
||||
>
|
||||
{(routedPartition) => (
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
{renderedBrowserPages.map((page) => (
|
||||
<BrowserPagePane
|
||||
key={page.id}
|
||||
browserTab={page}
|
||||
workspaceId={browserTab.id}
|
||||
worktreeId={browserTab.worktreeId}
|
||||
sessionProfileId={browserTab.sessionProfileId ?? null}
|
||||
sessionPartition={routedPartition ?? browserTab.sessionPartition ?? null}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
chromeShortcutScope={
|
||||
page.id === activeBrowserPage?.id ? resolvedChromeShortcutScope : 'inactive'
|
||||
}
|
||||
isAutomationVisible={automationVisiblePageIds.has(page.id)}
|
||||
isMobileDriven={mobileDrivenPageIds.has(page.id)}
|
||||
isRemotelyViewed={remotelyViewedPageIds.has(page.id)}
|
||||
inputLocked={activeBrowserDriver.kind === 'mobile'}
|
||||
onUpdatePageState={updateBrowserPageState}
|
||||
onSetUrl={setBrowserPageUrl}
|
||||
/>
|
||||
))}
|
||||
{renderedBrowserPages.map((page) =>
|
||||
page.docLocation ? (
|
||||
<WorkspaceDocPagePane
|
||||
key={page.id}
|
||||
page={page}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
/>
|
||||
) : (
|
||||
<BrowserPagePane
|
||||
key={page.id}
|
||||
browserTab={page}
|
||||
workspaceId={browserTab.id}
|
||||
worktreeId={browserTab.worktreeId}
|
||||
sessionProfileId={browserTab.sessionProfileId ?? null}
|
||||
sessionPartition={routedPartition ?? browserTab.sessionPartition ?? null}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
chromeShortcutScope={
|
||||
page.id === activeBrowserPage?.id ? resolvedChromeShortcutScope : 'inactive'
|
||||
}
|
||||
isAutomationVisible={automationVisiblePageIds.has(page.id)}
|
||||
isMobileDriven={mobileDrivenPageIds.has(page.id)}
|
||||
isRemotelyViewed={remotelyViewedPageIds.has(page.id)}
|
||||
inputLocked={activeBrowserDriver.kind === 'mobile'}
|
||||
onUpdatePageState={updateBrowserPageState}
|
||||
onSetUrl={setBrowserPageUrl}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<BrowserMobileDriverOverlay
|
||||
driver={activeBrowserDriver}
|
||||
onTakeBack={reclaimActiveBrowserForDesktop}
|
||||
|
||||
+8
-21
@@ -22,6 +22,7 @@ import {
|
||||
EMPTY_BROWSER_ANNOTATIONS,
|
||||
type BrowserOverlayViewport
|
||||
} from '../describe-page/browser-annotation-geometry'
|
||||
import { syncGuestAnnotationViewportBridge } from '../annotate/guest-annotation-viewport-bridge'
|
||||
import { attachBrowserPageWebview } from './attach-browser-page-webview'
|
||||
import { setBrowserPageWebviewInputLock } from './browser-page-webview'
|
||||
import type {
|
||||
@@ -201,27 +202,13 @@ export function useBrowserPageWebviewLifecycle({
|
||||
)
|
||||
|
||||
const syncBrowserAnnotationViewportBridge = useCallback((): void => {
|
||||
const pendingPayload = pendingAnnotationPayloadRef.current
|
||||
// Why: existing badges render in-guest for smooth scroll; only the pending dialog needs viewport messages.
|
||||
const markers = browserAnnotationsRef.current.map((annotation, index) => ({
|
||||
id: annotation.id,
|
||||
index,
|
||||
isFixed: annotation.payload.target.isFixed === true,
|
||||
rectPage: annotation.payload.target.rectPage,
|
||||
rectViewport: annotation.payload.target.rectViewport
|
||||
}))
|
||||
const enabled = isActiveRef.current && (pendingPayload !== null || markers.length > 0)
|
||||
void window.api.browser
|
||||
.setAnnotationViewportBridge({
|
||||
browserPageId: browserTabId,
|
||||
emitViewport: pendingPayload !== null,
|
||||
enabled,
|
||||
markers,
|
||||
token: annotationViewportBridgeTokenRef.current
|
||||
})
|
||||
.catch(() => {
|
||||
// The viewport bridge is visual-only; stale markers beat breaking the pane on a destroyed guest.
|
||||
})
|
||||
syncGuestAnnotationViewportBridge({
|
||||
toolTargetId: browserTabId,
|
||||
annotations: browserAnnotationsRef.current,
|
||||
pendingPayload: pendingAnnotationPayloadRef.current,
|
||||
surfaceActive: isActiveRef.current,
|
||||
token: annotationViewportBridgeTokenRef.current
|
||||
})
|
||||
}, [browserTabId])
|
||||
|
||||
// Why: browserTab.url excluded from deps (changes every navigation → would destroy/recreate the webview); URL logic reads browserTabUrlRef.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, type MutableRefObject } from 'react'
|
||||
import { useWebviewDragPassthroughActive } from './use-webview-drag-passthrough-active'
|
||||
|
||||
/**
|
||||
* Enrols a single component-owned guest in the renderer's drag passthrough.
|
||||
*
|
||||
* Why it matters: a `<webview>` swallows the pointer stream the document never sees, so a
|
||||
* dnd-kit drag stops receiving `pointermove` the instant the cursor crosses one — the dragged
|
||||
* tab stops following the cursor and the drop it was aiming for cannot be made. The browser
|
||||
* pane's guests are held click-through through their registry; a guest that belongs to one
|
||||
* component instead (the document preview) has no registry to be walked by, so it enrols here.
|
||||
*/
|
||||
export function useGuestDragPassthrough(
|
||||
webviewRef: MutableRefObject<Electron.WebviewTag | null>,
|
||||
/** Changes when the ref is pointed at a new guest, so one attached mid-drag is settled too. */
|
||||
guestKey: string | null
|
||||
): void {
|
||||
const passthroughActive = useWebviewDragPassthroughActive()
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) {
|
||||
return
|
||||
}
|
||||
webview.style.pointerEvents = passthroughActive ? 'none' : ''
|
||||
return () => {
|
||||
// Why reset rather than restore: the guest outlives this state, and leaving it transparent
|
||||
// would cost the reader every click on the document.
|
||||
webview.style.pointerEvents = ''
|
||||
}
|
||||
}, [guestKey, passthroughActive, webviewRef])
|
||||
}
|
||||
+27
@@ -56,4 +56,31 @@ describe('resolveBrowserAddressBarSubmission', () => {
|
||||
it('treats blank input as the blank page rather than an error', () => {
|
||||
expect(resolveBrowserAddressBarSubmission(' ')).toMatchObject({ status: 'navigate' })
|
||||
})
|
||||
|
||||
it('keeps file URLs navigable for the local browser pane', () => {
|
||||
expect(resolveBrowserAddressBarSubmission('/tmp/report.html')).toMatchObject({
|
||||
status: 'navigate',
|
||||
url: 'file:///tmp/report.html'
|
||||
})
|
||||
})
|
||||
|
||||
it('explains the refusal instead of blanking the tab when a client-hosted page gets a file URL', () => {
|
||||
expect(
|
||||
resolveBrowserAddressBarSubmission('/tmp/report.html', { allowFileUrls: false })
|
||||
).toEqual({
|
||||
status: 'invalid',
|
||||
loadError: {
|
||||
code: 0,
|
||||
description:
|
||||
'This browser tab cannot open local files. Use "Open Preview to the Side" on the file instead.',
|
||||
validatedUrl: '/tmp/report.html'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('still navigates http(s) when file URLs are refused', () => {
|
||||
expect(
|
||||
resolveBrowserAddressBarSubmission('https://example.com', { allowFileUrls: false })
|
||||
).toMatchObject({ status: 'navigate' })
|
||||
})
|
||||
})
|
||||
|
||||
+18
-6
@@ -15,24 +15,36 @@ export type BrowserAddressBarSubmission =
|
||||
* Kagi session link and the invalid-input failure cannot drift between backends.
|
||||
* Callers stay responsible for routing the two outcomes into their own chrome.
|
||||
*/
|
||||
export function resolveBrowserAddressBarSubmission(rawValue: string): BrowserAddressBarSubmission {
|
||||
export function resolveBrowserAddressBarSubmission(
|
||||
rawValue: string,
|
||||
options?: { allowFileUrls?: boolean }
|
||||
): BrowserAddressBarSubmission {
|
||||
const { browserDefaultSearchEngine, browserKagiSessionLink } = useAppStore.getState()
|
||||
// Why: the search-engine argument opts into search fallback; without it typed
|
||||
// queries parse as hosts ("google maps" -> https://google%20maps/).
|
||||
const url = normalizeBrowserNavigationUrl(rawValue, browserDefaultSearchEngine, {
|
||||
kagiSessionLink: browserKagiSessionLink
|
||||
})
|
||||
if (url) {
|
||||
// Why: client-hosted guests refuse file: by design (a remote page must not probe
|
||||
// this machine's disk). Saying so beats the blank tab that refusal used to produce.
|
||||
const fileUrlUnsupported =
|
||||
options?.allowFileUrls === false && Boolean(url) && url!.startsWith('file:')
|
||||
if (url && !fileUrlUnsupported) {
|
||||
return { status: 'navigate', url }
|
||||
}
|
||||
return {
|
||||
status: 'invalid',
|
||||
loadError: {
|
||||
code: 0,
|
||||
description: translate(
|
||||
'auto.components.browser.pane.BrowserPane.87eb75f7d2',
|
||||
'Enter a valid http(s) or localhost URL.'
|
||||
),
|
||||
description: fileUrlUnsupported
|
||||
? translate(
|
||||
'auto.components.browser.pane.BrowserPane.fileUrlUnsupported',
|
||||
'This browser tab cannot open local files. Use "Open Preview to the Side" on the file instead.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.browser.pane.BrowserPane.87eb75f7d2',
|
||||
'Enter a valid http(s) or localhost URL.'
|
||||
),
|
||||
// Why: validatedUrl is persisted, so redact a possible Kagi session token first.
|
||||
validatedUrl: redactKagiSessionToken(rawValue.trim()) || 'about:blank'
|
||||
}
|
||||
|
||||
+15
-9
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { BrowserNavigationControlRow } from '../assemble-chrome/browser-navigation-control-row'
|
||||
import BrowserAddressBar from '../assemble-chrome/BrowserAddressBar'
|
||||
import type { BrowserAddressBarEditSessionBinding } from '../assemble-chrome/use-browser-address-bar-edit-session'
|
||||
import { RemoteRuntimeEgressIndicator } from '../assemble-chrome/browser-egress-indicator'
|
||||
import { MarkupDrawButton } from '../annotate/MarkupDrawButton'
|
||||
@@ -54,15 +55,20 @@ export function RemoteBrowserPageToolbar({
|
||||
reload: onReload,
|
||||
navigate: onNavigateToUrl
|
||||
}}
|
||||
addressBarValue={addressBarValue}
|
||||
onAddressBarChange={onAddressBarChange}
|
||||
onSubmitAddressBar={onSubmitAddressBar}
|
||||
addressBarInputRef={addressBarInputRef}
|
||||
addressBarEditSession={addressBarEditSession}
|
||||
addressBarLeadingIcon={
|
||||
<RemoteRuntimeEgressIndicator
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
presentation="streamed"
|
||||
addressSlot={
|
||||
<BrowserAddressBar
|
||||
value={addressBarValue}
|
||||
onChange={onAddressBarChange}
|
||||
onSubmit={onSubmitAddressBar}
|
||||
onNavigate={onNavigateToUrl}
|
||||
inputRef={addressBarInputRef}
|
||||
editSession={addressBarEditSession}
|
||||
leadingIcon={
|
||||
<RemoteRuntimeEgressIndicator
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
presentation="streamed"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
reloadControl={
|
||||
|
||||
+1
-1
@@ -222,7 +222,7 @@ export function useRemoteBrowserPageNavigation({
|
||||
}, [isActive, keybindings, runRemoteNavigation])
|
||||
|
||||
const submitAddressBar = (): void => {
|
||||
const submission = resolveBrowserAddressBarSubmission(addressBarValue)
|
||||
const submission = resolveBrowserAddressBarSubmission(addressBarValue, { allowFileUrls: false })
|
||||
if (submission.status === 'invalid') {
|
||||
// 'direct': the only response to what the user just typed. With an empty address bar no
|
||||
// load-error overlay renders either, so outranking this would make Enter do nothing visible.
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The preview guest paints the handler's error body as if it were the document, so every
|
||||
// unreadable outcome arrives out-of-band on the failure channel. These pin that each reason
|
||||
// reaches the reader as its own sentence, and that a broken subresource cannot blank the page.
|
||||
//
|
||||
// The payloads here are the ones the reader can actually produce. The entry document is served as
|
||||
// text by every owner, so it fails only as too-large (a host-reported truncation) or unreadable (a
|
||||
// read error or a revoked grant); 'unsupported-asset' comes from a subresource whose format the
|
||||
// host declined to send — a font, say — and never from the document itself.
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { DocPreviewFailure } from '../../../../../shared/doc-preview-scheme'
|
||||
|
||||
const GRANT_ID = 'a'.repeat(32)
|
||||
const REMINTED_GRANT_ID = 'c'.repeat(32)
|
||||
const ENTRY_RELATIVE_PATH = 'doc.html'
|
||||
|
||||
const grantRuntime = vi.hoisted(() => ({ mints: 0, released: [] as string[] }))
|
||||
|
||||
vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
buildDocPreviewGrantRequest: () => ({
|
||||
owner: {
|
||||
kind: 'runtime' as const,
|
||||
environmentId: 'env-1',
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/repo'
|
||||
},
|
||||
root: '/repo/docs',
|
||||
entryRelativePath: ENTRY_RELATIVE_PATH
|
||||
}),
|
||||
ensureDocPreviewGrant: () => {
|
||||
grantRuntime.mints += 1
|
||||
// Why a fresh id per mint: a re-mint after a reconnect must bind the guest to the new grant.
|
||||
const grantId = grantRuntime.mints === 1 ? GRANT_ID : REMINTED_GRANT_ID
|
||||
return Promise.resolve({ grantId, url: `orca-preview://${grantId}/${ENTRY_RELATIVE_PATH}` })
|
||||
},
|
||||
releaseDocPreviewGrant: (previewId: string) => {
|
||||
grantRuntime.released.push(previewId)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
|
||||
moveFocusToRendererBeforeWebviewDetach: () => undefined
|
||||
}))
|
||||
|
||||
const storeState = {
|
||||
getKnownWorktreeById: () => ({ path: '/repo' }),
|
||||
persistedUIReady: true,
|
||||
settings: {},
|
||||
keybindings: {},
|
||||
browserAnnotationsByPageId: {} as Record<string, unknown[]>,
|
||||
activeGroupIdByWorktree: {} as Record<string, string>,
|
||||
agentSendPopoverTargetMode: null,
|
||||
openAgentSendPopoverTargetMode: () => undefined,
|
||||
closeAgentSendPopoverTargetMode: () => undefined,
|
||||
addBrowserPageAnnotation: () => undefined,
|
||||
deleteBrowserPageAnnotation: () => undefined,
|
||||
clearBrowserPageAnnotations: () => undefined,
|
||||
recordFeatureInteraction: () => undefined,
|
||||
openFile: () => 'file-1'
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector?: (state: typeof storeState) => unknown) =>
|
||||
selector ? selector(storeState) : storeState,
|
||||
{ getState: () => storeState }
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution-host-display-label', () => ({
|
||||
selectWorktreeHostDisplayLabel: () => 'Studio Mac mini'
|
||||
}))
|
||||
|
||||
const failureListeners: ((payload: DocPreviewFailure) => void)[] = []
|
||||
|
||||
function emitFailure(payload: DocPreviewFailure): void {
|
||||
for (const listener of failureListeners.slice()) {
|
||||
listener(payload)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPreview(container: HTMLDivElement, root: Root): Promise<void> {
|
||||
const { HtmlDocPreview } = await import('./HtmlDocPreview')
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<HtmlDocPreview
|
||||
previewId="preview-1"
|
||||
filePath="/repo/docs/doc.html"
|
||||
relativePath="docs/doc.html"
|
||||
worktreeId="wt-1"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
expect(container.querySelector('webview')).not.toBeNull()
|
||||
}
|
||||
|
||||
describe('HtmlDocPreview failure messages', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let mounted = false
|
||||
|
||||
beforeEach(() => {
|
||||
mounted = true
|
||||
failureListeners.length = 0
|
||||
grantRuntime.mints = 0
|
||||
grantRuntime.released = []
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
docPreview: {
|
||||
onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => {
|
||||
failureListeners.push(callback)
|
||||
return () => {
|
||||
const index = failureListeners.indexOf(callback)
|
||||
if (index !== -1) {
|
||||
failureListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ui: { writeClipboardText: () => Promise.resolve() },
|
||||
browser: { setAnnotationViewportBridge: () => Promise.resolve(true) }
|
||||
}
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (mounted) {
|
||||
act(() => root.unmount())
|
||||
mounted = false
|
||||
}
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('tells the reader the document is too large instead of showing a bare failure', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: ENTRY_RELATIVE_PATH, reason: 'too-large' })
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
'This document is too large to preview. Open it in the editor instead.'
|
||||
)
|
||||
})
|
||||
|
||||
// Why a notice and not the panel: the document rendered. Hiding it behind a failure screen would
|
||||
// take away a page the reader can use over one asset the workspace would not send.
|
||||
it('names the asset the workspace refused without hiding the document', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({
|
||||
grantId: GRANT_ID,
|
||||
relativePath: 'assets/inter.woff2',
|
||||
reason: 'unsupported-asset'
|
||||
})
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
'This workspace cannot send assets/inter.woff2 to a preview.'
|
||||
)
|
||||
expect(container.textContent).not.toContain('Preview unavailable')
|
||||
expect(container.querySelector('webview')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('names an unreadable or over-cap asset by path', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
'Orca could not read assets/logo.png from the workspace.'
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/data.json', reason: 'too-large' })
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('2 files in this document could not be loaded.')
|
||||
expect(container.textContent).not.toContain('Preview unavailable')
|
||||
})
|
||||
|
||||
it('counts each failing asset once, however often the guest retries it', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
'Orca could not read assets/logo.png from the workspace.'
|
||||
)
|
||||
expect(container.textContent).not.toContain('files in this document')
|
||||
})
|
||||
|
||||
// Why: nothing rendered, so the notice strip would be a footnote on a blank page.
|
||||
it('replaces the asset notice with the failure panel when the document itself fails', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
|
||||
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: ENTRY_RELATIVE_PATH, reason: 'too-large' })
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Preview unavailable')
|
||||
expect(container.textContent).not.toContain('assets/logo.png')
|
||||
expect(container.textContent).not.toContain('Downloads are disabled in document previews.')
|
||||
})
|
||||
|
||||
// Why the notice exists at all: the preview partition cancels the download before it starts, so
|
||||
// without this a pressed download button produces nothing the reader can tell from a bug.
|
||||
it('tells the reader a refused download was refused, without taking the document away', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Downloads are disabled in document previews.')
|
||||
expect(container.textContent).not.toContain('Preview unavailable')
|
||||
expect(container.querySelector('webview')).not.toBeNull()
|
||||
})
|
||||
|
||||
// Why: the document chooses when and how often to ask, so a per-attempt row would let a page
|
||||
// scroll Orca's own chrome off the screen.
|
||||
it('shows one refusal notice however often the document asks', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
|
||||
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
|
||||
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
|
||||
})
|
||||
|
||||
const notices = container.querySelectorAll('[role="status"]')
|
||||
expect(notices).toHaveLength(1)
|
||||
expect(notices[0]?.textContent).toContain('Downloads are disabled in document previews.')
|
||||
})
|
||||
|
||||
it('keeps a refused download and a failed asset as separate sentences', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, reason: 'download-blocked' })
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: 'assets/logo.png', reason: 'unreadable' })
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Downloads are disabled in document previews.')
|
||||
expect(container.textContent).toContain(
|
||||
'Orca could not read assets/logo.png from the workspace.'
|
||||
)
|
||||
// The asset count describes files the document could not load; a refusal is not one of them.
|
||||
expect(container.textContent).not.toContain('2 files in this document')
|
||||
})
|
||||
|
||||
it('ignores a refusal reported for another preview tab', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: 'b'.repeat(32), reason: 'download-blocked' })
|
||||
})
|
||||
|
||||
expect(container.textContent).not.toContain('Downloads are disabled in document previews.')
|
||||
})
|
||||
|
||||
it('falls back to the read failure for any other unreadable document', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: ENTRY_RELATIVE_PATH, reason: 'unreadable' })
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Orca could not read this file from the workspace.')
|
||||
})
|
||||
|
||||
it('ignores a failure minted for another preview tab', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
emitFailure({
|
||||
grantId: 'b'.repeat(32),
|
||||
relativePath: ENTRY_RELATIVE_PATH,
|
||||
reason: 'too-large'
|
||||
})
|
||||
})
|
||||
|
||||
expect(container.textContent).not.toContain('Preview unavailable')
|
||||
})
|
||||
|
||||
// Why: a grant is pinned to the owner ids it was minted with, so after a pairing or SSH
|
||||
// reconnect reloading the guest would just refetch the same failure forever.
|
||||
it('re-mints the grant when reload is pressed from a failure', async () => {
|
||||
await renderPreview(container, root)
|
||||
await act(async () => {
|
||||
emitFailure({ grantId: GRANT_ID, relativePath: ENTRY_RELATIVE_PATH, reason: 'unreadable' })
|
||||
})
|
||||
expect(grantRuntime.mints).toBe(1)
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('button[aria-label="Reload preview"]')?.click()
|
||||
})
|
||||
|
||||
expect(grantRuntime.released).toEqual(['preview-1'])
|
||||
expect(grantRuntime.mints).toBe(2)
|
||||
expect(container.querySelector('webview')?.getAttribute('src')).toContain(REMINTED_GRANT_ID)
|
||||
expect(container.textContent).not.toContain('Preview unavailable')
|
||||
})
|
||||
|
||||
it('reloads the guest in place when the document is rendering fine', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('button[aria-label="Reload preview"]')?.click()
|
||||
})
|
||||
|
||||
expect(grantRuntime.released).toEqual([])
|
||||
expect(grantRuntime.mints).toBe(1)
|
||||
})
|
||||
|
||||
it('unsubscribes on unmount so a late failure cannot touch a torn-down preview', async () => {
|
||||
await renderPreview(container, root)
|
||||
expect(failureListeners).toHaveLength(1)
|
||||
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
mounted = false
|
||||
})
|
||||
|
||||
expect(failureListeners).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
+582
@@ -0,0 +1,582 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The preview is an editor tab that has to read like a browser tab. These pin the parts of that
|
||||
// illusion a reader can catch us on: the document names itself and its owning machine instead of
|
||||
// showing the internal preview scheme, Back/Forward really drive the guest's history, and the chip
|
||||
// hands over the path the owner spells rather than the one the grant was minted with.
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { acquireWebviewsDragPassthrough } from '@/components/browser-pane/host-guest/webview-drag-passthrough'
|
||||
|
||||
const GRANT_ID = 'a'.repeat(32)
|
||||
// The draw-tool hint's own storage key; the hook that owns it keeps it private.
|
||||
const MARKUP_DRAW_HINT_SEEN_KEY = 'orca.browser.markup-draw-hint-seen'
|
||||
const ENTRY_RELATIVE_PATH = 'docs/reports/index.html'
|
||||
const ABSOLUTE_PATH = '/repo/docs/reports/index.html'
|
||||
|
||||
const clipboard = vi.hoisted(() => ({ writes: [] as string[] }))
|
||||
const grabCalls: { browserPageId: string; enabled: boolean }[] = []
|
||||
const osOpens: string[] = []
|
||||
|
||||
vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
buildDocPreviewGrantRequest: () => ({
|
||||
owner: {
|
||||
kind: 'runtime' as const,
|
||||
environmentId: 'env-1',
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/repo'
|
||||
},
|
||||
root: '/repo',
|
||||
entryRelativePath: ENTRY_RELATIVE_PATH
|
||||
}),
|
||||
ensureDocPreviewGrant: () =>
|
||||
Promise.resolve({
|
||||
grantId: GRANT_ID,
|
||||
url: `orca-preview://${GRANT_ID}/${ENTRY_RELATIVE_PATH}`
|
||||
}),
|
||||
releaseDocPreviewGrant: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
|
||||
moveFocusToRendererBeforeWebviewDetach: () => undefined
|
||||
}))
|
||||
|
||||
// The real one walks half the store to decide who owns a worktree; the chip only cares that
|
||||
// whatever it decides reaches the pill.
|
||||
vi.mock('@/lib/execution-host-display-label', () => ({
|
||||
selectWorktreeHostDisplayLabel: () => 'Studio Mac mini'
|
||||
}))
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
openedFiles: [] as unknown[],
|
||||
downloads: [] as string[],
|
||||
pageStateUpdates: [] as { pageId: string; updates: { title?: string } }[]
|
||||
}))
|
||||
|
||||
// The document lives on the SSH host that owns the workspace, which is what makes the preview a
|
||||
// preview at all — the client OS has no copy of it.
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionId: () => 'ssh-1',
|
||||
getConnectionIdForFile: () => 'ssh-1',
|
||||
getConnectionIdFromState: () => 'ssh-1'
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connection-owner-resolution', () => ({
|
||||
getConnectionIdForFileFromState: () => 'ssh-1'
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/terminal-remote-file-download-open', () => ({
|
||||
downloadAndOpenRemoteTerminalFile: (_context: unknown, filePath: string) => {
|
||||
store.downloads.push(filePath)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}))
|
||||
|
||||
const storeState = {
|
||||
getKnownWorktreeById: () => ({ path: '/repo' }),
|
||||
persistedUIReady: true,
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
keybindings: {},
|
||||
browserAnnotationsByPageId: {} as Record<string, unknown[]>,
|
||||
activeGroupIdByWorktree: {} as Record<string, string>,
|
||||
agentSendPopoverTargetMode: null,
|
||||
openAgentSendPopoverTargetMode: () => undefined,
|
||||
closeAgentSendPopoverTargetMode: () => undefined,
|
||||
addBrowserPageAnnotation: () => undefined,
|
||||
deleteBrowserPageAnnotation: () => undefined,
|
||||
clearBrowserPageAnnotations: () => undefined,
|
||||
recordFeatureInteraction: () => undefined,
|
||||
openFile: (file: unknown) => {
|
||||
store.openedFiles.push(file)
|
||||
return 'file-1'
|
||||
},
|
||||
updateBrowserPageState: (pageId: string, updates: { title?: string }) => {
|
||||
store.pageStateUpdates.push({ pageId, updates })
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector?: (state: typeof storeState) => unknown) =>
|
||||
selector ? selector(storeState) : storeState,
|
||||
{ getState: () => storeState }
|
||||
)
|
||||
}))
|
||||
|
||||
type StubWebview = Element & {
|
||||
canGoBack: () => boolean
|
||||
canGoForward: () => boolean
|
||||
goBack: () => void
|
||||
goForward: () => void
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
async function renderPreview(
|
||||
container: HTMLDivElement,
|
||||
root: Root,
|
||||
options: { holdsGuestFocus?: boolean } = {}
|
||||
): Promise<StubWebview> {
|
||||
const { HtmlDocPreview } = await import('./HtmlDocPreview')
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<HtmlDocPreview
|
||||
previewId="preview-1"
|
||||
filePath={ABSOLUTE_PATH}
|
||||
relativePath={ENTRY_RELATIVE_PATH}
|
||||
worktreeId="wt-1"
|
||||
holdsGuestFocus={options.holdsGuestFocus ?? false}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
const webview = container.querySelector('webview') as StubWebview | null
|
||||
expect(webview).not.toBeNull()
|
||||
// Why: the tools only arm once something has painted, so every case starts from a settled load.
|
||||
await act(async () => {
|
||||
webview?.dispatchEvent(new Event('did-stop-loading'))
|
||||
})
|
||||
return webview as StubWebview
|
||||
}
|
||||
|
||||
function stubHistory(
|
||||
webview: StubWebview,
|
||||
depth: { canGoBack: boolean; canGoForward: boolean }
|
||||
): { goBack: ReturnType<typeof vi.fn>; goForward: ReturnType<typeof vi.fn> } {
|
||||
const goBack = vi.fn()
|
||||
const goForward = vi.fn()
|
||||
webview.canGoBack = () => depth.canGoBack
|
||||
webview.canGoForward = () => depth.canGoForward
|
||||
webview.goBack = goBack
|
||||
webview.goForward = goForward
|
||||
webview.reload = vi.fn()
|
||||
return { goBack, goForward }
|
||||
}
|
||||
|
||||
function button(container: HTMLDivElement, label: string): HTMLButtonElement {
|
||||
const element = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
||||
expect(element).not.toBeNull()
|
||||
return element as HTMLButtonElement
|
||||
}
|
||||
|
||||
describe('HtmlDocPreview browser chrome', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let mounted = false
|
||||
|
||||
beforeEach(() => {
|
||||
mounted = true
|
||||
clipboard.writes = []
|
||||
store.openedFiles = []
|
||||
store.downloads = []
|
||||
grabCalls.length = 0
|
||||
osOpens.length = 0
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
docPreview: { onLoadFailure: () => () => undefined },
|
||||
ui: {
|
||||
writeClipboardText: (text: string) => {
|
||||
clipboard.writes.push(text)
|
||||
return Promise.resolve()
|
||||
},
|
||||
writeClipboardImage: () => Promise.resolve()
|
||||
},
|
||||
shell: {
|
||||
openFilePath: (filePath: string) => {
|
||||
osOpens.push(filePath)
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
},
|
||||
browser: {
|
||||
setGrabMode: (args: { browserPageId: string; enabled: boolean }) => {
|
||||
grabCalls.push(args)
|
||||
return Promise.resolve({ ok: true })
|
||||
},
|
||||
cancelGrab: () => Promise.resolve(true),
|
||||
awaitGrabSelection: () => new Promise(() => {}),
|
||||
captureSelectionScreenshot: () => Promise.resolve({ ok: false }),
|
||||
setAnnotationViewportBridge: () => Promise.resolve(true)
|
||||
}
|
||||
}
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (mounted) {
|
||||
act(() => root.unmount())
|
||||
mounted = false
|
||||
}
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('identifies the document by its workspace path and owning machine', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
const text = container.textContent ?? ''
|
||||
expect(text).toContain('docs/reports/')
|
||||
expect(text).toContain('index.html')
|
||||
expect(text).toContain('Workspace file')
|
||||
expect(text).toContain('Studio Mac mini')
|
||||
})
|
||||
|
||||
// The internal scheme is an implementation detail of how the workspace hands bytes to the guest.
|
||||
// The guest's own src attribute necessarily carries it; no other node in the tree may.
|
||||
it('never shows the internal preview scheme to the reader', async () => {
|
||||
const webview = await renderPreview(container, root)
|
||||
|
||||
expect(webview.getAttribute('src')).toContain('orca-preview')
|
||||
const withoutGuest = container.cloneNode(true) as HTMLElement
|
||||
for (const guest of withoutGuest.querySelectorAll('webview')) {
|
||||
guest.remove()
|
||||
}
|
||||
expect(withoutGuest.innerHTML).not.toContain('orca-preview')
|
||||
})
|
||||
|
||||
// Why this is a drag bug and not a styling one: a <webview> takes the pointer stream the
|
||||
// document never sees, so a tab drag stops getting pointermove the moment the cursor crosses
|
||||
// the preview — the dragged tab stops following the cursor and the split cannot be dropped.
|
||||
it('holds the guest click-through while a renderer drag is in flight', async () => {
|
||||
const webview = await renderPreview(container, root)
|
||||
|
||||
const guest = webview as unknown as HTMLElement
|
||||
let release: (() => void) | null = null
|
||||
expect(guest.style.pointerEvents).toBe('')
|
||||
|
||||
await act(async () => {
|
||||
release = acquireWebviewsDragPassthrough()
|
||||
})
|
||||
expect(guest.style.pointerEvents).toBe('none')
|
||||
|
||||
await act(async () => release?.())
|
||||
expect(guest.style.pointerEvents).toBe('')
|
||||
})
|
||||
|
||||
// Why append time and not the enrolling effect: dragging the preview's OWN tab across a split
|
||||
// remounts this component mid-drag, and an effect settles a turn later — for the rest of that
|
||||
// turn the fresh guest is hittable and eats the pointer stream, which is the original freeze.
|
||||
it('holds a guest that appears mid-drag click-through the moment it is appended', async () => {
|
||||
const appendedPointerEvents: string[] = []
|
||||
const originalAppendChild = HTMLElement.prototype.appendChild
|
||||
HTMLElement.prototype.appendChild = function <T extends Node>(node: T): T {
|
||||
const element = node as unknown as HTMLElement
|
||||
if (element.tagName?.toLowerCase() === 'webview') {
|
||||
appendedPointerEvents.push(element.style.pointerEvents)
|
||||
}
|
||||
return originalAppendChild.call(this, node) as T
|
||||
}
|
||||
const release = acquireWebviewsDragPassthrough()
|
||||
|
||||
try {
|
||||
await renderPreview(container, root)
|
||||
expect(appendedPointerEvents).toEqual(['none'])
|
||||
} finally {
|
||||
HTMLElement.prototype.appendChild = originalAppendChild
|
||||
release()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the chip sits in the row's height-pinned slot, and a wrapper of its own between the two
|
||||
// would leave it its natural height again — the toolbar would shrink for document tabs.
|
||||
it('hands the identity chip straight to the height-pinned address slot', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
const chip = button(container, 'Copy file path')
|
||||
const slot = container.querySelector('[data-browser-chrome-address-slot]')
|
||||
expect(slot).not.toBeNull()
|
||||
expect(chip.parentElement).toBe(slot)
|
||||
expect(chip.className).not.toMatch(/(^|\s)h-/)
|
||||
})
|
||||
|
||||
// Browser parity: a browser tab is named by the document it shows. What the document names is
|
||||
// the tab; what it must never rename is the chip, which is the reader's only proof of which
|
||||
// file on which host they are looking at.
|
||||
it('lets the document name its tab while the chip keeps naming the file', async () => {
|
||||
const webview = await renderPreview(container, root)
|
||||
store.pageStateUpdates.length = 0
|
||||
|
||||
await act(async () => {
|
||||
const event = new Event('page-title-updated')
|
||||
Object.assign(event, { title: 'Quarterly Report' })
|
||||
webview.dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(store.pageStateUpdates).toEqual([
|
||||
{ pageId: 'preview-1', updates: { title: 'Quarterly Report' } }
|
||||
])
|
||||
expect(button(container, 'Copy file path').textContent).toContain(ENTRY_RELATIVE_PATH)
|
||||
})
|
||||
|
||||
// Why: the browsing tour walks anchors by name, and a preview answering to the browser pane's
|
||||
// anchors would hand it steps about profiles and cookies that a document tab does not have.
|
||||
it('claims none of the browsing tour anchors', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
expect(container.querySelector('[data-contextual-tour-target]')).toBeNull()
|
||||
})
|
||||
|
||||
it('copies the absolute path the owning machine spells, not the workspace-relative one', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
button(container, 'Copy file path').click()
|
||||
})
|
||||
|
||||
expect(clipboard.writes).toEqual([ABSOLUTE_PATH])
|
||||
// The icon swap alone says nothing to a screen reader, so the control renames itself.
|
||||
expect(button(container, 'Copied')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('starts with both history controls disabled', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
expect(button(container, 'Back').disabled).toBe(true)
|
||||
expect(button(container, 'Forward').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables Back once the guest has somewhere to go back to and drives the guest', async () => {
|
||||
const webview = await renderPreview(container, root)
|
||||
const { goBack, goForward } = stubHistory(webview, { canGoBack: true, canGoForward: false })
|
||||
|
||||
await act(async () => {
|
||||
webview.dispatchEvent(new Event('did-navigate'))
|
||||
})
|
||||
|
||||
expect(button(container, 'Back').disabled).toBe(false)
|
||||
expect(button(container, 'Forward').disabled).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
button(container, 'Back').click()
|
||||
button(container, 'Forward').click()
|
||||
})
|
||||
|
||||
expect(goBack).toHaveBeenCalledTimes(1)
|
||||
// Why: a disabled edge control must be inert, not merely dimmed.
|
||||
expect(goForward).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Fragment links navigate in-document, which is still a history entry a reader expects Back to
|
||||
// unwind — the guest reports it on a different event than a full navigation.
|
||||
it('tracks in-document navigation as history too', async () => {
|
||||
const webview = await renderPreview(container, root)
|
||||
stubHistory(webview, { canGoBack: true, canGoForward: true })
|
||||
|
||||
await act(async () => {
|
||||
webview.dispatchEvent(new Event('did-navigate-in-page'))
|
||||
})
|
||||
|
||||
expect(button(container, 'Back').disabled).toBe(false)
|
||||
expect(button(container, 'Forward').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('still reloads the guest in place from the toolbar', async () => {
|
||||
const webview = await renderPreview(container, root)
|
||||
stubHistory(webview, { canGoBack: false, canGoForward: false })
|
||||
|
||||
await act(async () => {
|
||||
button(container, 'Reload preview').click()
|
||||
})
|
||||
|
||||
expect(webview.reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('tools', () => {
|
||||
it('offers the same tool cluster the browsing pane does', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
for (const label of [
|
||||
'Grab page element',
|
||||
'Annotate page element',
|
||||
'Draw on screenshot',
|
||||
'Open source file',
|
||||
'Open with default app',
|
||||
'Preview options'
|
||||
]) {
|
||||
expect(button(container, label)).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: cookies land in a browsing session, and a preview reads workspace disk over a grant —
|
||||
// there is no session for an import to reach.
|
||||
it('hides cookie import, which a preview has no session for', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
expect(container.querySelector('button[aria-label="Import cookies from browser"]')).toBeNull()
|
||||
})
|
||||
|
||||
// The picker is driven through main, which resolves the page to whichever guest is rendering
|
||||
// this document now — so the id it sends is the page, not the grant a re-mint would replace.
|
||||
it('arms the element picker against the page the document is open in', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
button(container, 'Grab page element').click()
|
||||
})
|
||||
|
||||
expect(grabCalls).toEqual([{ browserPageId: 'preview-1', enabled: true }])
|
||||
})
|
||||
|
||||
it('opens the document source as its own editor tab', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
button(container, 'Open source file').click()
|
||||
})
|
||||
|
||||
expect(store.openedFiles).toEqual([
|
||||
expect.objectContaining({
|
||||
filePath: ABSOLUTE_PATH,
|
||||
relativePath: ENTRY_RELATIVE_PATH,
|
||||
worktreeId: 'wt-1',
|
||||
mode: 'edit'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
// Why: a preview is a remote document by construction — the client OS has no copy to launch,
|
||||
// so "open externally" has to download first.
|
||||
it('downloads a runtime-owned document before handing it to the OS', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
button(container, 'Open with default app').click()
|
||||
})
|
||||
|
||||
expect(store.downloads).toEqual([ABSOLUTE_PATH])
|
||||
expect(osOpens).toEqual([])
|
||||
})
|
||||
|
||||
// Why the storage flag and not the popover: the nudge fires once per install, and the harm is
|
||||
// consuming that one view — a reader who opened a document would spend the browsing pane's
|
||||
// introduction to a tool they were not shown.
|
||||
it('never spends the once-per-install draw-tool hint', async () => {
|
||||
window.localStorage.removeItem(MARKUP_DRAW_HINT_SEEN_KEY)
|
||||
|
||||
await renderPreview(container, root)
|
||||
|
||||
expect(window.localStorage.getItem(MARKUP_DRAW_HINT_SEEN_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Why it has to be somewhere: the preview hides the editor's path header, and the relative path
|
||||
// was only ever copyable from there — the chip and the menu both give the absolute one.
|
||||
it('still offers the workspace-relative path the hidden editor header used to copy', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
await act(async () => {
|
||||
// Why not click(): the Radix trigger opens on pointerdown, which happy-dom does not synthesize.
|
||||
button(container, 'Preview options').dispatchEvent(
|
||||
new window.PointerEvent('pointerdown', { bubbles: true, button: 0 })
|
||||
)
|
||||
})
|
||||
const relativeCopy = [...document.querySelectorAll('[role="menuitem"]')].find((item) =>
|
||||
item.textContent?.includes('Copy relative path')
|
||||
)
|
||||
expect(relativeCopy).toBeDefined()
|
||||
|
||||
await act(async () => {
|
||||
;(relativeCopy as HTMLElement).click()
|
||||
})
|
||||
|
||||
expect(clipboard.writes).toEqual([ENTRY_RELATIVE_PATH])
|
||||
})
|
||||
})
|
||||
|
||||
// Why this is a test and not left to the pane: main answers a reported link click only from a
|
||||
// focused guest, so a preview whose guest never takes focus has no route out at all — the failure
|
||||
// is silent, and only the reader pressing a link ever sees it.
|
||||
describe('HtmlDocPreview guest focus', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let focused: Element[]
|
||||
let focusSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
focused = []
|
||||
focusSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'focus')
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
focused.push(this)
|
||||
})
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
focusSpy.mockRestore()
|
||||
})
|
||||
|
||||
async function settleFrames(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
}
|
||||
|
||||
it('hands the guest focus once the preview is the surface the reader is in', async () => {
|
||||
const webview = await renderPreview(container, root, { holdsGuestFocus: true })
|
||||
await settleFrames()
|
||||
|
||||
expect(focused).toContain(webview)
|
||||
})
|
||||
|
||||
it('leaves focus alone while the reader is looking at something else', async () => {
|
||||
const webview = await renderPreview(container, root, { holdsGuestFocus: false })
|
||||
await settleFrames()
|
||||
|
||||
expect(focused).not.toContain(webview)
|
||||
|
||||
// And the window coming back does not reopen the offer a preview behind a terminal refused.
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
await settleFrames()
|
||||
|
||||
expect(focused).not.toContain(webview)
|
||||
})
|
||||
|
||||
// Why the window's own focus has to re-offer: another app taking the front pulls focus out of the
|
||||
// guest, and coming back lands it on the embedder. The guest is where a clicked link is reported
|
||||
// from, so without this the route out of the preview stays shut until something remounts it.
|
||||
it('offers the guest focus again after the window gets it back', async () => {
|
||||
const webview = await renderPreview(container, root, { holdsGuestFocus: true })
|
||||
await settleFrames()
|
||||
focused.length = 0
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
await settleFrames()
|
||||
|
||||
expect(focused).toContain(webview)
|
||||
})
|
||||
|
||||
// Why that re-offer has to yield: the window also gets focus back when the reader presses a tab,
|
||||
// because the guest holding the keyboard is what blurred the embedder. Taking it back from there
|
||||
// fights the reader for their own click.
|
||||
it('leaves focus with whatever claimed it when the window comes back', async () => {
|
||||
const webview = await renderPreview(container, root, { holdsGuestFocus: true })
|
||||
await settleFrames()
|
||||
focused.length = 0
|
||||
|
||||
const claimant = document.createElement('button')
|
||||
document.body.append(claimant)
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
configurable: true,
|
||||
get: () => claimant
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
await settleFrames()
|
||||
|
||||
expect(focused).not.toContain(webview)
|
||||
Reflect.deleteProperty(document, 'activeElement')
|
||||
claimant.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
DOC_PREVIEW_PARTITION,
|
||||
type DocPreviewFileFailure,
|
||||
type DocPreviewFileFailureReason
|
||||
} from '../../../../../shared/doc-preview-scheme'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences'
|
||||
import { BrowserGuestAnnotateOverlays } from '@/components/browser-pane/annotate/browser-guest-annotate-overlays'
|
||||
import { useGuestDragPassthrough } from '@/components/browser-pane/host-guest/use-guest-drag-passthrough'
|
||||
import { isWebviewDragPassthroughActive } from '@/components/browser-pane/host-guest/webview-drag-passthrough'
|
||||
import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/host-guest/webview-registry'
|
||||
import {
|
||||
buildDocPreviewGrantRequest,
|
||||
ensureDocPreviewGrant,
|
||||
releaseDocPreviewGrant
|
||||
} from '@/lib/doc-preview-grants'
|
||||
import { selectWorktreeHostDisplayLabel } from '@/lib/execution-host-display-label'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { openDocPreviewExternally, openDocPreviewSource } from './doc-preview-document-actions'
|
||||
import { buildDocPreviewDocumentIdentity } from './doc-preview-document-identity'
|
||||
import {
|
||||
docPreviewAssetNotice,
|
||||
docPreviewDownloadBlockedNotice,
|
||||
docPreviewFailureDetail
|
||||
} from './doc-preview-failure-messages'
|
||||
import { DocPreviewToolbar } from './doc-preview-toolbar'
|
||||
import { useDocPreviewWebviewHistory } from './doc-preview-webview-history'
|
||||
import { useDocPreviewGuestTools } from './use-doc-preview-guest-tools'
|
||||
|
||||
type PreviewState = 'loading' | 'ready' | 'unavailable'
|
||||
|
||||
function attachDocPreviewWebview({
|
||||
container,
|
||||
url,
|
||||
ariaLabel,
|
||||
onLoadStarted,
|
||||
onLoadStopped,
|
||||
onLoadFailed,
|
||||
onNavigated,
|
||||
onTitleUpdated
|
||||
}: {
|
||||
container: HTMLDivElement
|
||||
url: string
|
||||
ariaLabel: string
|
||||
onLoadStarted: () => void
|
||||
onLoadStopped: () => void
|
||||
onLoadFailed: (event: Electron.DidFailLoadEvent) => void
|
||||
onNavigated: () => void
|
||||
onTitleUpdated: (event: Electron.PageTitleUpdatedEvent) => void
|
||||
}): { webview: Electron.WebviewTag; detach: () => void; reload: () => void } {
|
||||
const webview = document.createElement('webview') as Electron.WebviewTag
|
||||
// Why no allowpopups: the guest's preload intercepts a trusted click on a link before Chromium
|
||||
// considers a popup at all, so target="_blank" needs no popup path and every one stays denied.
|
||||
webview.setAttribute('partition', DOC_PREVIEW_PARTITION)
|
||||
webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)
|
||||
webview.setAttribute('aria-label', ariaLabel)
|
||||
// Browsers paint an undeclared page canvas white; the guest is transparent, so without this the
|
||||
// editor's dark surface shows through and default black text becomes unreadable.
|
||||
webview.style.backgroundColor = '#fff'
|
||||
webview.style.display = 'flex'
|
||||
webview.style.width = '100%'
|
||||
webview.style.height = '100%'
|
||||
webview.style.border = 'none'
|
||||
webview.addEventListener('did-start-loading', onLoadStarted)
|
||||
webview.addEventListener('did-stop-loading', onLoadStopped)
|
||||
webview.addEventListener('did-fail-load', onLoadFailed)
|
||||
// Both: a link to a sibling document is a full navigation, a fragment link is an in-page one,
|
||||
// and only the pair together tracks what Back can actually return to.
|
||||
webview.addEventListener('did-navigate', onNavigated)
|
||||
webview.addEventListener('did-navigate-in-page', onNavigated)
|
||||
// Why the document names its own tab: a preview is a browser tab, and this is how every other
|
||||
// one is named. What the document cannot do is name it the grant it is served over.
|
||||
webview.addEventListener('page-title-updated', onTitleUpdated)
|
||||
// Why here and not in the enrolling hook: appending is what makes this guest hittable, and the
|
||||
// registry's contract is that the path doing so settles it. Dragging the preview's own tab
|
||||
// remounts this component mid-drag, and a hook effect lands a turn too late — for the rest of
|
||||
// that turn the fresh guest eats the pointer stream and the drag freezes.
|
||||
if (isWebviewDragPassthroughActive()) {
|
||||
webview.style.pointerEvents = 'none'
|
||||
}
|
||||
container.appendChild(webview)
|
||||
webview.setAttribute('src', url)
|
||||
|
||||
return {
|
||||
webview,
|
||||
detach: () => {
|
||||
webview.removeEventListener('did-start-loading', onLoadStarted)
|
||||
webview.removeEventListener('did-stop-loading', onLoadStopped)
|
||||
webview.removeEventListener('did-fail-load', onLoadFailed)
|
||||
webview.removeEventListener('did-navigate', onNavigated)
|
||||
webview.removeEventListener('did-navigate-in-page', onNavigated)
|
||||
webview.removeEventListener('page-title-updated', onTitleUpdated)
|
||||
moveFocusToRendererBeforeWebviewDetach(webview)
|
||||
webview.remove()
|
||||
},
|
||||
// Why: the protocol handler answers with no-store, so a reload re-reads the workspace disk.
|
||||
reload: () => {
|
||||
try {
|
||||
webview.reload()
|
||||
} catch {
|
||||
webview.setAttribute('src', url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Frames a preview keeps offering focus to a guest that is still attaching. */
|
||||
const GUEST_FOCUS_FRAMES = 10
|
||||
|
||||
export function HtmlDocPreview({
|
||||
previewId,
|
||||
filePath,
|
||||
relativePath,
|
||||
worktreeId,
|
||||
holdsGuestFocus = false,
|
||||
runtimeEnvironmentId = null,
|
||||
externalSshTargetId = null
|
||||
}: {
|
||||
previewId: string
|
||||
filePath: string
|
||||
relativePath: string
|
||||
worktreeId: string
|
||||
/** Whether this preview is the surface the reader is in, and so may hold the keyboard. */
|
||||
holdsGuestFocus?: boolean
|
||||
runtimeEnvironmentId?: string | null
|
||||
externalSshTargetId?: string | null
|
||||
}): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const webviewRef = useRef<Electron.WebviewTag | null>(null)
|
||||
const reloadRef = useRef<(() => void) | null>(null)
|
||||
const [state, setState] = useState<PreviewState>('loading')
|
||||
const [failureReason, setFailureReason] = useState<DocPreviewFileFailureReason | null>(null)
|
||||
const [assetFailures, setAssetFailures] = useState<DocPreviewFileFailure[]>([])
|
||||
const [downloadBlocked, setDownloadBlocked] = useState(false)
|
||||
const [remintCount, setRemintCount] = useState(0)
|
||||
const [grantId, setGrantId] = useState<string | null>(null)
|
||||
|
||||
const history = useDocPreviewWebviewHistory(webviewRef)
|
||||
const { sync: syncHistory, reset: resetHistory } = history
|
||||
|
||||
const worktreeRoot = useAppStore((store) => store.getKnownWorktreeById(worktreeId)?.path ?? null)
|
||||
const hostLabel = useAppStore((store) => selectWorktreeHostDisplayLabel(store, worktreeId))
|
||||
const identity = useMemo(
|
||||
() => buildDocPreviewDocumentIdentity({ filePath, worktreeRoot, hostLabel }),
|
||||
[filePath, hostLabel, worktreeRoot]
|
||||
)
|
||||
const isUnavailable = state === 'unavailable' || failureReason !== null
|
||||
useGuestDragPassthrough(webviewRef, grantId)
|
||||
const { grab, markup, annotationSend, grabAnnotations, browserOverlayViewport, elementTools } =
|
||||
useDocPreviewGuestTools({
|
||||
previewId,
|
||||
worktreeId,
|
||||
grantId,
|
||||
webviewRef,
|
||||
containerRef,
|
||||
toolsReady: state === 'ready' && !isUnavailable
|
||||
})
|
||||
// Not `document`: shadowing the global inside a component is how a stray DOM call silently
|
||||
// starts reading a plain object.
|
||||
const previewDocument = useMemo(
|
||||
() => ({ filePath, relativePath, worktreeId, runtimeEnvironmentId, externalSshTargetId }),
|
||||
[externalSshTargetId, filePath, relativePath, runtimeEnvironmentId, worktreeId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let detach: (() => void) | undefined
|
||||
let loadFailed = false
|
||||
const onLoadStarted = (): void => {
|
||||
loadFailed = false
|
||||
setFailureReason(null)
|
||||
setAssetFailures([])
|
||||
setDownloadBlocked(false)
|
||||
setState('loading')
|
||||
}
|
||||
const onLoadStopped = (): void => {
|
||||
// Why sync here too: a navigation's history entry is only committed once loading settles.
|
||||
syncHistory()
|
||||
if (!loadFailed) {
|
||||
setState('ready')
|
||||
}
|
||||
}
|
||||
const onLoadFailed = (event: Electron.DidFailLoadEvent): void => {
|
||||
if (!event.isMainFrame || event.errorCode === -3) {
|
||||
return
|
||||
}
|
||||
loadFailed = true
|
||||
setState('unavailable')
|
||||
}
|
||||
|
||||
setState('loading')
|
||||
setFailureReason(null)
|
||||
setAssetFailures([])
|
||||
setDownloadBlocked(false)
|
||||
setGrantId(null)
|
||||
resetHistory()
|
||||
const request = buildDocPreviewGrantRequest(useAppStore.getState(), worktreeId, filePath)
|
||||
if (!request) {
|
||||
setState('unavailable')
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}
|
||||
// Why: an unreadable document answers with a status the guest renders as text, so the reason
|
||||
// arrives out-of-band. Subscribe before minting so the entry document's failure cannot be missed.
|
||||
let boundGrantId: string | null = null
|
||||
const unsubscribeFailure = window.api.docPreview?.onLoadFailure?.((payload) => {
|
||||
if (disposed || payload.grantId !== boundGrantId) {
|
||||
return
|
||||
}
|
||||
// Why first: a refused download is the fences answering for the reader, not the document
|
||||
// failing to load, so it can never take the page away — and it names no file to compare.
|
||||
if (payload.reason === 'download-blocked') {
|
||||
setDownloadBlocked(true)
|
||||
return
|
||||
}
|
||||
if (payload.relativePath === request.entryRelativePath) {
|
||||
setFailureReason(payload.reason)
|
||||
return
|
||||
}
|
||||
setAssetFailures((current) =>
|
||||
current.some((failure) => failure.relativePath === payload.relativePath)
|
||||
? current
|
||||
: [...current, payload]
|
||||
)
|
||||
})
|
||||
void ensureDocPreviewGrant(previewId, request)
|
||||
.then((handle) => {
|
||||
boundGrantId = handle.grantId
|
||||
if (disposed || !containerRef.current) {
|
||||
return
|
||||
}
|
||||
const attached = attachDocPreviewWebview({
|
||||
container: containerRef.current,
|
||||
url: handle.url,
|
||||
ariaLabel: translate(
|
||||
'auto.components.editor.HtmlDocPreview.previewAriaLabel',
|
||||
'HTML preview'
|
||||
),
|
||||
onLoadStarted,
|
||||
onLoadStopped,
|
||||
onLoadFailed,
|
||||
onNavigated: syncHistory,
|
||||
onTitleUpdated: (event) => {
|
||||
useAppStore.getState().updateBrowserPageState(previewId, { title: event.title })
|
||||
}
|
||||
})
|
||||
detach = attached.detach
|
||||
reloadRef.current = attached.reload
|
||||
webviewRef.current = attached.webview
|
||||
// Why only now: main binds this grant to the guest on its first commit, so the tools have
|
||||
// nothing to name until the webview exists and is pointed at it.
|
||||
setGrantId(handle.grantId)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) {
|
||||
setState('unavailable')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
reloadRef.current = null
|
||||
webviewRef.current = null
|
||||
unsubscribeFailure?.()
|
||||
detach?.()
|
||||
}
|
||||
}, [filePath, previewId, remintCount, resetHistory, syncHistory, worktreeId])
|
||||
|
||||
// Why the guest is handed focus rather than left to the press that opens a link: main answers a
|
||||
// reported link click only from a focused guest, and a preview has no chrome of its own to pass
|
||||
// focus on — a URL page's address bar is what hands it over. Without this the one route out of a
|
||||
// preview stays shut until something else happens to focus the document.
|
||||
useEffect(() => {
|
||||
if (!holdsGuestFocus || state !== 'ready') {
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
let attempts = 0
|
||||
let claimedOnly = false
|
||||
const focusGuest = (): void => {
|
||||
const webview = webviewRef.current
|
||||
attempts += 1
|
||||
// Why a re-offer yields: it is a handoff for focus nothing else wanted, and the reader
|
||||
// pressing a tab lands here first. Taking it back would fight them for the keyboard, which
|
||||
// is what shut the tab strip while a preview was open.
|
||||
if (
|
||||
claimedOnly &&
|
||||
document.activeElement !== document.body &&
|
||||
document.activeElement !== webview
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
webview?.focus()
|
||||
} catch {
|
||||
// Why swallowed: WebViewElement.focus() reads null internals once the guest is destroyed.
|
||||
return
|
||||
}
|
||||
// Why retried: the guest takes focus only once it is attached and laid out, a frame or two
|
||||
// after it reports ready.
|
||||
if (document.activeElement !== webview && attempts < GUEST_FOCUS_FRAMES) {
|
||||
frameId = window.requestAnimationFrame(focusGuest)
|
||||
}
|
||||
}
|
||||
const offerFocus = (yieldToOtherClaims: boolean): void => {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
attempts = 0
|
||||
claimedOnly = yieldToOtherClaims
|
||||
frameId = window.requestAnimationFrame(focusGuest)
|
||||
}
|
||||
// Why assertive: the reader just made this preview their surface, so the handoff is the point.
|
||||
offerFocus(false)
|
||||
// Why re-offered on the window's own focus: another app taking the front takes focus out of the
|
||||
// guest, and coming back puts it on the embedder. Nothing hands it on, so the route out of the
|
||||
// preview would stay shut until something remounted it.
|
||||
const reofferFocus = (): void => offerFocus(true)
|
||||
window.addEventListener('focus', reofferFocus)
|
||||
return () => {
|
||||
window.removeEventListener('focus', reofferFocus)
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}, [holdsGuestFocus, previewId, remintCount, state])
|
||||
|
||||
// Why: a grant is pinned to the owner ids resolved when it was minted, so after a pairing or
|
||||
// SSH reconnect the old one reads nothing and reloading the guest would just refetch the
|
||||
// failure. Drop it and mint against today's ids instead of making the user close the tab.
|
||||
const handleHardReload = useCallback(() => {
|
||||
releaseDocPreviewGrant(previewId)
|
||||
setRemintCount((count) => count + 1)
|
||||
}, [previewId])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (failureReason !== null || state === 'unavailable') {
|
||||
handleHardReload()
|
||||
return
|
||||
}
|
||||
reloadRef.current?.()
|
||||
}, [failureReason, handleHardReload, state])
|
||||
|
||||
// Nothing rendered on an unavailable preview, so a notice strip would be a footnote on a blank page.
|
||||
const notices = isUnavailable
|
||||
? []
|
||||
: [
|
||||
downloadBlocked ? docPreviewDownloadBlockedNotice() : null,
|
||||
docPreviewAssetNotice(assetFailures)
|
||||
].filter((notice): notice is string => notice !== null)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-editor-surface">
|
||||
<DocPreviewToolbar
|
||||
identity={identity}
|
||||
history={history}
|
||||
loading={state === 'loading' && failureReason === null}
|
||||
onReload={handleReload}
|
||||
onHardReload={handleHardReload}
|
||||
onCopyPath={() => void window.api.ui.writeClipboardText(identity.absolutePath)}
|
||||
onCopyRelativePath={() => void window.api.ui.writeClipboardText(relativePath)}
|
||||
onOpenSource={() => openDocPreviewSource(previewDocument)}
|
||||
onOpenExternally={() => openDocPreviewExternally(previewDocument)}
|
||||
elementTools={elementTools}
|
||||
markupActive={markup.isActive}
|
||||
onToggleMarkup={() => (markup.isActive ? markup.cancel() : void markup.start())}
|
||||
// Nothing has painted yet on a loading or failed preview, so there is nothing to draw on.
|
||||
markupDisabled={isUnavailable || state !== 'ready' || grab.state !== 'idle'}
|
||||
/>
|
||||
{notices.map((notice) => (
|
||||
<div
|
||||
key={notice}
|
||||
className="flex shrink-0 items-center gap-1.5 border-b px-2 py-1 text-xs text-muted-foreground"
|
||||
role="status"
|
||||
title={notice}
|
||||
>
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{notice}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden" ref={containerRef}>
|
||||
<BrowserGuestAnnotateOverlays
|
||||
markup={markup}
|
||||
grab={grab}
|
||||
annotationSend={annotationSend}
|
||||
grabAnnotations={grabAnnotations}
|
||||
containerRef={containerRef}
|
||||
webviewRef={webviewRef}
|
||||
browserOverlayViewport={browserOverlayViewport}
|
||||
worktreeId={worktreeId}
|
||||
/>
|
||||
{state === 'loading' && failureReason === null ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-editor-surface">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
{isUnavailable ? (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-editor-surface px-6 text-center">
|
||||
<AlertCircle className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.editor.HtmlDocPreview.previewUnavailableTitle',
|
||||
'Preview unavailable'
|
||||
)}
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
{docPreviewFailureDetail(failureReason)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
/** What the per-file resolver answers: null local, a string remote, undefined unresolved. */
|
||||
connectionIdForFile: null as string | null | undefined,
|
||||
/** What the workspace-scoped resolver answers, which is not always the same thing. */
|
||||
connectionIdForWorkspace: null as string | null | undefined,
|
||||
worktreePath: '/srv/repo' as string | null,
|
||||
/** What the worktree resolves its runtime owner to, which the grant was minted against. */
|
||||
worktreeRuntimeOwnerId: null as string | null,
|
||||
activeRuntimeEnvironmentId: undefined as string | undefined,
|
||||
openFile: vi.fn(),
|
||||
openFilePath: vi.fn().mockResolvedValue(true),
|
||||
downloadAndOpen: vi.fn(),
|
||||
toastError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, params?: Record<string, string>) =>
|
||||
fallback.replace('{{value0}}', params?.value0 ?? '')
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connection-owner-resolution', () => ({
|
||||
getConnectionIdForFileFromState: () => mocks.connectionIdForFile
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => mocks.worktreeRuntimeOwnerId
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionId: () => mocks.connectionIdForWorkspace,
|
||||
getConnectionIdForFile: () => mocks.connectionIdForFile
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/terminal-remote-file-download-open', () => ({
|
||||
downloadAndOpenRemoteTerminalFile: mocks.downloadAndOpen
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: mocks.activeRuntimeEnvironmentId
|
||||
},
|
||||
getKnownWorktreeById: () =>
|
||||
mocks.worktreePath === null ? undefined : { id: 'wt-1', path: mocks.worktreePath },
|
||||
openFile: mocks.openFile
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import { openDocPreviewExternally, openDocPreviewSource } from './doc-preview-document-actions'
|
||||
|
||||
const REMOTE_DOCUMENT = {
|
||||
filePath: '/root/demo/report/index.html',
|
||||
relativePath: 'report/index.html',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: null,
|
||||
externalSshTargetId: null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.connectionIdForFile = null
|
||||
mocks.connectionIdForWorkspace = null
|
||||
mocks.worktreePath = '/srv/repo'
|
||||
mocks.worktreeRuntimeOwnerId = null
|
||||
mocks.activeRuntimeEnvironmentId = undefined
|
||||
mocks.openFilePath.mockResolvedValue(true)
|
||||
vi.stubGlobal('window', { api: { shell: { openFilePath: mocks.openFilePath } } })
|
||||
})
|
||||
|
||||
describe('openDocPreviewSource', () => {
|
||||
it('opens the document as an ordinary source tab with the editor language', () => {
|
||||
openDocPreviewSource(REMOTE_DOCUMENT)
|
||||
|
||||
expect(mocks.openFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: REMOTE_DOCUMENT.filePath,
|
||||
relativePath: REMOTE_DOCUMENT.relativePath,
|
||||
worktreeId: 'wt-1',
|
||||
language: 'html',
|
||||
mode: 'edit'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Why this field and not the rest: an absolute SSH path outside the worktree is indistinguishable
|
||||
// from a client-local external file, so dropping it makes the reopened tab read the wrong host.
|
||||
it('carries the external SSH target onto the source tab', () => {
|
||||
openDocPreviewSource({ ...REMOTE_DOCUMENT, externalSshTargetId: 'ssh-7' })
|
||||
|
||||
expect(mocks.openFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ externalSshTargetId: 'ssh-7' })
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the field entirely when the document has no external target', () => {
|
||||
openDocPreviewSource(REMOTE_DOCUMENT)
|
||||
|
||||
expect(mocks.openFile.mock.calls[0]?.[0]).not.toHaveProperty('externalSshTargetId')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openDocPreviewExternally', () => {
|
||||
it('hands a local document straight to the OS', () => {
|
||||
openDocPreviewExternally(REMOTE_DOCUMENT)
|
||||
|
||||
expect(mocks.openFilePath).toHaveBeenCalledWith(REMOTE_DOCUMENT.filePath)
|
||||
expect(mocks.downloadAndOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads an SSH document before handing it over', () => {
|
||||
mocks.connectionIdForFile = 'ssh-1'
|
||||
mocks.connectionIdForWorkspace = 'ssh-1'
|
||||
|
||||
openDocPreviewExternally(REMOTE_DOCUMENT)
|
||||
|
||||
expect(mocks.openFilePath).not.toHaveBeenCalled()
|
||||
expect(mocks.downloadAndOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'ssh-1' }),
|
||||
REMOTE_DOCUMENT.filePath
|
||||
)
|
||||
})
|
||||
|
||||
// Why this case exists at all: a folder workspace can span repos on different hosts, so asking
|
||||
// who owns the *workspace* answers nothing while the file itself has a definite owner. Routing on
|
||||
// the workspace answer would hand the OS a remote absolute path — a silent no-op, or worse, an
|
||||
// unrelated local file that happens to share the path.
|
||||
it('routes on the file owner when the workspace-scoped owner is unresolved', () => {
|
||||
mocks.connectionIdForFile = 'ssh-1'
|
||||
mocks.connectionIdForWorkspace = undefined
|
||||
|
||||
openDocPreviewExternally(REMOTE_DOCUMENT)
|
||||
|
||||
expect(mocks.openFilePath).not.toHaveBeenCalled()
|
||||
expect(mocks.downloadAndOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'ssh-1' }),
|
||||
REMOTE_DOCUMENT.filePath
|
||||
)
|
||||
})
|
||||
|
||||
// Why refusing beats downloading here: with no owner at all the download route reads the absolute
|
||||
// path on this machine, so a client holding a same-named file would be shown its contents under
|
||||
// the remote document's name — the wrong-file outcome, one layer below the OS branch.
|
||||
it('refuses instead of reading this machine when no owner resolves', () => {
|
||||
mocks.connectionIdForFile = undefined
|
||||
mocks.connectionIdForWorkspace = undefined
|
||||
|
||||
openDocPreviewExternally(REMOTE_DOCUMENT)
|
||||
|
||||
expect(mocks.openFilePath).not.toHaveBeenCalled()
|
||||
expect(mocks.downloadAndOpen).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('report/index.html'))
|
||||
})
|
||||
|
||||
// Why this one varies only the root: every other case that loses the root also names another
|
||||
// host, so without it nothing proves the root is required for the OS branch on its own.
|
||||
it('refuses a document whose workspace root is unknown even when nothing names another host', () => {
|
||||
mocks.worktreePath = null
|
||||
|
||||
openDocPreviewExternally(REMOTE_DOCUMENT)
|
||||
|
||||
expect(mocks.openFilePath).not.toHaveBeenCalled()
|
||||
expect(mocks.downloadAndOpen).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why keep the tab's field as a fallback: the worktree stops resolving an owner once its runtime
|
||||
// is torn down, and the tab that is still open is then the only record of who owned the document.
|
||||
it('falls back to the tab runtime owner when the worktree resolves none', () => {
|
||||
mocks.worktreeRuntimeOwnerId = null
|
||||
mocks.activeRuntimeEnvironmentId = 'env-9'
|
||||
|
||||
openDocPreviewExternally({ ...REMOTE_DOCUMENT, runtimeEnvironmentId: 'env-9' })
|
||||
|
||||
expect(mocks.openFilePath).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(mocks.downloadAndOpen).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why the tab's own field is not enough: a preview tab opened or restored before its worktree's
|
||||
// runtime owner was known carries null there, while the grant it renders through was minted
|
||||
// against the owner the worktree resolves to — routing on the stale field sends a remote
|
||||
// document to the OS.
|
||||
it('routes on the worktree runtime owner when the tab carries none', () => {
|
||||
mocks.worktreeRuntimeOwnerId = 'env-1'
|
||||
mocks.activeRuntimeEnvironmentId = 'env-1'
|
||||
|
||||
openDocPreviewExternally({ ...REMOTE_DOCUMENT, runtimeEnvironmentId: null })
|
||||
|
||||
expect(mocks.openFilePath).not.toHaveBeenCalled()
|
||||
expect(mocks.downloadAndOpen).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why the root matters: remote-runtime detection is relative to the workspace root, so an unknown
|
||||
// root makes every path look outside the runtime — which reads as local.
|
||||
it('downloads a runtime document whose workspace root is unknown', () => {
|
||||
mocks.worktreePath = null
|
||||
mocks.worktreeRuntimeOwnerId = 'env-1'
|
||||
mocks.activeRuntimeEnvironmentId = 'env-1'
|
||||
|
||||
openDocPreviewExternally({ ...REMOTE_DOCUMENT, runtimeEnvironmentId: 'env-1' })
|
||||
|
||||
expect(mocks.openFilePath).not.toHaveBeenCalled()
|
||||
expect(mocks.downloadAndOpen).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import {
|
||||
buildWorkspaceFileContextForFile,
|
||||
canClientOsOpenWorkspaceFile
|
||||
} from '@/lib/workspace-file-host-routing'
|
||||
import { useAppStore } from '@/store'
|
||||
import { downloadAndOpenRemoteTerminalFile } from '@/components/terminal-pane/terminal-remote-file-download-open'
|
||||
|
||||
export type DocPreviewDocument = {
|
||||
filePath: string
|
||||
relativePath: string
|
||||
worktreeId: string
|
||||
runtimeEnvironmentId: string | null
|
||||
/**
|
||||
* Only the source tab reads this today, because no path that opens a preview sets it. If one ever
|
||||
* does, `openDocPreviewExternally` needs it as `expectedExternalSshTargetId` on the file context —
|
||||
* without it the read is not checked against the target the tab claims.
|
||||
*/
|
||||
externalSshTargetId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the previewed document as an ordinary source tab. A second tab, not a mode switch: the
|
||||
* preview keeps its own id, so the reader can leave the rendered page where it was.
|
||||
*/
|
||||
export function openDocPreviewSource(document: DocPreviewDocument): void {
|
||||
useAppStore.getState().openFile({
|
||||
filePath: document.filePath,
|
||||
relativePath: document.relativePath,
|
||||
worktreeId: document.worktreeId,
|
||||
// Why not the preview tab's language: it is pinned to 'html' for the preview itself, and the
|
||||
// source tab needs the editor's own detection to pick a highlighter.
|
||||
language: detectLanguage(document.filePath),
|
||||
runtimeEnvironmentId: document.runtimeEnvironmentId,
|
||||
...(document.externalSshTargetId ? { externalSshTargetId: document.externalSshTargetId } : {}),
|
||||
mode: 'edit'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the document to the reader's own machine. A preview is almost always remote, and the OS
|
||||
* cannot launch a path it has no copy of — so a remote document is downloaded first, exactly as
|
||||
* the terminal's "Download & open with default app" does.
|
||||
*/
|
||||
export function openDocPreviewExternally(document: DocPreviewDocument): void {
|
||||
const state = useAppStore.getState()
|
||||
const worktreeRoot = state.getKnownWorktreeById(document.worktreeId)?.path ?? null
|
||||
// Why the per-file resolver: this is the same document the grant authorized, and that grant was
|
||||
// minted against the file's own owner. A folder workspace spanning hosts answers `undefined`
|
||||
// workspace-wide, which downstream reads as local — the OS would then be handed a remote
|
||||
// absolute path and either do nothing or open an unrelated file of the same name.
|
||||
const connectionId = getConnectionIdForFileFromState(
|
||||
state,
|
||||
document.worktreeId,
|
||||
document.filePath
|
||||
)
|
||||
// Why re-resolve the runtime owner rather than trust the tab's field: the grant this preview
|
||||
// renders through was minted against the worktree's owner at render time, and a tab opened or
|
||||
// restored before that owner was known still carries null — which reads as local.
|
||||
const runtimeEnvironmentId =
|
||||
getRuntimeEnvironmentIdForWorktree(state, document.worktreeId) ?? document.runtimeEnvironmentId
|
||||
const fileContext = buildWorkspaceFileContextForFile(
|
||||
document.worktreeId,
|
||||
worktreeRoot ?? '',
|
||||
document.filePath,
|
||||
runtimeEnvironmentId
|
||||
)
|
||||
// Why these conditions rather than the shared predicate alone: it reads an unresolved owner, an
|
||||
// unknown workspace root, and a runtime-owned path that sits outside that root as "local", and a
|
||||
// preview really reaches all three. Only a document proven to live on this machine goes to the
|
||||
// OS; a resolved remote owner downloads first, and no owner at all is refused below.
|
||||
const ownedByThisMachine =
|
||||
connectionId === null && runtimeEnvironmentId === null && worktreeRoot !== null
|
||||
if (ownedByThisMachine && canClientOsOpenWorkspaceFile(fileContext, document.filePath)) {
|
||||
void window.api.shell.openFilePath(document.filePath)
|
||||
return
|
||||
}
|
||||
// Why refuse instead of downloading: with neither owner resolved the download route reads the
|
||||
// absolute path on THIS machine, so a client that happens to hold a file of the same name would
|
||||
// get its contents back under the remote document's name. Reachable once an owner un-resolves
|
||||
// under a tab that already exists — the repo evicted, the SSH target removed.
|
||||
if (connectionId == null && runtimeEnvironmentId === null) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.editor.HtmlDocPreview.openExternallyUnknownHostError',
|
||||
"Can't open '{{value0}}': the host that owns it is no longer known.",
|
||||
{ value0: document.relativePath }
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
void downloadAndOpenRemoteTerminalFile(fileContext, document.filePath)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Check, FileCode2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { DocPreviewDocumentIdentity } from './doc-preview-document-identity'
|
||||
|
||||
/**
|
||||
* The preview's stand-in for an address bar. The guest's real origin is an internal scheme the
|
||||
* reader has no use for, so this names the document the way the workspace does — and says whose
|
||||
* machine it was read from, which is the part a paired or SSH reader cannot otherwise tell.
|
||||
*/
|
||||
export function DocPreviewDocumentChip({
|
||||
identity
|
||||
}: {
|
||||
identity: DocPreviewDocumentIdentity
|
||||
}): React.JSX.Element {
|
||||
const { copyText, status } = useClipboardTextCopyFeedback(identity.absolutePath)
|
||||
const copied = status === 'copied'
|
||||
const copyLabel = translate(
|
||||
'auto.components.editor.HtmlDocPreview.copyDocumentPathControl',
|
||||
'Copy file path'
|
||||
)
|
||||
// Why the label swaps: the icon change is the only other feedback, and an icon says nothing to
|
||||
// a screen reader — the same trade TerminalLinkActionPopover makes.
|
||||
const copiedLabel = translate(
|
||||
'auto.components.editor.HtmlDocPreview.documentPathCopied',
|
||||
'Copied'
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText()}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1 text-left shadow-sm hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileCode2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{/* dir/ dimmed, filename normal — the same emphasis the file tree gives a path */}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
<span className="text-muted-foreground">{identity.directoryPrefix}</span>
|
||||
<span className="text-foreground">{identity.fileName}</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
|
||||
{translate(
|
||||
'auto.components.editor.HtmlDocPreview.workspaceFileChipLabel',
|
||||
'Workspace file'
|
||||
)}
|
||||
{identity.hostLabel ? (
|
||||
<Badge variant="secondary" className="max-w-40 truncate font-normal">
|
||||
{identity.hostLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{copied ? copiedLabel : `${copyLabel} · ${identity.absolutePath}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDocPreviewDocumentIdentity } from './doc-preview-document-identity'
|
||||
|
||||
describe('buildDocPreviewDocumentIdentity', () => {
|
||||
it('splits a workspace-relative path into a dimmable directory and a filename', () => {
|
||||
expect(
|
||||
buildDocPreviewDocumentIdentity({
|
||||
filePath: '/repo/docs/reports/index.html',
|
||||
worktreeRoot: '/repo',
|
||||
hostLabel: 'Studio Mac mini'
|
||||
})
|
||||
).toEqual({
|
||||
absolutePath: '/repo/docs/reports/index.html',
|
||||
directoryPrefix: 'docs/reports/',
|
||||
fileName: 'index.html',
|
||||
hostLabel: 'Studio Mac mini'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the directory empty for a document at the workspace root', () => {
|
||||
const identity = buildDocPreviewDocumentIdentity({
|
||||
filePath: '/repo/index.html',
|
||||
worktreeRoot: '/repo',
|
||||
hostLabel: null
|
||||
})
|
||||
|
||||
expect(identity.directoryPrefix).toBe('')
|
||||
expect(identity.fileName).toBe('index.html')
|
||||
})
|
||||
|
||||
// Why: an SSH preview of a file outside the workspace has no relative form, and a bare filename
|
||||
// would strip the only context the reader has for where it came from.
|
||||
it('falls back to the absolute path when the file sits outside the workspace', () => {
|
||||
const identity = buildDocPreviewDocumentIdentity({
|
||||
filePath: '/elsewhere/notes/report.html',
|
||||
worktreeRoot: '/repo',
|
||||
hostLabel: 'build-box'
|
||||
})
|
||||
|
||||
expect(identity.directoryPrefix).toBe('/elsewhere/notes/')
|
||||
expect(identity.fileName).toBe('report.html')
|
||||
})
|
||||
|
||||
// Why: the path is copied and read as the owning machine spells it, so a Windows host's
|
||||
// backslashes must survive rather than being normalised into a path that host would not accept.
|
||||
it('keeps the owning machine separator on Windows paths', () => {
|
||||
const identity = buildDocPreviewDocumentIdentity({
|
||||
filePath: 'C:\\repo\\docs\\index.html',
|
||||
worktreeRoot: 'C:\\repo',
|
||||
hostLabel: 'Windows box'
|
||||
})
|
||||
|
||||
expect(identity.fileName).toBe('index.html')
|
||||
expect(identity.directoryPrefix.endsWith('/')).toBe(true)
|
||||
expect(identity.absolutePath).toBe('C:\\repo\\docs\\index.html')
|
||||
})
|
||||
|
||||
it('carries an unknown owner through as null so the chip can drop the host pill', () => {
|
||||
expect(
|
||||
buildDocPreviewDocumentIdentity({
|
||||
filePath: '/repo/a.html',
|
||||
worktreeRoot: null,
|
||||
hostLabel: null
|
||||
}).hostLabel
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { basename, getRelativePathInsideRoot } from '@/lib/path'
|
||||
|
||||
export type DocPreviewDocumentIdentity = {
|
||||
/** What clicking the chip copies — always the path as the owning machine spells it. */
|
||||
absolutePath: string
|
||||
/** Workspace-relative directory with a trailing separator; empty at the workspace root. */
|
||||
directoryPrefix: string
|
||||
fileName: string
|
||||
/** Null while ownership is unknown, so the chip can drop the pill instead of inventing a host. */
|
||||
hostLabel: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What the preview toolbar shows instead of a URL. The reader never sees the internal preview
|
||||
* origin, so the document has to identify itself: where it sits in the workspace, and whose disk
|
||||
* that is.
|
||||
*/
|
||||
export function buildDocPreviewDocumentIdentity({
|
||||
filePath,
|
||||
worktreeRoot,
|
||||
hostLabel
|
||||
}: {
|
||||
filePath: string
|
||||
worktreeRoot: string | null
|
||||
hostLabel: string | null
|
||||
}): DocPreviewDocumentIdentity {
|
||||
// Why fall back to the absolute path: an SSH preview of a file outside the workspace has no
|
||||
// workspace-relative form, and a bare filename would strip the only context the reader has.
|
||||
const displayPath = getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath
|
||||
const fileName = basename(displayPath)
|
||||
return {
|
||||
absolutePath: filePath,
|
||||
// Sliced rather than rebuilt from dirname so the owner's own separator survives on Windows.
|
||||
directoryPrefix: displayPath.slice(0, displayPath.length - fileName.length),
|
||||
fileName,
|
||||
hostLabel
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
DocPreviewFileFailure,
|
||||
DocPreviewFileFailureReason
|
||||
} from '../../../../../shared/doc-preview-scheme'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function docPreviewFailureDetail(reason: DocPreviewFileFailureReason | null): string {
|
||||
if (reason === 'too-large') {
|
||||
return translate(
|
||||
'auto.components.editor.HtmlDocPreview.documentTooLargePanel',
|
||||
'This document is too large to preview. Open it in the editor instead.'
|
||||
)
|
||||
}
|
||||
// Why no 'unsupported-asset' sentence here: the entry document is served as text by every owner
|
||||
// — only a subresource can be refused for its format, and that failure is a notice, not a panel.
|
||||
return translate(
|
||||
'auto.components.editor.HtmlDocPreview.documentUnreadablePanel',
|
||||
'Orca could not read this file from the workspace.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a notice and not the failure panel: only the entry document's failure leaves the reader with
|
||||
* nothing to look at. A stylesheet, image or font the workspace would not send is a document that
|
||||
* rendered — degraded, and the reader deserves to know which piece is missing, but rendered.
|
||||
*/
|
||||
export function docPreviewAssetNotice(failures: DocPreviewFileFailure[]): string | null {
|
||||
const [first] = failures
|
||||
if (!first) {
|
||||
return null
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
return translate(
|
||||
'auto.components.editor.HtmlDocPreview.multipleAssetsFailedNotice',
|
||||
'{{count}} files in this document could not be loaded.',
|
||||
{ count: failures.length }
|
||||
)
|
||||
}
|
||||
if (first.reason === 'too-large') {
|
||||
return translate(
|
||||
'auto.components.editor.HtmlDocPreview.assetTooLargeNotice',
|
||||
'{{path}} is too large to load in this preview.',
|
||||
{ path: first.relativePath }
|
||||
)
|
||||
}
|
||||
if (first.reason === 'unsupported-asset') {
|
||||
return translate(
|
||||
'auto.components.editor.HtmlDocPreview.assetUnsupportedNotice',
|
||||
'This workspace cannot send {{path}} to a preview.',
|
||||
{ path: first.relativePath }
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.editor.HtmlDocPreview.assetUnreadableNotice',
|
||||
'Orca could not read {{path}} from the workspace.',
|
||||
{ path: first.relativePath }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the sentence names no file and does not count attempts: the document chooses both, and this
|
||||
* row is Orca's chrome. Constant text is also what makes repeated attempts unspammable — the
|
||||
* hundredth refusal renders exactly what the first one did.
|
||||
*/
|
||||
export function docPreviewDownloadBlockedNotice(): string {
|
||||
return translate(
|
||||
'auto.components.editor.HtmlDocPreview.downloadBlockedNotice',
|
||||
'Downloads are disabled in document previews.'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Copy, FileCode2, MoreHorizontal, RefreshCw, RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
* The preview's overflow menu. It carries the document actions rather than the browsing pane's
|
||||
* profile and viewport rows: a preview has no session to switch and no device to emulate.
|
||||
*/
|
||||
export function DocPreviewOverflowMenu({
|
||||
onReload,
|
||||
onHardReload,
|
||||
onOpenSource,
|
||||
onCopyPath,
|
||||
onCopyRelativePath
|
||||
}: {
|
||||
onReload: () => void
|
||||
onHardReload: () => void
|
||||
onOpenSource: () => void
|
||||
onCopyPath: () => void
|
||||
/** Why it lives here: the preview hides the editor's path header, which was the only way to copy it. */
|
||||
onCopyRelativePath: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
aria-label={translate(
|
||||
'auto.components.editor.HtmlDocPreview.previewMenuControl',
|
||||
'Preview options'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={onReload}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onHardReload}>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.a1f3c2e4b5', 'Hard Reload')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onOpenSource}>
|
||||
<FileCode2 className="size-3.5" />
|
||||
{translate('auto.components.editor.HtmlDocPreview.openSourceControl', 'Open source file')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCopyPath}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.editor.HtmlDocPreview.copyDocumentPathControl',
|
||||
'Copy file path'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCopyRelativePath}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.editor.HtmlDocPreview.copyDocumentRelativePathControl',
|
||||
'Copy relative path'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
BrowserChromeToolbar,
|
||||
type BrowserChromeElementTools
|
||||
} from '@/components/browser-pane/assemble-chrome/browser-chrome-toolbar'
|
||||
import { BrowserReloadControl } from '@/components/browser-pane/assemble-chrome/browser-reload-control'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { DocPreviewDocumentChip } from './doc-preview-document-chip'
|
||||
import type { DocPreviewDocumentIdentity } from './doc-preview-document-identity'
|
||||
import type { DocPreviewHistory } from './doc-preview-webview-history'
|
||||
import { DocPreviewOverflowMenu } from './doc-preview-overflow-menu'
|
||||
|
||||
/**
|
||||
* Binds the shared browser chrome to a workspace document: the address bar becomes a read-only
|
||||
* identity chip, because a preview shows a path the reader cannot retype into something else.
|
||||
*/
|
||||
export function DocPreviewToolbar({
|
||||
identity,
|
||||
history,
|
||||
loading,
|
||||
onReload,
|
||||
onHardReload,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onOpenSource,
|
||||
onOpenExternally,
|
||||
elementTools,
|
||||
markupActive,
|
||||
onToggleMarkup,
|
||||
markupDisabled
|
||||
}: {
|
||||
identity: DocPreviewDocumentIdentity
|
||||
history: DocPreviewHistory
|
||||
loading: boolean
|
||||
onReload: () => void
|
||||
/** Drops the grant and mints a new one — the preview's equivalent of ignoring every cache. */
|
||||
onHardReload: () => void
|
||||
onCopyPath: () => void
|
||||
onCopyRelativePath: () => void
|
||||
onOpenSource: () => void
|
||||
onOpenExternally: () => void
|
||||
elementTools: BrowserChromeElementTools
|
||||
markupActive: boolean
|
||||
onToggleMarkup: () => void
|
||||
markupDisabled: boolean
|
||||
}): React.JSX.Element {
|
||||
const [reloadMenuOpen, setReloadMenuOpen] = useState(false)
|
||||
const reloadLabel = translate(
|
||||
'auto.components.editor.HtmlDocPreview.reloadPreviewControl',
|
||||
'Reload preview'
|
||||
)
|
||||
|
||||
return (
|
||||
<BrowserChromeToolbar
|
||||
controls={{
|
||||
canGoBack: history.canGoBack,
|
||||
canGoForward: history.canGoForward,
|
||||
loading,
|
||||
goBack: history.goBack,
|
||||
goForward: history.goForward,
|
||||
reload: onReload,
|
||||
// Why a no-op: the identity chip has nothing to submit, so nothing can reach this.
|
||||
navigate: () => {}
|
||||
}}
|
||||
addressSlot={<DocPreviewDocumentChip identity={identity} />}
|
||||
reloadControl={
|
||||
<BrowserReloadControl
|
||||
menuOpen={reloadMenuOpen}
|
||||
onMenuOpenChange={setReloadMenuOpen}
|
||||
label={reloadLabel}
|
||||
loading={loading}
|
||||
showShortcutHint={false}
|
||||
reloadShortcut=""
|
||||
hardReloadShortcut=""
|
||||
onPrimary={onReload}
|
||||
onReload={onReload}
|
||||
onHardReload={onHardReload}
|
||||
/>
|
||||
}
|
||||
// Cookie import is a browsing-session action; a preview reads workspace disk over a grant
|
||||
// and has no session for cookies to land in.
|
||||
importControl={null}
|
||||
elementTools={elementTools}
|
||||
markup={{
|
||||
active: markupActive,
|
||||
disabled: markupDisabled,
|
||||
onToggle: onToggleMarkup,
|
||||
// Why false on a surface that is plainly visible: the draw-tool nudge fires once per
|
||||
// install, and it belongs to the browsing pane. A reader who opened a document should not
|
||||
// be the one who spends it.
|
||||
canShowDiscoveryHint: false
|
||||
}}
|
||||
viewSource={{
|
||||
onSelect: onOpenSource,
|
||||
label: translate(
|
||||
'auto.components.editor.HtmlDocPreview.openSourceControl',
|
||||
'Open source file'
|
||||
)
|
||||
}}
|
||||
openExternal={{
|
||||
onSelect: onOpenExternally,
|
||||
label: translate(
|
||||
'auto.components.editor.HtmlDocPreview.openExternallyControl',
|
||||
'Open with default app'
|
||||
)
|
||||
}}
|
||||
overflowMenu={
|
||||
<DocPreviewOverflowMenu
|
||||
onReload={onReload}
|
||||
onHardReload={onHardReload}
|
||||
onOpenSource={onOpenSource}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyRelativePath={onCopyRelativePath}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useRef, useState, type MutableRefObject } from 'react'
|
||||
|
||||
export type DocPreviewHistory = {
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
goBack: () => void
|
||||
goForward: () => void
|
||||
/** Re-read the guest's history depth; call from every navigation event. */
|
||||
sync: () => void
|
||||
/** Forget the old guest's depth when the preview re-mints and re-attaches. */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Back/Forward for the preview guest. In-preview navigation between workspace documents under the
|
||||
* same grant is allowed by the guest policy, so a report that links to a sibling page builds real
|
||||
* history — and fragments build in-document entries the same way a browser does.
|
||||
*/
|
||||
export function useDocPreviewWebviewHistory(
|
||||
webviewRef: MutableRefObject<Electron.WebviewTag | null>
|
||||
): DocPreviewHistory {
|
||||
const [depth, setDepth] = useState({ canGoBack: false, canGoForward: false })
|
||||
// Why a ref alongside state: sync fires per navigation event and would otherwise re-render on
|
||||
// every one, even though the buttons only change at the edges of history.
|
||||
const depthRef = useRef(depth)
|
||||
|
||||
const apply = useCallback((next: { canGoBack: boolean; canGoForward: boolean }): void => {
|
||||
if (
|
||||
depthRef.current.canGoBack === next.canGoBack &&
|
||||
depthRef.current.canGoForward === next.canGoForward
|
||||
) {
|
||||
return
|
||||
}
|
||||
depthRef.current = next
|
||||
setDepth(next)
|
||||
}, [])
|
||||
|
||||
const sync = useCallback((): void => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) {
|
||||
apply({ canGoBack: false, canGoForward: false })
|
||||
return
|
||||
}
|
||||
try {
|
||||
apply({ canGoBack: webview.canGoBack(), canGoForward: webview.canGoForward() })
|
||||
} catch {
|
||||
// The guest can detach between the event and this read; treat it as no history.
|
||||
apply({ canGoBack: false, canGoForward: false })
|
||||
}
|
||||
}, [apply, webviewRef])
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
apply({ canGoBack: false, canGoForward: false })
|
||||
}, [apply])
|
||||
|
||||
const goBack = useCallback((): void => {
|
||||
try {
|
||||
webviewRef.current?.goBack()
|
||||
} catch {
|
||||
/* detached guest */
|
||||
}
|
||||
}, [webviewRef])
|
||||
|
||||
const goForward = useCallback((): void => {
|
||||
try {
|
||||
webviewRef.current?.goForward()
|
||||
} catch {
|
||||
/* detached guest */
|
||||
}
|
||||
}, [webviewRef])
|
||||
|
||||
return {
|
||||
canGoBack: depth.canGoBack,
|
||||
canGoForward: depth.canGoForward,
|
||||
goBack,
|
||||
goForward,
|
||||
sync,
|
||||
reset
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// One id runs through this hook, and it is the browser page. A re-mint replaces the guest under
|
||||
// that page rather than renaming the surface, so annotations stay addressable and the tool target
|
||||
// keeps naming something main can resolve — the two used to be separate ids, and swapping them was
|
||||
// invisible until a preview re-minted.
|
||||
import { act, createElement, useRef } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
annotationSend: [] as { browserTabId: string }[],
|
||||
grabAnnotations: [] as { browserTabId: string; toolTargetId: string }[],
|
||||
grabMode: [] as string[],
|
||||
viewportBridge: [] as { toolTargetId: string }[]
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/annotate/use-browser-page-annotation-send', () => ({
|
||||
useBrowserPageAnnotationSend: (args: { browserTabId: string }) => {
|
||||
calls.annotationSend.push({ browserTabId: args.browserTabId })
|
||||
return { browserAnnotations: [], setBrowserAnnotationTrayOpen: () => undefined }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/annotate/use-browser-page-grab-annotations', () => ({
|
||||
useBrowserPageGrabAnnotations: (args: { browserTabId: string; toolTargetId: string }) => {
|
||||
calls.grabAnnotations.push({
|
||||
browserTabId: args.browserTabId,
|
||||
toolTargetId: args.toolTargetId
|
||||
})
|
||||
return { pendingAnnotationPayload: null, grabIntent: null, startGrabIntent: () => undefined }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/annotate/use-browser-page-markup-capture', () => ({
|
||||
useBrowserPageMarkupCapture: () => ({ isActive: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/annotate/useGrabMode', () => ({
|
||||
useGrabMode: (toolTargetId: string) => {
|
||||
calls.grabMode.push(toolTargetId)
|
||||
return { state: 'idle' }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/annotate/guest-annotation-viewport-bridge', () => ({
|
||||
syncGuestAnnotationViewportBridge: (args: { toolTargetId: string }) => {
|
||||
calls.viewportBridge.push({ toolTargetId: args.toolTargetId })
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'G' }))
|
||||
|
||||
import { useDocPreviewGuestTools } from './use-doc-preview-guest-tools'
|
||||
|
||||
const PREVIEW_ID = 'browser-page-9f2c'
|
||||
const FIRST_GRANT = 'a'.repeat(32)
|
||||
const SECOND_GRANT = 'b'.repeat(32)
|
||||
|
||||
function Harness({ grantId }: { grantId: string | null }): null {
|
||||
const webviewRef = useRef(null)
|
||||
const containerRef = useRef(null)
|
||||
useDocPreviewGuestTools({
|
||||
previewId: PREVIEW_ID,
|
||||
worktreeId: 'wt-1',
|
||||
grantId,
|
||||
webviewRef: webviewRef as never,
|
||||
containerRef: containerRef as never,
|
||||
toolsReady: true
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function render(grantId: string | null): void {
|
||||
act(() => {
|
||||
root.render(createElement(Harness, { grantId }))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls.annotationSend.length = 0
|
||||
calls.grabAnnotations.length = 0
|
||||
calls.grabMode.length = 0
|
||||
calls.viewportBridge.length = 0
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
describe('useDocPreviewGuestTools ids', () => {
|
||||
it('scopes annotations and tools to the browser page the document is open in', () => {
|
||||
render(FIRST_GRANT)
|
||||
|
||||
expect(calls.annotationSend.at(-1)?.browserTabId).toBe(PREVIEW_ID)
|
||||
expect(calls.grabAnnotations.at(-1)?.browserTabId).toBe(PREVIEW_ID)
|
||||
expect(calls.grabAnnotations.at(-1)?.toolTargetId).toBe(PREVIEW_ID)
|
||||
expect(calls.grabMode.at(-1)).toBe(PREVIEW_ID)
|
||||
})
|
||||
|
||||
// Why a re-mint is still worth a test with one id: main re-points the page at the replacement
|
||||
// guest, so the surface the reader is looking at must keep the same name through it. A hook that
|
||||
// rebuilt its target from the grant would orphan annotations under an id nothing reads again.
|
||||
it('keeps naming the same surface across a re-mint', () => {
|
||||
render(FIRST_GRANT)
|
||||
render(SECOND_GRANT)
|
||||
|
||||
expect(new Set(calls.annotationSend.map((call) => call.browserTabId))).toEqual(
|
||||
new Set([PREVIEW_ID])
|
||||
)
|
||||
expect(new Set(calls.grabAnnotations.map((call) => call.toolTargetId))).toEqual(
|
||||
new Set([PREVIEW_ID])
|
||||
)
|
||||
expect(calls.viewportBridge.at(-1)?.toolTargetId).toBe(PREVIEW_ID)
|
||||
})
|
||||
|
||||
// Why an empty target and not the page id: before a grant exists no guest has committed to this
|
||||
// page, so naming it would park every tool request in the registration wait for a document the
|
||||
// reader may never get.
|
||||
it('names no tool target before a grant exists', () => {
|
||||
render(null)
|
||||
|
||||
expect(calls.grabMode.at(-1)).toBe('')
|
||||
expect(calls.grabAnnotations.at(-1)?.toolTargetId).toBe('')
|
||||
expect(calls.viewportBridge).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { syncGuestAnnotationViewportBridge } from '@/components/browser-pane/annotate/guest-annotation-viewport-bridge'
|
||||
import { useBrowserPageAnnotationSend } from '@/components/browser-pane/annotate/use-browser-page-annotation-send'
|
||||
import { useBrowserPageGrabAnnotations } from '@/components/browser-pane/annotate/use-browser-page-grab-annotations'
|
||||
import { useBrowserPageMarkupCapture } from '@/components/browser-pane/annotate/use-browser-page-markup-capture'
|
||||
import { useGrabMode } from '@/components/browser-pane/annotate/useGrabMode'
|
||||
import type { BrowserOverlayViewport } from '@/components/browser-pane/describe-page/browser-annotation-geometry'
|
||||
import type { BrowserChromeElementTools } from '@/components/browser-pane/assemble-chrome/browser-chrome-toolbar'
|
||||
|
||||
/**
|
||||
* The preview's half of the browser tool cluster: the in-guest element picker, the annotation
|
||||
* store and the markup canvas, wired exactly as the browsing pane wires them — including the id,
|
||||
* which is the browser page for both the stored annotations and the tool target. A re-mint
|
||||
* replaces the guest under that page rather than renaming the surface, so nothing here has to
|
||||
* track which grant is currently on screen.
|
||||
*/
|
||||
export function useDocPreviewGuestTools({
|
||||
previewId,
|
||||
worktreeId,
|
||||
grantId,
|
||||
webviewRef,
|
||||
containerRef,
|
||||
toolsReady
|
||||
}: {
|
||||
previewId: string
|
||||
worktreeId: string
|
||||
grantId: string | null
|
||||
webviewRef: MutableRefObject<Electron.WebviewTag | null>
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>
|
||||
toolsReady: boolean
|
||||
}): {
|
||||
grab: ReturnType<typeof useGrabMode>
|
||||
markup: ReturnType<typeof useBrowserPageMarkupCapture>
|
||||
annotationSend: ReturnType<typeof useBrowserPageAnnotationSend>
|
||||
grabAnnotations: ReturnType<typeof useBrowserPageGrabAnnotations>
|
||||
browserOverlayViewport: BrowserOverlayViewport
|
||||
elementTools: BrowserChromeElementTools
|
||||
} {
|
||||
// Why still empty before the first grant: the page is only a tool target once a document is on
|
||||
// screen, and useGrabMode needs a stable identity every render rather than one to guess with.
|
||||
const toolTargetId = grantId === null ? '' : previewId
|
||||
const annotationViewportBridgeTokenRef = useRef(createBrowserUuid().replaceAll('-', ''))
|
||||
const [browserOverlayViewport, setBrowserOverlayViewport] = useState<BrowserOverlayViewport>({
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
version: 0
|
||||
})
|
||||
|
||||
const grabElementShortcut = useShortcutLabel('browser.grabElement')
|
||||
const grab = useGrabMode(toolTargetId)
|
||||
const markup = useBrowserPageMarkupCapture(webviewRef, containerRef)
|
||||
const annotationSend = useBrowserPageAnnotationSend({ browserTabId: previewId, worktreeId })
|
||||
const grabAnnotations = useBrowserPageGrabAnnotations({
|
||||
browserTabId: previewId,
|
||||
toolTargetId,
|
||||
isActive: toolsReady,
|
||||
grab,
|
||||
containerRef,
|
||||
webviewRef,
|
||||
setBrowserOverlayViewport,
|
||||
browserAnnotationsLength: annotationSend.browserAnnotations.length,
|
||||
setBrowserAnnotationTrayOpen: annotationSend.setBrowserAnnotationTrayOpen
|
||||
})
|
||||
|
||||
const { browserAnnotations } = annotationSend
|
||||
const { pendingAnnotationPayload } = grabAnnotations
|
||||
useEffect(() => {
|
||||
if (!toolTargetId) {
|
||||
return
|
||||
}
|
||||
syncGuestAnnotationViewportBridge({
|
||||
toolTargetId,
|
||||
annotations: browserAnnotations,
|
||||
pendingPayload: pendingAnnotationPayload,
|
||||
surfaceActive: toolsReady,
|
||||
token: annotationViewportBridgeTokenRef.current
|
||||
})
|
||||
}, [browserAnnotations, pendingAnnotationPayload, toolTargetId, toolsReady])
|
||||
|
||||
const elementTools = useMemo<BrowserChromeElementTools>(
|
||||
() => ({
|
||||
activeIntent: grab.state !== 'idle' ? grabAnnotations.grabIntent : null,
|
||||
onStartIntent: grabAnnotations.startGrabIntent,
|
||||
// Nothing has painted on a loading or failed preview, so there is no element to pick.
|
||||
disabled: !toolsReady || markup.isActive,
|
||||
grabShortcutLabel: grabElementShortcut,
|
||||
annotationCount: browserAnnotations.length
|
||||
}),
|
||||
[
|
||||
browserAnnotations.length,
|
||||
grab.state,
|
||||
grabAnnotations.grabIntent,
|
||||
grabAnnotations.startGrabIntent,
|
||||
grabElementShortcut,
|
||||
markup.isActive,
|
||||
toolsReady
|
||||
]
|
||||
)
|
||||
|
||||
return { grab, markup, annotationSend, grabAnnotations, browserOverlayViewport, elementTools }
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*
|
||||
* STA-5557: a preview hands its guest the keyboard, so what counts as "the reader is in this
|
||||
* preview" is load-bearing in both directions — too narrow and the one route out of a preview stays
|
||||
* shut, too wide and it takes the keyboard from the terminal the reader is actually typing in.
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserPage } from '../../../../../shared/browser-workspace-types'
|
||||
|
||||
const WORKTREE_ID = 'repo1::/path/wt1'
|
||||
const WORKSPACE_ID = 'workspace-1'
|
||||
const OTHER_WORKSPACE_ID = 'workspace-2'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
storeState: {} as Record<string, unknown>,
|
||||
handedFocus: [] as (boolean | undefined)[]
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) => selector(mocks.storeState)
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => null
|
||||
}))
|
||||
vi.mock('./HtmlDocPreview', () => ({
|
||||
HtmlDocPreview: ({ holdsGuestFocus }: { holdsGuestFocus?: boolean }) => {
|
||||
mocks.handedFocus.push(holdsGuestFocus)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
import { WorkspaceDocPagePane } from './workspace-doc-page-pane'
|
||||
|
||||
function docPage(): BrowserPage {
|
||||
return {
|
||||
id: 'page-1',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
url: 'about:blank',
|
||||
title: 'index.html',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0,
|
||||
docLocation: {
|
||||
kind: 'workspace-doc',
|
||||
worktreeId: WORKTREE_ID,
|
||||
filePath: '/path/wt1/report/index.html'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('the surface a document pane will hand its guest the keyboard from', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.handedFocus = []
|
||||
mocks.storeState = {
|
||||
activeTabTypeByWorktree: { [WORKTREE_ID]: 'browser' },
|
||||
activeBrowserTabIdByWorktree: { [WORKTREE_ID]: WORKSPACE_ID },
|
||||
getKnownWorktreeById: () => ({ path: '/path/wt1' })
|
||||
}
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderPane(isActive: boolean): boolean | undefined {
|
||||
act(() => {
|
||||
root.render(<WorkspaceDocPagePane page={docPage()} isActive={isActive} />)
|
||||
})
|
||||
return mocks.handedFocus.at(-1)
|
||||
}
|
||||
|
||||
// The presence half: with the reader in this very preview the answer has to be yes, so a pane
|
||||
// that had stopped offering focus at all would fail here rather than pass every refusal below.
|
||||
it('hands focus on when the reader is in this preview', () => {
|
||||
expect(renderPane(true)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses while the reader is in a terminal in front of it', () => {
|
||||
mocks.storeState.activeTabTypeByWorktree = { [WORKTREE_ID]: 'terminal' }
|
||||
|
||||
expect(renderPane(true)).toBe(false)
|
||||
})
|
||||
|
||||
// Why this is separate from the terminal case: the reader can be in the browser and still be
|
||||
// looking at another tab of it, which is a different half of the check.
|
||||
it('refuses while the reader is in a different browser tab', () => {
|
||||
mocks.storeState.activeBrowserTabIdByWorktree = { [WORKTREE_ID]: OTHER_WORKSPACE_ID }
|
||||
|
||||
expect(renderPane(true)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses while its own page is not the active one', () => {
|
||||
expect(renderPane(false)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from 'react'
|
||||
import { getRelativePathInsideRoot } from '@/lib/path'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { HtmlDocPreview } from './HtmlDocPreview'
|
||||
import type { BrowserPage } from '../../../../../shared/browser-workspace-types'
|
||||
|
||||
/**
|
||||
* The pane for a browser page located by a workspace document. Everything a URL page's chrome does
|
||||
* — address bar, history, favicon — is answered by the document itself, so this stands in for the
|
||||
* whole of `BrowserPagePane` rather than wrapping it.
|
||||
*/
|
||||
export function WorkspaceDocPagePane({
|
||||
page,
|
||||
isActive
|
||||
}: {
|
||||
page: BrowserPage
|
||||
isActive: boolean
|
||||
}): React.JSX.Element | null {
|
||||
const docLocation = page.docLocation ?? null
|
||||
const worktreeId = docLocation?.worktreeId ?? page.worktreeId
|
||||
const filePath = docLocation?.filePath ?? ''
|
||||
const worktreeRoot = useAppStore((store) => store.getKnownWorktreeById(worktreeId)?.path ?? null)
|
||||
// Why resolved here rather than stored on the page: ownership moves. A page persisted before a
|
||||
// pairing or an SSH reconnect would otherwise route its document actions at yesterday's host.
|
||||
const runtimeEnvironmentId = useAppStore(
|
||||
(store) => getRuntimeEnvironmentIdForWorktree(store, worktreeId) ?? null
|
||||
)
|
||||
const relativePath = useMemo(
|
||||
() => getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath,
|
||||
[filePath, worktreeRoot]
|
||||
)
|
||||
// Why the reader's whole surface and not just this page's activity: a preview keeps its pane
|
||||
// mounted behind a terminal or an editor, and focusing its guest from there would take the
|
||||
// keyboard away from what the reader is actually in.
|
||||
const isReaderSurface = useAppStore(
|
||||
(store) =>
|
||||
store.activeTabTypeByWorktree[worktreeId] === 'browser' &&
|
||||
store.activeBrowserTabIdByWorktree[worktreeId] === page.workspaceId
|
||||
)
|
||||
|
||||
if (!docLocation) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
// Why hidden and not unmounted: an inactive page keeps its guest, its scroll position and any
|
||||
// grab in flight, exactly as a URL page's pane does.
|
||||
<div className="absolute inset-0 flex min-h-0 flex-col" hidden={!isActive}>
|
||||
<HtmlDocPreview
|
||||
holdsGuestFocus={isActive && isReaderSurface}
|
||||
previewId={page.id}
|
||||
filePath={filePath}
|
||||
relativePath={relativePath}
|
||||
worktreeId={worktreeId}
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { EditorPanelShell } from './EditorPanelShell'
|
||||
|
||||
// Why stub both children: the claim under test is which of them the shell renders for a given tab
|
||||
// mode, and mounting the real editor surface would drag in Monaco for a layout question.
|
||||
vi.mock('./EditorPanelHeader', () => ({
|
||||
EditorPanelHeader: () => <div data-editor-panel-header />
|
||||
}))
|
||||
|
||||
vi.mock('./EditorContent', () => ({
|
||||
EditorContent: () => <div data-editor-content />
|
||||
}))
|
||||
|
||||
vi.mock('./UntitledFileRenameDialog', () => ({
|
||||
UntitledFileRenameDialog: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => selector({ worktreesByRepo: {} }),
|
||||
{ getState: () => ({ worktreesByRepo: {} }) }
|
||||
)
|
||||
}))
|
||||
|
||||
function openFile(mode: OpenFile['mode']): OpenFile {
|
||||
return {
|
||||
id: `file-${mode}`,
|
||||
filePath: '/home/alice/docs/report/index.html',
|
||||
relativePath: 'report/index.html',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'html',
|
||||
mode
|
||||
} as OpenFile
|
||||
}
|
||||
|
||||
function renderShell(file: OpenFile, isCombinedDiff = false): string {
|
||||
const model = {
|
||||
isCombinedDiff,
|
||||
isSingleDiff: false,
|
||||
isDiffSurface: false,
|
||||
isMarkdown: false,
|
||||
isMermaid: false,
|
||||
isCsv: false,
|
||||
isNotebook: false,
|
||||
hasEditorToggle: false,
|
||||
availableEditorToggleModes: [],
|
||||
effectiveToggleValue: 'edit',
|
||||
canOpenPreviewToSide: false,
|
||||
canShowMarkdownPreview: false,
|
||||
canShowMarkdownTableOfContents: false,
|
||||
isMarkdownTableOfContentsDisabled: false,
|
||||
shouldShowMarkdownExportAction: false,
|
||||
canExportMarkdownToPdf: false,
|
||||
openFileState: { canOpen: false },
|
||||
worktreeEntries: [],
|
||||
resolvedLanguage: 'html',
|
||||
mdViewMode: 'rich'
|
||||
}
|
||||
const noop = (): void => {}
|
||||
return renderToStaticMarkup(
|
||||
<EditorPanelShell
|
||||
panelRef={null}
|
||||
activeFile={file}
|
||||
activeViewStateId={file.id}
|
||||
model={model as never}
|
||||
copiedPathVisible={false}
|
||||
showMarkdownTableOfContents={false}
|
||||
canShowMarkdownFrontmatterToggle={false}
|
||||
markdownFrontmatterVisible={false}
|
||||
sideBySide={false}
|
||||
openFiles={[file]}
|
||||
fileContents={{}}
|
||||
diffContents={{}}
|
||||
editorDrafts={{}}
|
||||
pendingEditorReveal={null}
|
||||
renameDialogFile={null}
|
||||
renameError={null}
|
||||
disableRenameBrowse={false}
|
||||
onCopyPath={noop}
|
||||
onOpenDiffTargetFile={noop}
|
||||
onOpenPreviewToSide={noop}
|
||||
onOpenMarkdownPreview={noop}
|
||||
onOpenContainingFolder={noop}
|
||||
onToggleSideBySide={noop}
|
||||
onEditorToggleChange={noop}
|
||||
onToggleMarkdownTableOfContents={noop}
|
||||
onToggleMarkdownFrontmatter={noop}
|
||||
onExportMarkdownToPdf={noop}
|
||||
onContentChange={noop}
|
||||
onContentChangeForFile={noop}
|
||||
onDirtyStateHint={noop}
|
||||
onSave={async () => true}
|
||||
onSaveForFile={async () => true}
|
||||
onReloadContent={noop}
|
||||
onCloseMarkdownTableOfContents={noop}
|
||||
onCloseRenameDialog={noop}
|
||||
onRenameConfirm={async () => {}}
|
||||
markdownAnnotationsEnabled={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('EditorPanelShell path header', () => {
|
||||
// Why assert every remaining mode: a gate that hides the header everywhere would satisfy the
|
||||
// check-details claim below while silently removing the path from every ordinary file tab.
|
||||
it.each(['edit', 'diff', 'conflict-review', 'markdown-preview'] as const)(
|
||||
'keeps the header for a %s tab',
|
||||
(mode) => {
|
||||
expect(renderShell(openFile(mode))).toContain('data-editor-panel-header')
|
||||
}
|
||||
)
|
||||
|
||||
it('hides the header for check-details and combined diffs, as it always has', () => {
|
||||
expect(renderShell(openFile('check-details'))).not.toContain('data-editor-panel-header')
|
||||
expect(renderShell(openFile('edit'), true)).not.toContain('data-editor-panel-header')
|
||||
})
|
||||
|
||||
it('renders the editor surface either way', () => {
|
||||
expect(renderShell(openFile('check-details'))).toContain('data-editor-content')
|
||||
expect(renderShell(openFile('edit'))).toContain('data-editor-content')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { UntitledFileRenameDialog } from './UntitledFileRenameDialog'
|
||||
import type { getEditorPanelRenderModel } from './editor-panel-render-model'
|
||||
import type { DiffContent, FileContent } from './editor-panel-content-types'
|
||||
import type { EditorToggleValue } from './EditorViewToggle'
|
||||
import { shouldShowEditorPanelHeader } from './editor-header'
|
||||
import { getUntitledFileRoot } from './untitled-file-rename-path'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
@@ -97,7 +98,7 @@ export function EditorPanelShell({
|
||||
}: EditorPanelShellProps): JSX.Element {
|
||||
return (
|
||||
<div ref={panelRef} className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{!model.isCombinedDiff && activeFile.mode !== 'check-details' && (
|
||||
{shouldShowEditorPanelHeader(activeFile, model.isCombinedDiff) && (
|
||||
<EditorPanelHeader
|
||||
activeFile={activeFile}
|
||||
copiedPathVisible={copiedPathVisible}
|
||||
|
||||
@@ -14,6 +14,11 @@ export type EditorHeaderOpenFileState = {
|
||||
canOpen: boolean
|
||||
}
|
||||
|
||||
/** Whether the panel shows its own path header; check-details names the document itself. */
|
||||
export function shouldShowEditorPanelHeader(file: OpenFile, isCombinedDiff: boolean): boolean {
|
||||
return !isCombinedDiff && file.mode !== 'check-details'
|
||||
}
|
||||
|
||||
export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState {
|
||||
if (file.mode === 'conflict-review') {
|
||||
return {
|
||||
|
||||
@@ -119,4 +119,42 @@ describe('useTabStripPointerActivation', () => {
|
||||
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why this is not that flush: an in-page <webview> holding the keyboard leaves the embedder
|
||||
// blurred, so the press on the tab is itself what pulls window focus back. Treating it as an app
|
||||
// switch is what shut the tab strip for anyone reading a browser page or a document preview.
|
||||
it('still activates when the press is what pulled focus back from a guest', () => {
|
||||
const onActivate = vi.fn()
|
||||
const guest = document.createElement('webview')
|
||||
document.body.append(guest)
|
||||
guest.tabIndex = -1
|
||||
guest.focus()
|
||||
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
|
||||
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
firePointer('pointerup', 11, 11)
|
||||
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
guest.remove()
|
||||
})
|
||||
|
||||
// The forgiveness is spent on that one handoff: a press held across a real app switch still
|
||||
// flushes rather than activating whenever the release happens to land.
|
||||
it('flushes a press that outlives the guest handoff it started with', () => {
|
||||
const onActivate = vi.fn()
|
||||
const guest = document.createElement('webview')
|
||||
document.body.append(guest)
|
||||
guest.tabIndex = -1
|
||||
guest.focus()
|
||||
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
|
||||
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
firePointer('pointerup', 11, 11)
|
||||
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
guest.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,11 @@ import { beginTabStripPointerGesture } from './tab-strip-pointer-gesture'
|
||||
* drag (activation suppressed). Because each press measures its own gesture, a
|
||||
* click after a reorder always activates.
|
||||
*/
|
||||
/** Whether an in-page guest (a `<webview>`) currently owns the keyboard, which blurs the embedder. */
|
||||
function isGuestHoldingKeyboard(): boolean {
|
||||
return typeof document !== 'undefined' && document.activeElement?.tagName === 'WEBVIEW'
|
||||
}
|
||||
|
||||
export function useTabStripPointerActivation({
|
||||
onActivate,
|
||||
disabled = false
|
||||
@@ -51,12 +56,16 @@ export function useTabStripPointerActivation({
|
||||
const startX = event.clientX
|
||||
const startY = event.clientY
|
||||
const releaseTabStripPointerGesture = beginTabStripPointerGesture()
|
||||
// Why a press that starts under a guest forgives one window focus: an in-page <webview>
|
||||
// holding the keyboard leaves the embedder blurred, so this very press is what pulls focus
|
||||
// back and #7316's flush would eat the click that takes the reader out of a browser pane.
|
||||
let pendingGuestFocusHandoff = isGuestHoldingKeyboard()
|
||||
|
||||
const cleanup = (): void => {
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerCancel)
|
||||
window.removeEventListener('blur', onPointerCancel)
|
||||
window.removeEventListener('focus', onPointerCancel)
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
releaseTabStripPointerGesture()
|
||||
cleanupRef.current = null
|
||||
}
|
||||
@@ -74,11 +83,18 @@ export function useTabStripPointerActivation({
|
||||
const onPointerCancel = (): void => {
|
||||
cleanup()
|
||||
}
|
||||
const onWindowFocus = (): void => {
|
||||
if (pendingGuestFocusHandoff) {
|
||||
pendingGuestFocusHandoff = false
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerCancel)
|
||||
window.addEventListener('blur', onPointerCancel)
|
||||
window.addEventListener('focus', onPointerCancel)
|
||||
window.addEventListener('focus', onWindowFocus)
|
||||
cleanupRef.current = cleanup
|
||||
},
|
||||
[disabled]
|
||||
|
||||
@@ -38,7 +38,9 @@ function ActionRow({
|
||||
>
|
||||
{action.external === true ? <ExternalLink className="size-3.5" /> : null}
|
||||
{action.external === false ? <Globe className="size-3.5" /> : null}
|
||||
<span className="min-w-0 flex-1 text-left">{action.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-left" title={action.label}>
|
||||
{action.label}
|
||||
</span>
|
||||
<ShortcutKeyCombo keys={keys} keyCapClassName="min-w-5 px-1 py-0 text-[11px]" />
|
||||
</Button>
|
||||
)
|
||||
@@ -125,7 +127,7 @@ export function TerminalLinkActionPopover({
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
collisionPadding={8}
|
||||
className="w-max min-w-52 max-w-[min(17rem,calc(100vw-1rem))] p-1"
|
||||
className="w-max min-w-52 max-w-[min(21rem,calc(100vw-1rem))] p-1"
|
||||
data-terminal-link-action-popover
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TerminalLinkActionContext } from './terminal-link-action-request'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
canOpenWithSystemDefault: true,
|
||||
downloadAndOpen: vi.fn(),
|
||||
openDetectedFilePath: vi.fn(),
|
||||
worktreeRoot: false
|
||||
}))
|
||||
@@ -19,6 +20,10 @@ vi.mock('./terminal-worktree-path-link', () => ({
|
||||
resolveKnownWorktreeRootPathLink: () => (mocks.worktreeRoot ? { id: 'wt-2' } : null)
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-remote-file-download-open', () => ({
|
||||
downloadAndOpenRemoteTerminalFile: mocks.downloadAndOpen
|
||||
}))
|
||||
|
||||
import { handleTerminalFileLink } from './terminal-file-link-actions'
|
||||
|
||||
const deps = { worktreeId: 'wt-1', worktreePath: '/repo' }
|
||||
@@ -95,4 +100,61 @@ describe('terminal file link actions', () => {
|
||||
)
|
||||
expect(actionRequest).not.toHaveProperty('alternate')
|
||||
})
|
||||
|
||||
it('offers the same rows for a remote previewable file, downloading before the OS opens it', () => {
|
||||
mocks.canOpenWithSystemDefault = false
|
||||
const request = vi.fn()
|
||||
handleTerminalFileLink(
|
||||
'/repo/docs/report.html',
|
||||
null,
|
||||
null,
|
||||
plainEvent(),
|
||||
deps,
|
||||
context(request)
|
||||
)
|
||||
|
||||
const actionRequest = request.mock.calls[0][0]
|
||||
expect(actionRequest.primary.label).toBe('Open file')
|
||||
expect(actionRequest.alternate.label).toBe('Download & open with default app')
|
||||
|
||||
actionRequest.alternate.run()
|
||||
expect(mocks.downloadAndOpen).toHaveBeenCalledWith({}, '/repo/docs/report.html')
|
||||
expect(mocks.openDetectedFilePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps row parity between local and remote previewable files', () => {
|
||||
const localRequest = vi.fn()
|
||||
handleTerminalFileLink(
|
||||
'/repo/docs/report.html',
|
||||
null,
|
||||
null,
|
||||
plainEvent(),
|
||||
deps,
|
||||
context(localRequest)
|
||||
)
|
||||
mocks.canOpenWithSystemDefault = false
|
||||
const remoteRequest = vi.fn()
|
||||
handleTerminalFileLink(
|
||||
'/repo/docs/report.html',
|
||||
null,
|
||||
null,
|
||||
plainEvent(),
|
||||
deps,
|
||||
context(remoteRequest)
|
||||
)
|
||||
|
||||
const rowCount = (call: { alternate?: unknown }): number => 1 + (call.alternate ? 1 : 0)
|
||||
expect(rowCount(remoteRequest.mock.calls[0][0])).toBe(rowCount(localRequest.mock.calls[0][0]))
|
||||
})
|
||||
|
||||
// Why: a directory has nothing to hand the OS, and the download row would offer a transfer that
|
||||
// can only fail. The popover is built on hover, so the path shape decides rather than a stat.
|
||||
it('drops the remote download row for a path that announces itself as a directory', () => {
|
||||
mocks.canOpenWithSystemDefault = false
|
||||
const request = vi.fn()
|
||||
|
||||
handleTerminalFileLink('/repo/docs/', null, null, plainEvent(), deps, context(request))
|
||||
|
||||
expect(request.mock.calls[0][0]).not.toHaveProperty('alternate')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type TerminalLinkActionContext
|
||||
} from './terminal-link-action-request'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type TerminalFileLinkActionDeps = {
|
||||
@@ -52,6 +53,45 @@ export function handleTerminalFileLink(
|
||||
const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext, mappedPath)
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
||||
// Why: the OS can only launch a local file, so remote links keep the same row by
|
||||
// downloading first — local and remote workspaces offer the same actions.
|
||||
const systemDefaultRow = worktreeRoot
|
||||
? canOpenWithSystemDefault
|
||||
? {
|
||||
label: isMac
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openInFinder',
|
||||
'Open in Finder'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openFolder',
|
||||
'Open folder'
|
||||
),
|
||||
run: () =>
|
||||
openDetectedFilePath(filePath, line, column, { ...deps, openWithSystemDefault: true })
|
||||
}
|
||||
: null
|
||||
: canOpenWithSystemDefault
|
||||
? {
|
||||
label: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openWithDefaultApp',
|
||||
'Open with default app'
|
||||
),
|
||||
run: () =>
|
||||
openDetectedFilePath(filePath, line, column, { ...deps, openWithSystemDefault: true })
|
||||
}
|
||||
: // Why the path shape and not a stat: the popover is built synchronously on hover, and a
|
||||
// remote stat per link would put a round-trip in front of every terminal path. A directory
|
||||
// that does not announce itself with a separator still fails visibly, in the download toast.
|
||||
/[/\\]$/.test(mappedPath)
|
||||
? null
|
||||
: {
|
||||
label: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.downloadOpenWithDefaultApp',
|
||||
'Download & open with default app'
|
||||
),
|
||||
run: () => downloadAndOpenRemoteTerminalFile(fileContext, mappedPath)
|
||||
}
|
||||
return requestTerminalLinkAction(event, actionContext, {
|
||||
destination: actionDestination ?? mappedPath,
|
||||
kind: worktreeRoot ? 'workspace' : 'file',
|
||||
@@ -67,30 +107,6 @@ export function handleTerminalFileLink(
|
||||
),
|
||||
run: () => openDetectedFilePath(filePath, line, column, deps)
|
||||
},
|
||||
...(canOpenWithSystemDefault
|
||||
? {
|
||||
alternate: {
|
||||
label: worktreeRoot
|
||||
? isMac
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openInFinder',
|
||||
'Open in Finder'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openFolder',
|
||||
'Open folder'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openWithDefaultApp',
|
||||
'Open with default app'
|
||||
),
|
||||
run: () =>
|
||||
openDetectedFilePath(filePath, line, column, {
|
||||
...deps,
|
||||
openWithSystemDefault: true
|
||||
})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
...(systemDefaultRow ? { alternate: systemDefaultRow } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
|
||||
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
|
||||
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
|
||||
import {
|
||||
isRemoteRuntimeFileOperation,
|
||||
statRuntimePath,
|
||||
type RuntimeFileOperationArgs
|
||||
} from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
buildWorkspaceFileContext,
|
||||
canClientOsOpenWorkspaceFile
|
||||
} from '@/lib/workspace-file-host-routing'
|
||||
import { statRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
@@ -50,13 +50,7 @@ export function getTerminalFileContext(
|
||||
worktreePath: string,
|
||||
runtimeEnvironmentId?: string | null
|
||||
): RuntimeFileOperationArgs {
|
||||
const settings = useAppStore.getState().settings
|
||||
return {
|
||||
settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId),
|
||||
worktreeId: worktreeId || null,
|
||||
worktreePath,
|
||||
connectionId: getConnectionId(worktreeId || null) ?? undefined
|
||||
}
|
||||
return buildWorkspaceFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
|
||||
}
|
||||
|
||||
// Why: a WSL-runtime pane prints POSIX paths even when the worktree lives on a
|
||||
@@ -97,7 +91,7 @@ export function shouldOpenTerminalFileWithSystemDefault(
|
||||
fileContext: RuntimeFileOperationArgs,
|
||||
filePath: string
|
||||
): boolean {
|
||||
return !fileContext.connectionId && !isRemoteRuntimeFileOperation(fileContext, filePath)
|
||||
return canClientOsOpenWorkspaceFile(fileContext, filePath)
|
||||
}
|
||||
|
||||
let latestOpenDetectedFilePathRequestId = 0
|
||||
@@ -196,14 +190,28 @@ export function openDetectedFilePath(
|
||||
return
|
||||
}
|
||||
|
||||
if (openWithSystemDefault && !canOpenWithSystemDefault) {
|
||||
// Why: the popover names Shift+Cmd/Ctrl "Download & open with default app", and the OS
|
||||
// cannot launch a remote path, so the direct gesture must reach the same download.
|
||||
await downloadAndOpenRemoteTerminalFile(fileContext, mappedFilePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: local HTML files render in Orca's browser for ordinary Cmd/Ctrl-click,
|
||||
// and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default.
|
||||
if (
|
||||
isHtmlFilePath(mappedFilePath) &&
|
||||
shouldOpenTerminalFileWithSystemDefault(fileContext, mappedFilePath)
|
||||
) {
|
||||
openHtmlFileInBrowser(mappedFilePath, worktreeId)
|
||||
return
|
||||
if (isHtmlFilePath(mappedFilePath)) {
|
||||
if (shouldOpenTerminalFileWithSystemDefault(fileContext, mappedFilePath)) {
|
||||
openHtmlFileInBrowser(mappedFilePath, worktreeId)
|
||||
return
|
||||
}
|
||||
// Why: the same gesture renders remote HTML too, through the doc preview; only an
|
||||
// unsupported plan (e.g. a paired doc outside the worktree) falls back to source.
|
||||
const plan = getWorkspaceFilePreviewPlan(useAppStore.getState(), worktreeId, mappedFilePath)
|
||||
if (plan.status === 'doc-preview') {
|
||||
activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
|
||||
openFileInBrowserTab({ filePath: mappedFilePath, worktreeId })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const store = useAppStore.getState()
|
||||
|
||||
+61
-3
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openDetectedFilePath } from './terminal-link-handlers'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
|
||||
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
|
||||
import {
|
||||
flushAsyncWork,
|
||||
@@ -39,6 +42,15 @@ vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionId: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-remote-file-download-open', () => ({
|
||||
downloadAndOpenRemoteTerminalFile: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/file-preview', () => ({
|
||||
getWorkspaceFilePreviewPlan: vi.fn(() => ({ status: 'doc-preview' })),
|
||||
openFileInBrowserTab: vi.fn()
|
||||
}))
|
||||
|
||||
installTerminalLinkTestEnvironment(doubles)
|
||||
|
||||
describe('handleOscLink', () => {
|
||||
@@ -114,8 +126,7 @@ describe('handleOscLink', () => {
|
||||
|
||||
openDetectedFilePath('/home/me/repo/src/main.ts', null, null, {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/home/me/repo',
|
||||
openWithSystemDefault: true
|
||||
worktreePath: '/home/me/repo'
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
@@ -205,7 +216,7 @@ describe('handleOscLink', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open SSH html file links as client-local file browser tabs', async () => {
|
||||
it('downloads shift-modifier SSH file links before the OS opens them, like the popover row', async () => {
|
||||
setPlatform('Macintosh')
|
||||
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
|
||||
|
||||
@@ -217,6 +228,53 @@ describe('handleOscLink', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(openFilePathMock).not.toHaveBeenCalled()
|
||||
expect(openFileMock).not.toHaveBeenCalled()
|
||||
expect(downloadAndOpenRemoteTerminalFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'ssh-1' }),
|
||||
'/home/me/repo/report.html'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders plain-modifier SSH html links in the doc preview, not a client-local browser tab', async () => {
|
||||
setPlatform('Macintosh')
|
||||
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
|
||||
|
||||
openDetectedFilePath('/home/me/repo/report.html', null, null, {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/home/me/repo'
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(openFileMock).not.toHaveBeenCalled()
|
||||
expect(openFileInBrowserTab).toHaveBeenCalledWith({
|
||||
filePath: '/home/me/repo/report.html',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
// Why: the preview tab is the surface — activation must not re-seed a shell into a
|
||||
// workspace whose last terminal the user closed.
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
|
||||
providesInitialSurface: true
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the source editor when the preview plan is unsupported', async () => {
|
||||
setPlatform('Macintosh')
|
||||
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
|
||||
vi.mocked(getWorkspaceFilePreviewPlan).mockReturnValueOnce({
|
||||
status: 'unsupported',
|
||||
message: 'nope',
|
||||
reason: 'outside-worktree'
|
||||
})
|
||||
|
||||
openDetectedFilePath('/home/me/repo/report.html', null, null, {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/home/me/repo'
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(openFileInBrowserTab).not.toHaveBeenCalled()
|
||||
expect(openFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/home/me/repo/report.html',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { basename } from '@/lib/path'
|
||||
import { downloadRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
|
||||
/**
|
||||
* Remote counterpart of "Open with default app": the OS can only launch a local
|
||||
* file, so the workspace copy is downloaded to a user-chosen path first.
|
||||
*/
|
||||
export async function downloadAndOpenRemoteTerminalFile(
|
||||
fileContext: RuntimeFileOperationArgs,
|
||||
filePath: string
|
||||
): Promise<void> {
|
||||
const name = basename(filePath) || filePath
|
||||
try {
|
||||
const result = fileContext.connectionId
|
||||
? await window.api.fs.downloadFile({ filePath, connectionId: fileContext.connectionId })
|
||||
: await downloadRuntimeFile(fileContext, filePath, name)
|
||||
// Why: cancelling the native save dialog is a deliberate no-op, not a failure.
|
||||
if (result.canceled) {
|
||||
return
|
||||
}
|
||||
await window.api.shell.openFilePath(result.destinationPath)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
extractIpcErrorMessage(
|
||||
error,
|
||||
translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.downloadOpenFailed',
|
||||
"Failed to download '{{value0}}'.",
|
||||
{ value0: name }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { redactKagiSessionToken } from '../../../../shared/browser-url'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -105,4 +107,31 @@ export function registerBrowserStateIpcBridge(
|
||||
})
|
||||
})
|
||||
)
|
||||
// Why: the doc-preview scheme is desktop-only, so hosts without it (web client) simply have no channel.
|
||||
if (typeof window.api.docPreview?.onExternalLink === 'function') {
|
||||
unsubs.push(
|
||||
window.api.docPreview.onExternalLink(({ url }) => {
|
||||
// Why: an external link in a doc preview leaves the preview entirely — it becomes a normal
|
||||
// browser tab through the same path as any other new tab, local or paired.
|
||||
// Why: the click already left the preview, so a refused tab is a dead end unless it says so.
|
||||
const reportLinkFailure = (): void => {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.hooks.ipc.events.browserStateIpcBridge.docPreviewLinkFailed',
|
||||
'Could not open this link in Orca Browser.'
|
||||
)
|
||||
)
|
||||
}
|
||||
void useAppStore
|
||||
.getState()
|
||||
.openBrowserProfileTabInActiveWorkspace(url, null)
|
||||
.then((opened) => {
|
||||
if (!opened) {
|
||||
reportLinkFailure()
|
||||
}
|
||||
})
|
||||
.catch(reportLinkFailure)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toastError: vi.fn(),
|
||||
openBrowserProfileTabInActiveWorkspace: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
vi.mock('@/components/browser-pane/describe-page/live-browser-url-registry', () => ({
|
||||
rememberLiveBrowserUrl: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => null }))
|
||||
vi.mock('./browser-automation-bootstrap-lease', () => ({
|
||||
acquireBrowserAutomationBootstrapLease: vi.fn()
|
||||
}))
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
openBrowserProfileTabInActiveWorkspace: mocks.openBrowserProfileTabInActiveWorkspace,
|
||||
remoteBrowserPageHandlesByPageId: {}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge'
|
||||
|
||||
/** Every channel the bridge subscribes to, stubbed; only the preview link one is exercised here. */
|
||||
function installBridge(): (payload: { url: string }) => void {
|
||||
let externalLinkHandler: ((payload: { url: string }) => void) | null = null
|
||||
const noopSubscribe = (): (() => void) => () => {}
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: { onFullscreenChanged: noopSubscribe },
|
||||
browser: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => (): (() => void) => () => {}
|
||||
}
|
||||
),
|
||||
docPreview: {
|
||||
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
|
||||
externalLinkHandler = callback
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
registerBrowserStateIpcBridge([], () => false)
|
||||
if (!externalLinkHandler) {
|
||||
throw new Error('bridge did not subscribe to the doc preview external link channel')
|
||||
}
|
||||
return externalLinkHandler
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe('doc preview external links', () => {
|
||||
it('routes an external link into a browser tab', async () => {
|
||||
installBridge()({ url: 'https://example.com/docs' })
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith(
|
||||
'https://example.com/docs',
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the click already left the preview behind, so a refused tab is a dead end unless it says
|
||||
// so — the store reports that refusal by returning false, not by throwing.
|
||||
it('surfaces a refused tab instead of dropping the click', async () => {
|
||||
mocks.openBrowserProfileTabInActiveWorkspace.mockResolvedValue(false)
|
||||
|
||||
installBridge()({ url: 'https://example.com/docs' })
|
||||
|
||||
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
// Why the same sentence for a rejection: to the reader a tab that threw and a tab that was refused
|
||||
// are the same dead end, and an unhandled rejection would leave the press with no answer at all.
|
||||
it('surfaces a tab that failed rather than refused', async () => {
|
||||
mocks.openBrowserProfileTabInActiveWorkspace.mockRejectedValue(new Error('no workspace'))
|
||||
|
||||
installBridge()({ url: 'https://example.com/docs' })
|
||||
|
||||
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
|
||||
'browser.onNavigationUpdate',
|
||||
'browser.onOpenLinkInOrcaTab',
|
||||
'browser.onPaneFocus',
|
||||
'docPreview.onExternalLink',
|
||||
'emulator.onAutoAttach',
|
||||
'emulator.onPaneFocus',
|
||||
'gh.onPRRefreshEvent',
|
||||
@@ -159,6 +160,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
|
||||
'browser.onActivateView',
|
||||
'browser.onPaneFocus',
|
||||
'browser.onOpenLinkInOrcaTab',
|
||||
'docPreview.onExternalLink',
|
||||
'ui.onNewBrowserTab',
|
||||
'ui.onNewMarkdownTab',
|
||||
'ui.onNewSimulatorTab',
|
||||
|
||||
@@ -925,6 +925,11 @@
|
||||
},
|
||||
"ephemeralVmWorktreeCreation": {
|
||||
"sparseCheckoutUnsupported": "Provisioned-root recipes do not support sparse checkout."
|
||||
},
|
||||
"file": {
|
||||
"preview": {
|
||||
"pairedOutsideWorktree": "Files outside the workspace can't be previewed on a paired server yet."
|
||||
}
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
@@ -1083,6 +1088,13 @@
|
||||
"description": "Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orca’s Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.",
|
||||
"openManageSessions": "Open Manage Sessions",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"ipc": {
|
||||
"events": {
|
||||
"browserStateIpcBridge": {
|
||||
"docPreviewLinkFailed": "Could not open this link in Orca Browser."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -3072,7 +3084,9 @@
|
||||
"copyLink": "Copy link",
|
||||
"copied": "Copied",
|
||||
"copiedLink": "Copied link",
|
||||
"copyLinkFailed": "Failed to copy link"
|
||||
"copyLinkFailed": "Failed to copy link",
|
||||
"downloadOpenWithDefaultApp": "Download & open with default app",
|
||||
"downloadOpenFailed": "Failed to download '{{value0}}'."
|
||||
},
|
||||
"TerminalSessionStateSaveFailureDialog": {
|
||||
"6bee0c8f17": "Open Disk Space Analyzer",
|
||||
@@ -14763,6 +14777,26 @@
|
||||
"insertColumnRight": "Insert column right",
|
||||
"deleteRow": "Delete row",
|
||||
"deleteColumn": "Delete column"
|
||||
},
|
||||
"HtmlDocPreview": {
|
||||
"documentTooLargePanel": "This document is too large to preview. Open it in the editor instead.",
|
||||
"documentUnreadablePanel": "Orca could not read this file from the workspace.",
|
||||
"multipleAssetsFailedNotice": "{{count}} files in this document could not be loaded.",
|
||||
"assetTooLargeNotice": "{{path}} is too large to load in this preview.",
|
||||
"assetUnsupportedNotice": "This workspace cannot send {{path}} to a preview.",
|
||||
"assetUnreadableNotice": "Orca could not read {{path}} from the workspace.",
|
||||
"previewAriaLabel": "HTML preview",
|
||||
"reloadPreviewControl": "Reload preview",
|
||||
"previewUnavailableTitle": "Preview unavailable",
|
||||
"copyDocumentPathControl": "Copy file path",
|
||||
"documentPathCopied": "Copied",
|
||||
"workspaceFileChipLabel": "Workspace file",
|
||||
"previewMenuControl": "Preview options",
|
||||
"openSourceControl": "Open source file",
|
||||
"openExternallyControl": "Open with default app",
|
||||
"copyDocumentRelativePathControl": "Copy relative path",
|
||||
"openExternallyUnknownHostError": "Can't open '{{value0}}': the host that owns it is no longer known.",
|
||||
"downloadBlockedNotice": "Downloads are disabled in document previews."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
@@ -15082,7 +15116,8 @@
|
||||
"6e776f9ef9": "Download failed",
|
||||
"756bfc25c9": "Open",
|
||||
"09a9489aa5": "Show",
|
||||
"2a4c4b8e1f": "Copy"
|
||||
"2a4c4b8e1f": "Copy",
|
||||
"fileUrlUnsupported": "This browser tab cannot open local files. Use \"Open Preview to the Side\" on the file instead."
|
||||
},
|
||||
"BrowserToolbarMenu": {
|
||||
"429ef481f9": "Cancel",
|
||||
|
||||
@@ -3879,17 +3879,82 @@
|
||||
},
|
||||
"skills": {
|
||||
"SkillsPage": {
|
||||
"cb142070b4": "새로 고침", "a68dee6a32": "스킬 검색", "f43ad6edf3": "스킬", "ea72d6185b": "스킬을 검색하지 못했습니다", "dc4c3328ee": "파일 표시", "9963dff6d3": "설명을 찾을 수 없습니다.", "995fde8337": "스킬 파일을 표시하지 못했습니다", "4acd6d68ec": "스킬을 찾을 수 없습니다", "6a62a0168c": "일치하는 항목 없음", "08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.", "cd7893fbc1": "스킬 검색 중", "35b9a724a0": "사용 가능", "c13b82793c": "설치 관리", "aee7b99cc6": "링크에서 설치", "filterProvider": "에이전트별 필터", "filterSource": "소스별 필터", "allSources": "전체", "clearFilters": "필터 지우기", "closeSkills": "스킬 닫기", "closeTooltip": "닫기 · Esc", "moreActions": "추가 작업", "sharedLinks": "공유 링크", "emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.", "retry": "다시 시도", "remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.", "viewSwitch": "표시", "searchLinks": "링크 검색", "deleteSkills": "스킬 삭제…"
|
||||
"cb142070b4": "새로 고침",
|
||||
"a68dee6a32": "스킬 검색",
|
||||
"f43ad6edf3": "스킬",
|
||||
"ea72d6185b": "스킬을 검색하지 못했습니다",
|
||||
"dc4c3328ee": "파일 표시",
|
||||
"9963dff6d3": "설명을 찾을 수 없습니다.",
|
||||
"995fde8337": "스킬 파일을 표시하지 못했습니다",
|
||||
"4acd6d68ec": "스킬을 찾을 수 없습니다",
|
||||
"6a62a0168c": "일치하는 항목 없음",
|
||||
"08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.",
|
||||
"cd7893fbc1": "스킬 검색 중",
|
||||
"35b9a724a0": "사용 가능",
|
||||
"c13b82793c": "설치 관리",
|
||||
"aee7b99cc6": "링크에서 설치",
|
||||
"filterProvider": "에이전트별 필터",
|
||||
"filterSource": "소스별 필터",
|
||||
"allSources": "전체",
|
||||
"clearFilters": "필터 지우기",
|
||||
"closeSkills": "스킬 닫기",
|
||||
"closeTooltip": "닫기 · Esc",
|
||||
"moreActions": "추가 작업",
|
||||
"sharedLinks": "공유 링크",
|
||||
"emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.",
|
||||
"retry": "다시 시도",
|
||||
"remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.",
|
||||
"viewSwitch": "표시",
|
||||
"searchLinks": "링크 검색",
|
||||
"deleteSkills": "스킬 삭제…"
|
||||
},
|
||||
"SkillShareSelectionControls": { "01c5a15e02": "스킬 공유" },
|
||||
"SkillRow": { "updatedUnknown": "날짜 없음", "pathCopied": "경로 복사됨", "copyPath": "경로 복사", "detailPath": "경로", "skillActions": "{{value0}} 작업", "viewDetails": "세부 정보 보기", "deleteSkill": "삭제…" },
|
||||
"SkillRow": {
|
||||
"updatedUnknown": "날짜 없음",
|
||||
"pathCopied": "경로 복사됨",
|
||||
"copyPath": "경로 복사",
|
||||
"detailPath": "경로",
|
||||
"skillActions": "{{value0}} 작업",
|
||||
"viewDetails": "세부 정보 보기",
|
||||
"deleteSkill": "삭제…"
|
||||
},
|
||||
"SkillsList": { "listLabel": "스킬" },
|
||||
"sourceStatus": { "missing": "폴더를 찾을 수 없음", "remoteRepo": "원격 리포지토리 — 검색 안 됨", "unavailable": "검색 안 됨" },
|
||||
"sourceStatus": {
|
||||
"missing": "폴더를 찾을 수 없음",
|
||||
"remoteRepo": "원격 리포지토리 — 검색 안 됨",
|
||||
"unavailable": "검색 안 됨"
|
||||
},
|
||||
"sources": { "heading": "스킬 폴더" },
|
||||
"sourceKind": { "home": "홈", "workspace": "워크스페이스", "bundled": "번들", "plugin": "플러그인" },
|
||||
"count": { "skillOne": "스킬 {{count}}개", "skillOther": "스킬 {{count}}개", "sourceOne": "소스 {{count}}개", "sourceOther": "소스 {{count}}개", "fileOne": "파일 {{count}}개", "fileOther": "파일 {{count}}개", "resultOne": "결과 {{count}}개", "resultOther": "결과 {{count}}개", "selected": "{{count}}개 선택됨", "shareOne": "스킬 {{count}}개 공유", "shareOther": "스킬 {{count}}개 공유", "linkOne": "링크 {{count}}개", "linkOther": "링크 {{count}}개" },
|
||||
"sourceKind": {
|
||||
"home": "홈",
|
||||
"workspace": "워크스페이스",
|
||||
"bundled": "번들",
|
||||
"plugin": "플러그인"
|
||||
},
|
||||
"count": {
|
||||
"skillOne": "스킬 {{count}}개",
|
||||
"skillOther": "스킬 {{count}}개",
|
||||
"sourceOne": "소스 {{count}}개",
|
||||
"sourceOther": "소스 {{count}}개",
|
||||
"fileOne": "파일 {{count}}개",
|
||||
"fileOther": "파일 {{count}}개",
|
||||
"resultOne": "결과 {{count}}개",
|
||||
"resultOther": "결과 {{count}}개",
|
||||
"selected": "{{count}}개 선택됨",
|
||||
"shareOne": "스킬 {{count}}개 공유",
|
||||
"shareOther": "스킬 {{count}}개 공유",
|
||||
"linkOne": "링크 {{count}}개",
|
||||
"linkOther": "링크 {{count}}개"
|
||||
},
|
||||
"filter": { "allAgents": "모든 에이전트", "sharedAgent": "공유됨 (.agents)" },
|
||||
"SkillsSelectionHeader": { "exit": "선택 나가기", "exitTooltip": "선택 나가기 · Esc", "title": "공유할 스킬 선택", "selectAll": "가능한 {{count}}개 모두 선택", "clear": "지우기", "deleteTitle": "삭제할 스킬 선택" },
|
||||
"SkillsSelectionHeader": {
|
||||
"exit": "선택 나가기",
|
||||
"exitTooltip": "선택 나가기 · Esc",
|
||||
"title": "공유할 스킬 선택",
|
||||
"selectAll": "가능한 {{count}}개 모두 선택",
|
||||
"clear": "지우기",
|
||||
"deleteTitle": "삭제할 스킬 선택"
|
||||
},
|
||||
"SkillDetailDialog": { "agents": "에이전트", "updated": "업데이트됨", "copy": "복사" },
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "설치된 Orca 스킬이 오래되었습니다",
|
||||
|
||||
@@ -3889,17 +3889,90 @@
|
||||
},
|
||||
"skills": {
|
||||
"SkillsPage": {
|
||||
"cb142070b4": "刷新", "a68dee6a32": "搜索技能", "f43ad6edf3": "技能", "ea72d6185b": "无法扫描技能", "dc4c3328ee": "显示文件", "9963dff6d3": "未找到说明。", "995fde8337": "无法显示技能文件", "4acd6d68ec": "未找到技能", "6a62a0168c": "无匹配项", "08a321a984": "没有技能匹配当前搜索和筛选条件。", "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用", "c13b82793c": "管理安装", "aee7b99cc6": "从链接安装", "filterProvider": "按 Agent 筛选", "filterSource": "按来源筛选", "allSources": "全部", "clearFilters": "清除筛选", "closeSkills": "关闭技能", "closeTooltip": "关闭 · Esc", "moreActions": "更多操作", "sharedLinks": "共享链接", "emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。", "retry": "重试", "remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。", "viewSwitch": "显示", "searchLinks": "搜索链接", "deleteSkills": "删除技能…"
|
||||
"cb142070b4": "刷新",
|
||||
"a68dee6a32": "搜索技能",
|
||||
"f43ad6edf3": "技能",
|
||||
"ea72d6185b": "无法扫描技能",
|
||||
"dc4c3328ee": "显示文件",
|
||||
"9963dff6d3": "未找到说明。",
|
||||
"995fde8337": "无法显示技能文件",
|
||||
"4acd6d68ec": "未找到技能",
|
||||
"6a62a0168c": "无匹配项",
|
||||
"08a321a984": "没有技能匹配当前搜索和筛选条件。",
|
||||
"cd7893fbc1": "正在扫描技能",
|
||||
"35b9a724a0": "可用",
|
||||
"c13b82793c": "管理安装",
|
||||
"aee7b99cc6": "从链接安装",
|
||||
"filterProvider": "按 Agent 筛选",
|
||||
"filterSource": "按来源筛选",
|
||||
"allSources": "全部",
|
||||
"clearFilters": "清除筛选",
|
||||
"closeSkills": "关闭技能",
|
||||
"closeTooltip": "关闭 · Esc",
|
||||
"moreActions": "更多操作",
|
||||
"sharedLinks": "共享链接",
|
||||
"emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。",
|
||||
"retry": "重试",
|
||||
"remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。",
|
||||
"viewSwitch": "显示",
|
||||
"searchLinks": "搜索链接",
|
||||
"deleteSkills": "删除技能…"
|
||||
},
|
||||
"SkillShareSelectionControls": { "01c5a15e02": "共享技能" },
|
||||
"SkillRow": { "updatedUnknown": "无日期", "pathCopied": "路径已复制", "copyPath": "复制路径", "detailPath": "路径", "skillActions": "{{value0}} 的操作", "viewDetails": "查看详情", "deleteSkill": "删除…" },
|
||||
"SkillRow": {
|
||||
"updatedUnknown": "无日期",
|
||||
"pathCopied": "路径已复制",
|
||||
"copyPath": "复制路径",
|
||||
"detailPath": "路径",
|
||||
"skillActions": "{{value0}} 的操作",
|
||||
"viewDetails": "查看详情",
|
||||
"deleteSkill": "删除…"
|
||||
},
|
||||
"SkillsList": { "listLabel": "技能" },
|
||||
"sourceStatus": { "missing": "未找到文件夹", "remoteRepo": "远程仓库 — 未扫描", "unavailable": "未扫描" },
|
||||
"sourceStatus": {
|
||||
"missing": "未找到文件夹",
|
||||
"remoteRepo": "远程仓库 — 未扫描",
|
||||
"unavailable": "未扫描"
|
||||
},
|
||||
"sources": { "heading": "技能文件夹" },
|
||||
"sourceKind": { "home": "主目录", "workspace": "工作区", "bundled": "内置", "plugin": "插件" },
|
||||
"count": { "skillOne": "{{count}} 个技能", "skillOther": "{{count}} 个技能", "sourceOne": "{{count}} 个来源", "sourceOther": "{{count}} 个来源", "fileOne": "{{count}} 个文件", "fileOther": "{{count}} 个文件", "resultOne": "{{count}} 个结果", "resultOther": "{{count}} 个结果", "selected": "已选择 {{count}} 个", "shareOne": "共享 {{count}} 个技能", "shareOther": "共享 {{count}} 个技能", "linkOne": "{{count}} 个链接", "linkOther": "{{count}} 个链接", "deleteOne": "删除 {{count}} 个技能", "deleteOther": "删除 {{count}} 个技能", "deletedOne": "已删除 {{count}} 个技能", "deletedOther": "已删除 {{count}} 个技能", "deleteFolderOne": "{{count}} 个文件夹", "deleteFolderOther": "{{count}} 个文件夹", "deleteLinkOne": "{{count}} 个链接", "deleteLinkOther": "{{count}} 个链接" },
|
||||
"sourceKind": {
|
||||
"home": "主目录",
|
||||
"workspace": "工作区",
|
||||
"bundled": "内置",
|
||||
"plugin": "插件"
|
||||
},
|
||||
"count": {
|
||||
"skillOne": "{{count}} 个技能",
|
||||
"skillOther": "{{count}} 个技能",
|
||||
"sourceOne": "{{count}} 个来源",
|
||||
"sourceOther": "{{count}} 个来源",
|
||||
"fileOne": "{{count}} 个文件",
|
||||
"fileOther": "{{count}} 个文件",
|
||||
"resultOne": "{{count}} 个结果",
|
||||
"resultOther": "{{count}} 个结果",
|
||||
"selected": "已选择 {{count}} 个",
|
||||
"shareOne": "共享 {{count}} 个技能",
|
||||
"shareOther": "共享 {{count}} 个技能",
|
||||
"linkOne": "{{count}} 个链接",
|
||||
"linkOther": "{{count}} 个链接",
|
||||
"deleteOne": "删除 {{count}} 个技能",
|
||||
"deleteOther": "删除 {{count}} 个技能",
|
||||
"deletedOne": "已删除 {{count}} 个技能",
|
||||
"deletedOther": "已删除 {{count}} 个技能",
|
||||
"deleteFolderOne": "{{count}} 个文件夹",
|
||||
"deleteFolderOther": "{{count}} 个文件夹",
|
||||
"deleteLinkOne": "{{count}} 个链接",
|
||||
"deleteLinkOther": "{{count}} 个链接"
|
||||
},
|
||||
"filter": { "allAgents": "所有 Agent", "sharedAgent": "共享 (.agents)" },
|
||||
"SkillsSelectionHeader": { "exit": "退出选择", "exitTooltip": "退出选择 · Esc", "title": "选择要共享的技能", "selectAll": "选择全部 {{count}} 个符合条件的项目", "clear": "清除", "deleteTitle": "选择要删除的技能" },
|
||||
"SkillsSelectionHeader": {
|
||||
"exit": "退出选择",
|
||||
"exitTooltip": "退出选择 · Esc",
|
||||
"title": "选择要共享的技能",
|
||||
"selectAll": "选择全部 {{count}} 个符合条件的项目",
|
||||
"clear": "清除",
|
||||
"deleteTitle": "选择要删除的技能"
|
||||
},
|
||||
"SkillDetailDialog": { "agents": "Agent", "updated": "已更新", "copy": "复制" },
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "已安装的 Orca 技能已过期",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateBrowserWorkspaceTab } from '@/lib/browser-workspace-tab-activation'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { isBlankBrowserUrl } from './browser-palette-search'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
@@ -59,18 +60,16 @@ export function activateBrowserPagePaletteResult({
|
||||
return { status: 'failed', reason: 'missing-worktree' }
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const matchingUnifiedTab = (state.unifiedTabsByWorktree[worktree.id] ?? []).find(
|
||||
(candidate) => candidate.contentType === 'browser' && candidate.entityId === workspace.id
|
||||
)
|
||||
// Why: the pane renders whatever the group's active tab is, so without a unified
|
||||
// tab the browser state would go active behind a tab that never shows the page.
|
||||
if (!matchingUnifiedTab) {
|
||||
// Why the failure and not a bare activation: without a unified tab the browser state would go
|
||||
// active behind a tab that never shows the page.
|
||||
if (
|
||||
!activateBrowserWorkspaceTab({
|
||||
worktreeId: worktree.id,
|
||||
workspaceId: workspace.id,
|
||||
pageId
|
||||
})
|
||||
) {
|
||||
return { status: 'failed', reason: 'missing-tab' }
|
||||
}
|
||||
state.focusGroup(worktree.id, matchingUnifiedTab.groupId)
|
||||
state.activateTab(matchingUnifiedTab.id)
|
||||
state.setActiveBrowserTab(workspace.id)
|
||||
state.setActiveBrowserPage(workspace.id, pageId)
|
||||
return { status: 'activated', pageId, focusTarget }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
/**
|
||||
* Bring a browser workspace forward as the surface the reader is in.
|
||||
*
|
||||
* Why the unified tab and not just the browser state: the pane renders whatever its group's active
|
||||
* tab is, so selecting the workspace alone leaves the page live behind a tab that never shows it.
|
||||
* Returns false when the workspace has no unified tab yet, which is the caller's cue that there is
|
||||
* nothing to bring forward.
|
||||
*/
|
||||
export function activateBrowserWorkspaceTab(params: {
|
||||
worktreeId: string
|
||||
workspaceId: string
|
||||
pageId?: string
|
||||
}): boolean {
|
||||
const state = useAppStore.getState()
|
||||
const unifiedTab = (state.unifiedTabsByWorktree[params.worktreeId] ?? []).find(
|
||||
(candidate) => candidate.contentType === 'browser' && candidate.entityId === params.workspaceId
|
||||
)
|
||||
if (!unifiedTab) {
|
||||
return false
|
||||
}
|
||||
state.focusGroup(params.worktreeId, unifiedTab.groupId)
|
||||
state.activateTab(unifiedTab.id)
|
||||
state.setActiveBrowserTab(params.workspaceId)
|
||||
if (params.pageId) {
|
||||
state.setActiveBrowserPage(params.workspaceId, params.pageId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connectionId: null as string | null | undefined,
|
||||
environmentId: null as string | null,
|
||||
mintGrant: vi.fn(),
|
||||
revokeGrant: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connection-owner-resolution', () => ({
|
||||
getConnectionIdForFileFromState: () => mocks.connectionId
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => mocks.environmentId
|
||||
}))
|
||||
|
||||
import {
|
||||
buildDocPreviewGrantRequest,
|
||||
ensureDocPreviewGrant,
|
||||
releaseDocPreviewGrant
|
||||
} from './doc-preview-grants'
|
||||
|
||||
const state = { getKnownWorktreeById: () => ({ id: 'wt-1', path: '/srv/repo' }) } as never
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.connectionId = null
|
||||
mocks.environmentId = null
|
||||
mocks.mintGrant.mockResolvedValue({ grantId: 'grant-1', url: 'orca-preview://grant-1/a.html' })
|
||||
vi.stubGlobal('window', {
|
||||
api: { docPreview: { mintGrant: mocks.mintGrant, revokeGrant: mocks.revokeGrant } }
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDocPreviewGrantRequest', () => {
|
||||
// Why: SSH previews are unrestricted by design, and a document outside every workspace has no
|
||||
// boundary to root a grant in.
|
||||
it('roots an SSH grant outside the workspace at the document directory', () => {
|
||||
mocks.connectionId = 'ssh-1'
|
||||
|
||||
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/home/alice/docs/report.html')).toEqual({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'report.html'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: reports keep their assets in a sibling directory, so `../assets/app.css` has to resolve.
|
||||
it('roots a document inside the workspace at the workspace root', () => {
|
||||
mocks.connectionId = 'ssh-1'
|
||||
|
||||
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/srv/repo/docs/report.html')).toEqual({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/srv/repo',
|
||||
entryRelativePath: 'docs/report.html'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the worktree selector and root for a paired runtime grant', () => {
|
||||
mocks.environmentId = 'env-1'
|
||||
|
||||
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/srv/repo/docs/report.html')).toEqual({
|
||||
owner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'env-1',
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/srv/repo'
|
||||
},
|
||||
root: '/srv/repo',
|
||||
entryRelativePath: 'docs/report.html'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: files.read is worktree-scoped, so a paired document outside it has no readable root.
|
||||
it('refuses a paired document outside the workspace', () => {
|
||||
mocks.environmentId = 'env-1'
|
||||
|
||||
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/var/tmp/report.html')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a local workspace, which has no remote channel to read over', () => {
|
||||
expect(buildDocPreviewGrantRequest(state, 'wt-1', '/tmp/report.html')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('doc preview grant lifetime', () => {
|
||||
it('mints once for repeated mounts of the same preview tab', async () => {
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
ensureDocPreviewGrant('preview-1', request),
|
||||
ensureDocPreviewGrant('preview-1', request)
|
||||
])
|
||||
|
||||
// Why: React StrictMode double-invokes mount effects in dev; a second mint would
|
||||
// strand the first grant and a mount-scoped revoke would kill the live webview.
|
||||
expect(mocks.mintGrant).toHaveBeenCalledOnce()
|
||||
expect(first).toBe(second)
|
||||
expect(mocks.revokeGrant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes on release and mints fresh afterwards', async () => {
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
await ensureDocPreviewGrant('preview-2', request)
|
||||
|
||||
releaseDocPreviewGrant('preview-2')
|
||||
await vi.waitFor(() => expect(mocks.revokeGrant).toHaveBeenCalledWith('grant-1'))
|
||||
|
||||
await ensureDocPreviewGrant('preview-2', request)
|
||||
expect(mocks.mintGrant).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores a release for a tab that never minted a grant', () => {
|
||||
releaseDocPreviewGrant('never-opened')
|
||||
|
||||
expect(mocks.revokeGrant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a mint that rejects after the tab was released and reopened must not evict the entry the
|
||||
// reopen created, or that grant can never be revoked from the tab that owns it.
|
||||
it('leaves the entry of a later mint alone when an earlier one rejects', async () => {
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
let failStaleMint: (error: Error) => void = () => {}
|
||||
mocks.mintGrant.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failStaleMint = reject
|
||||
})
|
||||
)
|
||||
mocks.mintGrant.mockResolvedValueOnce({
|
||||
grantId: 'grant-2',
|
||||
url: 'orca-preview://grant-2/a.html'
|
||||
})
|
||||
|
||||
const stale = ensureDocPreviewGrant('preview-4', request)
|
||||
releaseDocPreviewGrant('preview-4')
|
||||
await ensureDocPreviewGrant('preview-4', request)
|
||||
failStaleMint(new Error('runtime offline'))
|
||||
await expect(stale).rejects.toThrow('runtime offline')
|
||||
|
||||
releaseDocPreviewGrant('preview-4')
|
||||
await vi.waitFor(() => expect(mocks.revokeGrant).toHaveBeenCalledWith('grant-2'))
|
||||
})
|
||||
|
||||
it('does not cache a failed mint', async () => {
|
||||
mocks.mintGrant.mockRejectedValueOnce(new Error('runtime offline'))
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
|
||||
await expect(ensureDocPreviewGrant('preview-3', request)).rejects.toThrow('runtime offline')
|
||||
await expect(ensureDocPreviewGrant('preview-3', request)).resolves.toMatchObject({
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { DocPreviewGrantRequest } from '../../../preload/api/doc-preview-api'
|
||||
import { basename, dirname, getRelativePathInsideRoot } from '@/lib/path'
|
||||
import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
export type DocPreviewGrantHandle = { grantId: string; url: string }
|
||||
|
||||
/**
|
||||
* Grants are keyed by preview tab id, never by effect mount: React StrictMode
|
||||
* double-invokes mount effects in dev, and a mount-scoped grant would be revoked
|
||||
* out from under the surviving webview. Release is driven by tab close instead.
|
||||
*/
|
||||
const grantsByPreviewId = new Map<string, Promise<DocPreviewGrantHandle>>()
|
||||
|
||||
/** The page is filled in by `ensureDocPreviewGrant`, so the grant and its key name one surface. */
|
||||
export type DocPreviewGrantLocation = Omit<DocPreviewGrantRequest, 'browserPageId'>
|
||||
|
||||
export function buildDocPreviewGrantRequest(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
filePath: string
|
||||
): DocPreviewGrantLocation | null {
|
||||
const worktreeRoot = state.getKnownWorktreeById(worktreeId)?.path ?? null
|
||||
// Why the workspace root and not the document's folder: reports keep their assets in a sibling
|
||||
// directory (`../assets/app.css`), which a folder-rooted grant refuses. This is no wider than the
|
||||
// channel already allows — files.read is worktree-scoped on paired hosts either way.
|
||||
const worktreeRelativePath = getRelativePathInsideRoot(filePath, worktreeRoot)
|
||||
const root = worktreeRoot && worktreeRelativePath ? worktreeRoot : dirname(filePath)
|
||||
const entryRelativePath = worktreeRelativePath ?? basename(filePath)
|
||||
if (!root || !entryRelativePath) {
|
||||
return null
|
||||
}
|
||||
const connectionId = getConnectionIdForFileFromState(state, worktreeId, filePath)
|
||||
if (connectionId) {
|
||||
// Why SSH keeps a document-folder root when the file sits outside the workspace: those previews
|
||||
// are unrestricted by design, and there is no workspace boundary to root them in.
|
||||
return { owner: { kind: 'ssh', connectionId }, root, entryRelativePath }
|
||||
}
|
||||
const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
|
||||
if (!environmentId || !worktreeRoot || !worktreeRelativePath) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
owner: {
|
||||
kind: 'runtime',
|
||||
environmentId,
|
||||
worktreeSelector: toRuntimeWorktreeSelector(worktreeId),
|
||||
worktreeRoot
|
||||
},
|
||||
root,
|
||||
entryRelativePath
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureDocPreviewGrant(
|
||||
previewId: string,
|
||||
location: DocPreviewGrantLocation
|
||||
): Promise<DocPreviewGrantHandle> {
|
||||
const existing = grantsByPreviewId.get(previewId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const pending: Promise<DocPreviewGrantHandle> = window.api.docPreview
|
||||
.mintGrant({ ...location, browserPageId: previewId })
|
||||
.catch((error: unknown) => {
|
||||
// Why the identity check: a release and a fresh ensure can both land before this rejects, and
|
||||
// an unconditional delete would evict the newer entry, leaving its grant unrevokable.
|
||||
if (grantsByPreviewId.get(previewId) === pending) {
|
||||
grantsByPreviewId.delete(previewId)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
grantsByPreviewId.set(previewId, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
export function releaseDocPreviewGrant(previewId: string): void {
|
||||
const pending = grantsByPreviewId.get(previewId)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
grantsByPreviewId.delete(previewId)
|
||||
void pending.then((handle) => window.api.docPreview.revokeGrant(handle.grantId)).catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { getHostSettingOverride } from '../../../shared/host-setting-overrides'
|
||||
import {
|
||||
getExecutionHostIdForWorktree,
|
||||
getExplicitRuntimeEnvironmentIdForWorktree
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import { selectRuntimeAwareSshTargetLabel } from '@/store/slices/runtime-environment-ssh-selectors'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
/**
|
||||
* What to call an execution host in front of a user: their own rename first, then the machine's
|
||||
* published name (paired runtime) or the SSH target's label, and the raw id only as a last resort.
|
||||
*/
|
||||
export function selectExecutionHostDisplayLabel(
|
||||
state: AppState,
|
||||
hostId: ExecutionHostId,
|
||||
// SSH labels are published per runtime environment when the target is reached through one.
|
||||
options: { sshEnvironmentId?: string | null } = {}
|
||||
): string {
|
||||
const override = getHostSettingOverride(state.settings, hostId, 'displayLabel')
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (parsed?.kind === 'runtime') {
|
||||
const name = state.runtimeEnvironments
|
||||
?.find((environment) => environment.id === parsed.environmentId)
|
||||
?.name.trim()
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
if (parsed?.kind === 'ssh') {
|
||||
return selectRuntimeAwareSshTargetLabel(
|
||||
state,
|
||||
options.sshEnvironmentId ?? null,
|
||||
parsed.targetId
|
||||
)
|
||||
}
|
||||
return getExecutionHostLabel(hostId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The machine a worktree's files live on, or null when ownership is still contested — the
|
||||
* `unresolved-owner` sentinel is routing bookkeeping and must never reach a reader as a host name.
|
||||
*/
|
||||
export function selectWorktreeHostDisplayLabel(state: AppState, worktreeId: string): string | null {
|
||||
const hostId = getExecutionHostIdForWorktree(state, worktreeId)
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (parsed?.kind === 'runtime' && parsed.environmentId === 'unresolved-owner') {
|
||||
return null
|
||||
}
|
||||
return selectExecutionHostDisplayLabel(state, hostId, {
|
||||
sshEnvironmentId:
|
||||
parsed?.kind === 'ssh' ? getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) : null
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The Explorer row decides whether to show the preview action with the hook, and activating it
|
||||
// runs the plan. The hook used to re-derive that answer from its own copy of the rules; it now
|
||||
// delegates, and these pin the two to one answer so a future rule change cannot split them.
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
browserAvailability: { state: 'enabled', provider: 'local-client' } as
|
||||
| { state: 'enabled'; provider: 'local-client' | 'paired-runtime' }
|
||||
| { state: 'hidden'; reason: string },
|
||||
environmentId: null as string | null
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock('@/lib/client-creation-action-policy', () => ({
|
||||
getClientCreationActionPolicy: () => ({ 'managed-browser': mocks.browserAvailability })
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => mocks.environmentId
|
||||
}))
|
||||
|
||||
const storeState = {
|
||||
getKnownWorktreeById: () => ({ id: 'wt-1', path: '/repo' }),
|
||||
repos: [{ id: 'repo-1', connectionId: null }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(storeState), {
|
||||
getState: () => storeState
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
canShowWorkspaceFileBrowserAction,
|
||||
useWorkspaceFileBrowserActionPredicate
|
||||
} from './file-preview'
|
||||
|
||||
const FILE_PATH = '/repo/report.html'
|
||||
|
||||
function predicateAnswer(): boolean {
|
||||
return renderHook(() => useWorkspaceFileBrowserActionPredicate('wt-1')).result.current(FILE_PATH)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.browserAvailability = { state: 'enabled', provider: 'local-client' }
|
||||
mocks.environmentId = null
|
||||
})
|
||||
|
||||
describe('workspace file browser action visibility', () => {
|
||||
it('agrees with the plan for a local workspace that can open a browser', () => {
|
||||
expect(predicateAnswer()).toBe(
|
||||
canShowWorkspaceFileBrowserAction(storeState as never, 'wt-1', FILE_PATH)
|
||||
)
|
||||
expect(predicateAnswer()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the action when the local workspace has no managed browser', () => {
|
||||
mocks.browserAvailability = { state: 'hidden', reason: 'browser unavailable' }
|
||||
|
||||
expect(canShowWorkspaceFileBrowserAction(storeState as never, 'wt-1', FILE_PATH)).toBe(false)
|
||||
expect(predicateAnswer()).toBe(false)
|
||||
})
|
||||
|
||||
it('agrees with the plan on a paired workspace with no managed browser', () => {
|
||||
mocks.environmentId = 'env-1'
|
||||
mocks.browserAvailability = { state: 'hidden', reason: 'browser unavailable' }
|
||||
|
||||
expect(predicateAnswer()).toBe(
|
||||
canShowWorkspaceFileBrowserAction(storeState as never, 'wt-1', FILE_PATH)
|
||||
)
|
||||
expect(predicateAnswer()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a stable predicate identity across re-renders of the same workspace', () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspaceFileBrowserActionPredicate('wt-1'))
|
||||
const first = result.current
|
||||
|
||||
rerender()
|
||||
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user