mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(desktop): composite the workspace browser with the app UI
The workspace browser was a native WebContentsView, which always paints above the DOM. Switching to the skills or settings page left the page floating over it, and menus over the page needed a screenshot swap that showed up clipped and flashed white. Pages are now <webview> guests kept in a layer inside the session panel that is never unmounted, so other pages, menus and dialogs draw over them like any element. The main process adopts each guest and keeps all in-page behaviour: navigation, history, find, zoom, capture, PDF, downloads, shortcuts and annotation. - Guard every attach in the main window: browser partition only, start at about:blank, preload and sandbox pinned, reported ids validated. - Match the native page: drop the blank history entry, keep page zoom independent of app zoom, allow popups so they still become tabs. - Keep app drags working over a page and close menus on a click into it. - Tell the side dock it is off screen when the session page is hidden. - Remove the overlay snapshot machinery and native bounds syncing.
This commit is contained in:
@@ -56,16 +56,17 @@ describe('Electron IPC capabilities', () => {
|
||||
]) expect(validateElectronIpcPayload(channel, { ...payload, ...patch })).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts optional initial browser visibility without widening the create payload', () => {
|
||||
it('requires the renderer guest id on create and accepts no geometry', () => {
|
||||
const channel = ELECTRON_IPC_CHANNELS.workspaceBrowserCreate
|
||||
const identity = { tabId: 'wb-1', storageId: 'store-1' }
|
||||
const identity = { tabId: 'wb-1', storageId: 'store-1', webContentsId: 7 }
|
||||
expect(validateElectronIpcPayload(channel, identity)).toBe(true)
|
||||
expect(validateElectronIpcPayload(channel, { ...identity, visible: false })).toBe(true)
|
||||
expect(validateElectronIpcPayload(channel, { ...identity, visible: true })).toBe(true)
|
||||
for (const visible of ['false', 0, null, {}]) {
|
||||
expect(validateElectronIpcPayload(channel, { ...identity, visible })).toBe(false)
|
||||
expect(validateElectronIpcPayload(channel, { ...identity, url: 'https://example.com/' })).toBe(true)
|
||||
for (const webContentsId of [undefined, 0, -1, 1.5, '7', Number.NaN, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
expect(validateElectronIpcPayload(channel, { ...identity, webContentsId })).toBe(false)
|
||||
}
|
||||
expect(validateElectronIpcPayload(channel, { ...identity, visible: false, unknown: true })).toBe(false)
|
||||
// Pages are drawn by the renderer now; the old native-view options are gone.
|
||||
expect(validateElectronIpcPayload(channel, { ...identity, visible: false })).toBe(false)
|
||||
expect(validateElectronIpcPayload(channel, { ...identity, bounds: { x: 0, y: 0, width: 1, height: 1 } })).toBe(false)
|
||||
})
|
||||
|
||||
it('has a validator for every exposed invoke channel', () => {
|
||||
@@ -74,15 +75,6 @@ describe('Electron IPC capabilities', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('limits presentation snapshots to a single browser page id', () => {
|
||||
const channel = ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot
|
||||
expect(validateElectronIpcPayload(channel, { tabId: 'wb-1' })).toBe(true)
|
||||
for (const payload of [{}, { tabId: '' }, { tabId: 'wb-1', kind: 'full' }, { tabId: 'wb-1', url: 'https://example.com' }]) {
|
||||
expect(validateElectronIpcPayload(channel, payload)).toBe(false)
|
||||
}
|
||||
expect(isElectronIpcChannelAllowedForPetWindow(channel)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects channels outside the desktop host contract', () => {
|
||||
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetVersion)).toBe(true)
|
||||
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetLocalePreference)).toBe(true)
|
||||
|
||||
@@ -215,14 +215,17 @@ const workspaceBrowserTab: Validator = value =>
|
||||
&& hasOnlyKeys(value, ['tabId'])
|
||||
&& isWorkspaceBrowserId(value.tabId)
|
||||
|
||||
// `webContentsId` names the renderer's `<webview>` guest. It is only a claim:
|
||||
// the main process adopts it solely if it is a browser guest of the main window.
|
||||
const workspaceBrowserCreate: Validator = value =>
|
||||
isRecord(value)
|
||||
&& hasOnlyKeys(value, ['tabId', 'storageId', 'url', 'bounds', 'visible'])
|
||||
&& hasOnlyKeys(value, ['tabId', 'storageId', 'url', 'webContentsId'])
|
||||
&& isWorkspaceBrowserId(value.tabId)
|
||||
&& isWorkspaceBrowserId(value.storageId)
|
||||
&& (value.url === undefined || (typeof value.url === 'string' && value.url.length <= 8_192))
|
||||
&& (value.bounds === undefined || boundsPayload(value.bounds))
|
||||
&& (value.visible === undefined || typeof value.visible === 'boolean')
|
||||
&& typeof value.webContentsId === 'number'
|
||||
&& Number.isSafeInteger(value.webContentsId)
|
||||
&& value.webContentsId > 0
|
||||
|
||||
const workspaceBrowserMenuLabelKeys = ['find', 'print', 'zoom', 'zoomIn', 'zoomOut', 'zoomReset', 'capture', 'pickElement', 'downloads', 'history', 'openExternal']
|
||||
|
||||
@@ -256,12 +259,6 @@ const workspaceBrowserReload: Validator = value =>
|
||||
&& isWorkspaceBrowserId(value.tabId)
|
||||
&& (value.ignoreCache === undefined || typeof value.ignoreCache === 'boolean')
|
||||
|
||||
const workspaceBrowserSetBounds: Validator = value =>
|
||||
isRecord(value)
|
||||
&& hasOnlyKeys(value, ['tabId', 'bounds'])
|
||||
&& isWorkspaceBrowserId(value.tabId)
|
||||
&& boundsPayload(value.bounds)
|
||||
|
||||
const workspaceBrowserSetVisible: Validator = value =>
|
||||
isRecord(value)
|
||||
&& hasOnlyKeys(value, ['tabId', 'visible'])
|
||||
@@ -402,13 +399,11 @@ export const ELECTRON_IPC_VALIDATORS = {
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserGoForward]: workspaceBrowserTab,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserReload]: workspaceBrowserReload,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserStop]: workspaceBrowserTab,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetBounds]: workspaceBrowserSetBounds,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetVisible]: workspaceBrowserSetVisible,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetZoom]: workspaceBrowserSetZoom,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserFind]: workspaceBrowserFind,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserStopFind]: workspaceBrowserTab,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserCapture]: workspaceBrowserCapture,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot]: workspaceBrowserTab,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserMessage]: workspaceBrowserMessage,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserPrintToPdf]: workspaceBrowserTab,
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserClose]: workspaceBrowserTab,
|
||||
|
||||
@@ -71,13 +71,11 @@ export const ELECTRON_IPC_CHANNELS = {
|
||||
workspaceBrowserGoForward: 'desktop:workspace-browser:go-forward',
|
||||
workspaceBrowserReload: 'desktop:workspace-browser:reload',
|
||||
workspaceBrowserStop: 'desktop:workspace-browser:stop',
|
||||
workspaceBrowserSetBounds: 'desktop:workspace-browser:set-bounds',
|
||||
workspaceBrowserSetVisible: 'desktop:workspace-browser:set-visible',
|
||||
workspaceBrowserSetZoom: 'desktop:workspace-browser:set-zoom',
|
||||
workspaceBrowserFind: 'desktop:workspace-browser:find',
|
||||
workspaceBrowserStopFind: 'desktop:workspace-browser:stop-find',
|
||||
workspaceBrowserCapture: 'desktop:workspace-browser:capture',
|
||||
workspaceBrowserSnapshot: 'desktop:workspace-browser:snapshot',
|
||||
workspaceBrowserMessage: 'desktop:workspace-browser:message',
|
||||
workspaceBrowserPrintToPdf: 'desktop:workspace-browser:print-to-pdf',
|
||||
workspaceBrowserClose: 'desktop:workspace-browser:close',
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
createPreviewSessionPartition,
|
||||
isAllowlistedMainRendererMediaRequest,
|
||||
} from './services/previewSession'
|
||||
import { applyWorkspaceBrowserAttachPolicy } from './services/workspaceBrowserGuest'
|
||||
import {
|
||||
WORKSPACE_BROWSER_INITIAL_SRC,
|
||||
WORKSPACE_BROWSER_PARTITION,
|
||||
} from '../src/lib/workspace/browserGuestContract'
|
||||
|
||||
const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'))
|
||||
? process.cwd()
|
||||
@@ -80,9 +85,15 @@ describe('Electron preview security boundary', () => {
|
||||
})
|
||||
|
||||
it('locks workspace browser sandboxing on', () => {
|
||||
expect(workspaceBrowserServiceSource).toContain('sandbox: true')
|
||||
expect(workspaceBrowserServiceSource).toContain('contextIsolation: true')
|
||||
expect(workspaceBrowserServiceSource).toContain('nodeIntegration: false')
|
||||
// Pages are `<webview>` guests now; their preferences are pinned by the
|
||||
// attach policy, which the main window must install.
|
||||
const preferences: Record<string, unknown> = { sandbox: false, contextIsolation: false, nodeIntegration: true }
|
||||
expect(applyWorkspaceBrowserAttachPolicy(preferences, {
|
||||
partition: WORKSPACE_BROWSER_PARTITION,
|
||||
src: WORKSPACE_BROWSER_INITIAL_SRC,
|
||||
}, { preload: '/preview-preload.cjs' })).toBe(true)
|
||||
expect(preferences).toMatchObject({ sandbox: true, contextIsolation: true, nodeIntegration: false })
|
||||
expect(mainWindowSource).toContain('installWorkspaceBrowserGuestPolicy(mainWindow.webContents')
|
||||
})
|
||||
|
||||
it('lets only the main window host workspace browser pages', () => {
|
||||
@@ -132,9 +143,15 @@ describe('Electron preview security boundary', () => {
|
||||
})
|
||||
|
||||
it('locks workspace browser sandboxing on', () => {
|
||||
expect(workspaceBrowserServiceSource).toContain('sandbox: true')
|
||||
expect(workspaceBrowserServiceSource).toContain('contextIsolation: true')
|
||||
expect(workspaceBrowserServiceSource).toContain('nodeIntegration: false')
|
||||
// Pages are `<webview>` guests now; their preferences are pinned by the
|
||||
// attach policy, which the main window must install.
|
||||
const preferences: Record<string, unknown> = { sandbox: false, contextIsolation: false, nodeIntegration: true }
|
||||
expect(applyWorkspaceBrowserAttachPolicy(preferences, {
|
||||
partition: WORKSPACE_BROWSER_PARTITION,
|
||||
src: WORKSPACE_BROWSER_INITIAL_SRC,
|
||||
}, { preload: '/preview-preload.cjs' })).toBe(true)
|
||||
expect(preferences).toMatchObject({ sandbox: true, contextIsolation: true, nodeIntegration: false })
|
||||
expect(mainWindowSource).toContain('installWorkspaceBrowserGuestPolicy(mainWindow.webContents')
|
||||
})
|
||||
|
||||
it('lets only the main window host workspace browser pages', () => {
|
||||
|
||||
+52
-40
@@ -1,5 +1,5 @@
|
||||
import { PublicAccessManager } from './services/publicAccess'
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, nativeTheme, Notification, screen, session, systemPreferences, WebContentsView } from 'electron'
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, nativeTheme, Notification, screen, session, systemPreferences, WebContentsView, webContents } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import path from 'node:path'
|
||||
import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels'
|
||||
@@ -33,11 +33,15 @@ import { ElectronPreviewService, type PreviewBounds } from './services/preview'
|
||||
import {
|
||||
ElectronWorkspaceBrowserService,
|
||||
WORKSPACE_BROWSER_PARTITION,
|
||||
type WorkspaceBrowserBounds,
|
||||
type WorkspaceBrowserCaptureKind,
|
||||
type WorkspaceBrowserCreateOptions,
|
||||
type WorkspaceBrowserFindOptions,
|
||||
type WorkspaceBrowserWebContentsLike,
|
||||
} from './services/workspaceBrowser'
|
||||
import {
|
||||
installWorkspaceBrowserGuestPolicy,
|
||||
resolveWorkspaceBrowserGuest,
|
||||
} from './services/workspaceBrowserGuest'
|
||||
import {
|
||||
configureLocalServerRequestAuth,
|
||||
configurePreviewSessionPermissions,
|
||||
@@ -113,6 +117,7 @@ let updaterService: ElectronUpdaterService | null = null
|
||||
let terminalService: ElectronTerminalService | null = null
|
||||
let previewService: ElectronPreviewService | null = null
|
||||
let workspaceBrowserService: ElectronWorkspaceBrowserService | null = null
|
||||
let workspaceBrowserSessionConfigured = false
|
||||
let petWindowController: PetWindowController | null = null
|
||||
const traceWindows = new Map<string, BrowserWindow>()
|
||||
let isQuitting = false
|
||||
@@ -445,10 +450,6 @@ function getWorkspaceBrowserService() {
|
||||
emit: event => {
|
||||
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.workspaceBrowserEvent, event)
|
||||
},
|
||||
resolveScaleFactor: parent => {
|
||||
const bounds = parent.getBounds?.()
|
||||
return bounds ? screen.getDisplayMatching(bounds).scaleFactor : 1
|
||||
},
|
||||
writePdf: async ({ data, filename }) => {
|
||||
return saveWorkspaceBrowserPdf(data, async () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return null
|
||||
@@ -459,31 +460,37 @@ function getWorkspaceBrowserService() {
|
||||
return result.canceled ? null : result.filePath ?? null
|
||||
})
|
||||
},
|
||||
createView: () => {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
preload: previewPreloadPath(),
|
||||
// One shared persistent partition for every workspace page: a login in
|
||||
// one tab has to still be there in the next one. Per-tab partitions
|
||||
// would turn every new tab into a fresh, logged-out browser.
|
||||
partition: WORKSPACE_BROWSER_PARTITION,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
// Same boundary as the singleton preview: OS permissions are denied, and
|
||||
// `configureLocalServerRequestAuth` is deliberately NOT installed here.
|
||||
// These pages render arbitrary remote sites, so attaching the desktop's
|
||||
// local access token to their loopback requests would hand any visited
|
||||
// site the local API.
|
||||
configurePreviewSessionPermissions(view.webContents.session)
|
||||
return view
|
||||
},
|
||||
// Pages are `<webview>` guests the renderer creates; their preferences are
|
||||
// fixed by `installWorkspaceBrowserGuestPolicy` on the main window. The id
|
||||
// the renderer reports is only adopted if it names one of those guests.
|
||||
resolveGuest: webContentsId => resolveWorkspaceBrowserGuest<Electron.WebContents>(webContentsId, {
|
||||
fromId: id => webContents.fromId(id),
|
||||
host: mainWindow?.webContents,
|
||||
session: workspaceBrowserSession(),
|
||||
}) as WorkspaceBrowserWebContentsLike,
|
||||
})
|
||||
return workspaceBrowserService
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared persistent partition for every workspace page: a login in one
|
||||
* tab has to still be there in the next one. Per-tab partitions would turn
|
||||
* every new tab into a fresh, logged-out browser.
|
||||
*/
|
||||
function workspaceBrowserSession() {
|
||||
const browserSession = session.fromPartition(WORKSPACE_BROWSER_PARTITION)
|
||||
// Same boundary as the singleton preview: OS permissions are denied, and
|
||||
// `configureLocalServerRequestAuth` is deliberately NOT installed here.
|
||||
// These pages render arbitrary remote sites, so attaching the desktop's
|
||||
// local access token to their loopback requests would hand any visited
|
||||
// site the local API.
|
||||
if (!workspaceBrowserSessionConfigured) {
|
||||
workspaceBrowserSessionConfigured = true
|
||||
configurePreviewSessionPermissions(browserSession)
|
||||
}
|
||||
return browserSession
|
||||
}
|
||||
|
||||
async function listCustomPets() {
|
||||
const { pets, errors } = await loadCustomPetCatalog()
|
||||
return { pets, errors }
|
||||
@@ -830,11 +837,10 @@ function registerIpcHandlers() {
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.previewClose, () => getPreviewService().close())
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (event, payload) => getPreviewService().message(payload, event.sender))
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserCreate, (event, payload) => {
|
||||
// The service keeps a single parent window, so whichever renderer calls
|
||||
// `create` last owns where every page is attached and detached. Trace and
|
||||
// pet windows load the same preload, so without this a secondary window
|
||||
// could adopt the pages and strand them as unremovable children of the main
|
||||
// window. Same guard shape as `appSetLocalePreference`.
|
||||
// Pages are guests of the main window's renderer. Trace and pet windows
|
||||
// load the same preload, so without this a secondary window could register
|
||||
// pages it does not host — and own the menu parent of every page. Same
|
||||
// guard shape as `appSetLocalePreference`.
|
||||
if (!mainWindow || currentWindow(event) !== mainWindow) {
|
||||
throw new Error('Only the main window can host workspace browser pages')
|
||||
}
|
||||
@@ -864,10 +870,6 @@ function registerIpcHandlers() {
|
||||
})
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserStop, (_event, payload) =>
|
||||
getWorkspaceBrowserService().stop((payload as { tabId: string }).tabId))
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserSetBounds, (_event, payload) => {
|
||||
const { tabId, bounds } = payload as { tabId: string, bounds: WorkspaceBrowserBounds }
|
||||
return getWorkspaceBrowserService().setBounds(tabId, bounds)
|
||||
})
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserSetVisible, (_event, payload) => {
|
||||
const { tabId, visible } = payload as { tabId: string, visible: boolean }
|
||||
return getWorkspaceBrowserService().setVisible(tabId, visible)
|
||||
@@ -890,8 +892,6 @@ function registerIpcHandlers() {
|
||||
const { tabId, kind } = payload as { tabId: string, kind: WorkspaceBrowserCaptureKind }
|
||||
return getWorkspaceBrowserService().capture(tabId, kind)
|
||||
})
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot, (_event, payload) =>
|
||||
getWorkspaceBrowserService().snapshot((payload as { tabId: string }).tabId))
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserMessage, (_event, payload) => {
|
||||
const { tabId, payload: message } = payload as { tabId: string, payload: unknown }
|
||||
return getWorkspaceBrowserService().message(tabId, message)
|
||||
@@ -913,7 +913,13 @@ function registerIpcHandlers() {
|
||||
app.quit()
|
||||
})
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.adaptersRestartSidecar, () => getServerRuntime().restartAdaptersSidecars())
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.zoomSet, (event, payload) => currentWindow(event).webContents.setZoomFactor(normalizeZoomFactor(payload)))
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.zoomSet, (event, payload) => {
|
||||
const window = currentWindow(event)
|
||||
window.webContents.setZoomFactor(normalizeZoomFactor(payload))
|
||||
// Electron pushes the embedder's zoom into every browser guest; app zoom
|
||||
// must not change the pages' own zoom.
|
||||
if (window === mainWindow) workspaceBrowserService?.restorePageZoom()
|
||||
})
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.appearanceSetApplied, (_event, payload) => {
|
||||
if (!isAppliedAppearance(payload)) return
|
||||
lastAppliedAppearance = payload
|
||||
@@ -942,8 +948,15 @@ async function createMainWindow() {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
// Only for the workspace browser, whose pages must be composited with
|
||||
// the DOM. Every attach is decided by the guest policy installed below.
|
||||
webviewTag: true,
|
||||
},
|
||||
})
|
||||
// Before any guest can exist: its session denies OS permissions, and its
|
||||
// preferences are replaced with the sandboxed set the policy pins.
|
||||
workspaceBrowserSession()
|
||||
installWorkspaceBrowserGuestPolicy(mainWindow.webContents, { preload: previewPreloadPath() })
|
||||
configureLocalServerRequestAuth(
|
||||
mainWindow.webContents.session.webRequest,
|
||||
resolveMainRendererServerAccess,
|
||||
@@ -1051,7 +1064,6 @@ app.whenReady().then(async () => {
|
||||
screen.on('display-metrics-changed', (_event, _display, changedMetrics) => {
|
||||
if (changedMetrics.includes('scaleFactor') || changedMetrics.includes('bounds')) {
|
||||
previewService?.refreshBounds()
|
||||
workspaceBrowserService?.refreshBounds()
|
||||
}
|
||||
})
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,38 +12,29 @@ import type {
|
||||
} from '../../src/lib/desktopHost/types'
|
||||
import { parsePreviewAgentMessage, type PreviewAgentMessage } from '../ipc/previewMessage'
|
||||
import { parseHostMessage, type HostMessage } from '../../src/preview-agent/protocol'
|
||||
import { WORKSPACE_BROWSER_INITIAL_SRC } from '../../src/lib/workspace/browserGuestContract'
|
||||
import { isHttpUrl } from './navigationGuards'
|
||||
import {
|
||||
normalizePreviewBounds,
|
||||
normalizePreviewUrl,
|
||||
resolvePreviewScriptPath,
|
||||
snapPreviewBoundsToScaleFactor,
|
||||
type PreviewBounds,
|
||||
} from './preview'
|
||||
import { normalizePreviewUrl, resolvePreviewScriptPath } from './preview'
|
||||
import { normalizeZoomFactor } from './zoom'
|
||||
import { WorkspaceBrowserMenuController, type WorkspaceBrowserMenuFactory } from './workspaceBrowserMenu'
|
||||
|
||||
export type { WorkspaceBrowserCaptureKind, WorkspaceBrowserEvent, WorkspaceBrowserFindOptions }
|
||||
|
||||
/**
|
||||
* One persistent partition for every workspace browser page of this user.
|
||||
*
|
||||
* Codex does the same with `persist:codex-browser-app`: a login performed in
|
||||
* one tab has to be there in the next one. The per-tab `storageId` is a *page
|
||||
* restore identity* — which page to reopen after a restart — and must never be
|
||||
* turned into a partition name, or every tab would get its own cookie jar.
|
||||
*/
|
||||
export const WORKSPACE_BROWSER_PARTITION = 'persist:cc-haha-browser-app'
|
||||
export { WORKSPACE_BROWSER_PARTITION } from '../../src/lib/workspace/browserGuestContract'
|
||||
|
||||
/** Mirrors the preview capture guard rails; a page controls these dimensions. */
|
||||
const FULL_CAPTURE_MAX_EDGE = 16_384
|
||||
const FULL_CAPTURE_MAX_PIXELS = 32_000_000
|
||||
|
||||
/**
|
||||
* A guest that is not on screen has no compositor frame, and `capturePage`
|
||||
* then waits ~30s before failing. Without a bound, a capture racing a tab
|
||||
* switch would keep the page's zoom chrome hidden for that long.
|
||||
*/
|
||||
const VIEWPORT_CAPTURE_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Visit log bound. The native back/forward stack is unaffected by this cap. */
|
||||
const MAX_HISTORY_ENTRIES = 200
|
||||
|
||||
export type WorkspaceBrowserBounds = PreviewBounds
|
||||
|
||||
type WorkspaceBrowserDebuggerLike = {
|
||||
isAttached(): boolean
|
||||
attach(protocolVersion?: string): void
|
||||
@@ -77,6 +68,7 @@ type WorkspaceBrowserNavigationHistoryLike = {
|
||||
canGoForward(): boolean
|
||||
goBack(): void
|
||||
goForward(): void
|
||||
clear?(): void
|
||||
}
|
||||
|
||||
export type WorkspaceBrowserWebContentsLike = {
|
||||
@@ -124,6 +116,7 @@ export type WorkspaceBrowserWebContentsLike = {
|
||||
event: 'will-navigate',
|
||||
handler: (event: { preventDefault: () => void }, url: string) => void,
|
||||
): unknown
|
||||
on(event: 'destroyed', handler: () => void): unknown
|
||||
navigationHistory?: WorkspaceBrowserNavigationHistoryLike
|
||||
canGoBack?(): boolean
|
||||
canGoForward?(): boolean
|
||||
@@ -140,49 +133,50 @@ export type WorkspaceBrowserWebContentsLike = {
|
||||
isFocused?(): boolean
|
||||
}
|
||||
|
||||
export type WorkspaceBrowserViewLike = {
|
||||
webContents: WorkspaceBrowserWebContentsLike
|
||||
setBounds(bounds: PreviewBounds): void
|
||||
setVisible?(visible: boolean): void
|
||||
}
|
||||
|
||||
export type WorkspaceBrowserParentWindowLike = {
|
||||
webContents?: {
|
||||
focus(): void
|
||||
isDestroyed?(): boolean
|
||||
}
|
||||
isDestroyed?(): boolean
|
||||
contentView: {
|
||||
addChildView(view: unknown): void
|
||||
removeChildView(view: unknown): void
|
||||
}
|
||||
getBounds?(): PreviewBounds
|
||||
}
|
||||
|
||||
export type WorkspaceBrowserCreateOptions = {
|
||||
storageId: string
|
||||
url?: string
|
||||
bounds?: WorkspaceBrowserBounds
|
||||
visible?: boolean
|
||||
/** The `<webview>` guest the renderer created for this page. */
|
||||
webContentsId: number
|
||||
}
|
||||
|
||||
export type ElectronWorkspaceBrowserServiceOptions = {
|
||||
createView: () => WorkspaceBrowserViewLike
|
||||
/**
|
||||
* Turns a renderer-reported id into the guest it names. It must throw for
|
||||
* anything that is not a workspace browser webview of the main window.
|
||||
*/
|
||||
resolveGuest: (webContentsId: number) => WorkspaceBrowserWebContentsLike
|
||||
previewScriptPath: string
|
||||
emit: (event: WorkspaceBrowserEvent) => void
|
||||
resolveScaleFactor?: (parent: WorkspaceBrowserParentWindowLike) => number
|
||||
/** Writes an exported PDF and resolves with the path it landed on. */
|
||||
writePdf?: (input: { data: Uint8Array, filename: string }) => Promise<string | null>
|
||||
platform?: NodeJS.Platform
|
||||
menuFactory?: WorkspaceBrowserMenuFactory
|
||||
/**
|
||||
* Runs a task after the current native dispatch. Electron applies its own
|
||||
* navigation zoom after `did-navigate` listeners return, so a correction made
|
||||
* inside the listener would be overwritten.
|
||||
*/
|
||||
defer?: (task: () => void) => void
|
||||
/** Bounds a viewport capture; a hidden guest otherwise stalls for ~30s. */
|
||||
captureTimeoutMs?: number
|
||||
}
|
||||
|
||||
type WorkspaceBrowserPage = {
|
||||
tabId: string
|
||||
storageId: string
|
||||
view: WorkspaceBrowserViewLike
|
||||
attached: boolean
|
||||
requestedBounds: PreviewBounds | null
|
||||
webContents: WorkspaceBrowserWebContentsLike
|
||||
/** Whether the renderer currently shows this page. Only it can take input. */
|
||||
presented: boolean
|
||||
/** The guest starts on a blank document whose entry must not be "Back". */
|
||||
initialEntryCleared: boolean
|
||||
zoomFactor: number
|
||||
controls: PreviewBrowserControlsMessage | null
|
||||
controlsSignature: string | null
|
||||
@@ -234,32 +228,45 @@ export function workspaceBrowserPdfFilename(url: string, title: string): string
|
||||
/**
|
||||
* Multi-page browser host.
|
||||
*
|
||||
* The whole point of this service is that a page's lifetime is decided by
|
||||
* `close(tabId)` and nothing else. Hiding a tab, re-bounding it, moving the
|
||||
* panel or unmounting the React surface only change where — or whether — a page
|
||||
* is drawn; the `webContents` behind it keeps its form state, scroll position
|
||||
* and navigation history the entire time.
|
||||
* Each page is a `<webview>` guest the renderer keeps in a layer that is never
|
||||
* unmounted, so the page is composited with the DOM: menus, dialogs and other
|
||||
* pages draw over it like over any element. This service adopts the guest and
|
||||
* owns everything that happens *inside* it — navigation, history, find, zoom,
|
||||
* capture, downloads and the annotation agent.
|
||||
*
|
||||
* A page's lifetime is still decided by `close(tabId)` and nothing else.
|
||||
* Hiding a tab, moving the panel or unmounting the React surface only change
|
||||
* whether a page is drawn; the guest keeps its form state, scroll position and
|
||||
* navigation history the entire time.
|
||||
*/
|
||||
export class ElectronWorkspaceBrowserService {
|
||||
private readonly createView: () => WorkspaceBrowserViewLike
|
||||
private readonly resolveGuest: (webContentsId: number) => WorkspaceBrowserWebContentsLike
|
||||
private readonly previewScriptPath: string
|
||||
private readonly emit: (event: WorkspaceBrowserEvent) => void
|
||||
private readonly resolveScaleFactor?: (parent: WorkspaceBrowserParentWindowLike) => number
|
||||
private readonly writePdf?: (input: { data: Uint8Array, filename: string }) => Promise<string | null>
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly defer: (task: () => void) => void
|
||||
private readonly captureTimeoutMs: number
|
||||
private readonly pages = new Map<string, WorkspaceBrowserPage>()
|
||||
private readonly hookedSessions = new Set<WorkspaceBrowserSessionLike>()
|
||||
/**
|
||||
* Page zoom as Chromium keeps it: per host, shared by every page of the
|
||||
* partition. Electron overwrites a guest's zoom with its embedder's whenever
|
||||
* app zoom changes or the guest navigates, so this is the value to restore.
|
||||
*/
|
||||
private readonly zoomByHost = new Map<string, number>()
|
||||
private parent: WorkspaceBrowserParentWindowLike | null = null
|
||||
private downloadSequence = 0
|
||||
private readonly menu?: WorkspaceBrowserMenuController
|
||||
|
||||
constructor(options: ElectronWorkspaceBrowserServiceOptions) {
|
||||
this.createView = options.createView
|
||||
this.resolveGuest = options.resolveGuest
|
||||
this.previewScriptPath = options.previewScriptPath
|
||||
this.emit = options.emit
|
||||
this.resolveScaleFactor = options.resolveScaleFactor
|
||||
this.writePdf = options.writePdf
|
||||
this.platform = options.platform ?? process.platform
|
||||
this.defer = options.defer ?? (task => { setImmediate(task) })
|
||||
this.captureTimeoutMs = options.captureTimeoutMs ?? VIEWPORT_CAPTURE_TIMEOUT_MS
|
||||
if (options.menuFactory) this.menu = new WorkspaceBrowserMenuController(options.menuFactory)
|
||||
}
|
||||
|
||||
@@ -268,35 +275,38 @@ export class ElectronWorkspaceBrowserService {
|
||||
tabId: string,
|
||||
options: WorkspaceBrowserCreateOptions,
|
||||
): Promise<void> {
|
||||
// Validate before anything is constructed or registered: `openPage` inserts
|
||||
// a live view into `this.pages`, and a throw after that point strands a
|
||||
// `webContents` that was never attached and can never be addressed again.
|
||||
const bounds = options.bounds ? normalizePreviewBounds(options.bounds) : null
|
||||
// Validate before anything is registered: a throw after `openPage` would
|
||||
// leave a page this service can neither address nor release.
|
||||
const url = options.url ? normalizePreviewUrl(options.url) : null
|
||||
if (options.visible !== undefined && typeof options.visible !== 'boolean') throw new Error('visible must be a boolean')
|
||||
const webContents = this.resolveGuest(options.webContentsId)
|
||||
|
||||
this.parent = parent
|
||||
const existing = this.pages.get(tabId)
|
||||
// Re-creating a live id would strand its `webContents` with no way to close
|
||||
// it, so an already-known tab keeps its page.
|
||||
const page = existing ?? this.openPage(tabId, options)
|
||||
if (bounds) page.requestedBounds = bounds
|
||||
if (options.visible === false) {
|
||||
this.detach(page)
|
||||
// Registration is complete even while the first navigation is pending.
|
||||
// The renderer may now safely send geometry, visibility and Stop.
|
||||
this.emitState(page)
|
||||
} else this.showExclusively(page)
|
||||
// A live page is NEVER re-navigated from `create`. The renderer re-mounts
|
||||
// this component every time its tab is re-activated, and `loadURL` on an
|
||||
// existing `webContents` is a hard navigation: it would wipe the form the
|
||||
// user had filled in, reset the scroll position and push a duplicate entry
|
||||
// onto the native back stack — destroying exactly the state that keeping
|
||||
// the page alive exists to preserve. Navigation is `navigate()`'s job.
|
||||
if (url && !existing) {
|
||||
await page.view.webContents.loadURL(url)
|
||||
if (existing) {
|
||||
// A live page is NEVER re-navigated from `create`. The renderer asks
|
||||
// again whenever it has to re-establish a page, and `loadURL` on a live
|
||||
// guest is a hard navigation: it would wipe the form the user filled in,
|
||||
// reset the scroll position and push a duplicate back entry.
|
||||
if (existing.webContents !== webContents) {
|
||||
throw new Error(`workspace browser tab already has a live page: ${tabId}`)
|
||||
}
|
||||
this.emitState(existing)
|
||||
return
|
||||
}
|
||||
const owner = this.findPageByWebContents(webContents)
|
||||
if (owner) throw new Error(`workspace browser guest already belongs to ${owner.tabId}`)
|
||||
|
||||
const page = this.openPage(tabId, options.storageId, webContents)
|
||||
// Resolving means "registered": the renderer may now send visibility,
|
||||
// Stop and navigation. The first load is an ordinary navigation, so its
|
||||
// failure reaches the tab as a `failed` event like any other — answering
|
||||
// `create` with it would make an adopted page look unregistered.
|
||||
this.emitState(page)
|
||||
if (url) {
|
||||
void webContents.loadURL(url).catch(() => {
|
||||
// Reported through `did-fail-load`, or superseded by a newer navigation.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async navigate(tabId: string, url: string): Promise<void> {
|
||||
@@ -304,7 +314,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
page.pickerArmed = false
|
||||
page.persistentPicker = null
|
||||
page.pickerGeneration += 1
|
||||
await page.view.webContents.loadURL(normalizePreviewUrl(url))
|
||||
await page.webContents.loadURL(normalizePreviewUrl(url))
|
||||
}
|
||||
|
||||
async showMenu(
|
||||
@@ -317,7 +327,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
throw new Error('Workspace browser menu requires its live owner window')
|
||||
}
|
||||
if (!this.menu) throw new Error('Workspace browser native menu unavailable')
|
||||
const nativeZoom = page.view.webContents.getZoomFactor?.()
|
||||
const nativeZoom = page.webContents.getZoomFactor?.()
|
||||
if (nativeZoom !== undefined && Number.isFinite(nativeZoom) && nativeZoom > 0 && nativeZoom !== page.zoomFactor) {
|
||||
page.zoomFactor = nativeZoom
|
||||
// Menu actions return to the renderer. Its next zoom step must start
|
||||
@@ -328,7 +338,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
|
||||
goBack(tabId: string): void {
|
||||
const webContents = this.requirePage(tabId).view.webContents
|
||||
const webContents = this.requirePage(tabId).webContents
|
||||
if (webContents.navigationHistory) {
|
||||
webContents.navigationHistory.goBack()
|
||||
return
|
||||
@@ -337,7 +347,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
|
||||
goForward(tabId: string): void {
|
||||
const webContents = this.requirePage(tabId).view.webContents
|
||||
const webContents = this.requirePage(tabId).webContents
|
||||
if (webContents.navigationHistory) {
|
||||
webContents.navigationHistory.goForward()
|
||||
return
|
||||
@@ -346,55 +356,69 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
|
||||
reload(tabId: string, options?: { ignoreCache?: boolean }): void {
|
||||
const webContents = this.requirePage(tabId).view.webContents
|
||||
const webContents = this.requirePage(tabId).webContents
|
||||
if (options?.ignoreCache) webContents.reloadIgnoringCache()
|
||||
else webContents.reload()
|
||||
}
|
||||
|
||||
stop(tabId: string): void {
|
||||
this.requirePage(tabId).view.webContents.stop()
|
||||
}
|
||||
|
||||
setBounds(tabId: string, bounds: WorkspaceBrowserBounds): void {
|
||||
const page = this.requirePage(tabId)
|
||||
page.requestedBounds = normalizePreviewBounds(bounds)
|
||||
this.applyBounds(page)
|
||||
this.requirePage(tabId).webContents.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hiding detaches the native view from the window so it cannot cover a modal
|
||||
* or steal clicks — it never destroys the page.
|
||||
* Records whether the renderer shows this page. Drawing is the renderer's
|
||||
* job; this decides which page may act as browser chrome (shortcuts, zoom
|
||||
* capsule) and closes a native menu that belonged to a page going away.
|
||||
*/
|
||||
setVisible(tabId: string, visible: boolean): void {
|
||||
// Controller close may precede the component's passive unmount cleanup.
|
||||
// Only hide is an idempotent teardown; showing a missing page is still an error.
|
||||
if (!visible && !this.pages.has(tabId)) return
|
||||
const page = this.requirePage(tabId)
|
||||
if (visible) this.showExclusively(page)
|
||||
else this.detach(page)
|
||||
page.presented = visible
|
||||
if (!visible) {
|
||||
this.menu?.cancel(page.tabId)
|
||||
return
|
||||
}
|
||||
// Chromium may have changed this page's zoom while it was off screen (a
|
||||
// same-host page zoomed); the controls it shows must read the real value.
|
||||
this.emitState(page)
|
||||
}
|
||||
|
||||
setZoom(tabId: string, factor: unknown): void {
|
||||
const page = this.requirePage(tabId)
|
||||
page.zoomFactor = normalizeZoomFactor(factor)
|
||||
page.view.webContents.setZoomFactor?.(page.zoomFactor)
|
||||
this.zoomByHost.set(zoomHostKey(page.webContents.getURL()), page.zoomFactor)
|
||||
page.webContents.setZoomFactor?.(page.zoomFactor)
|
||||
// Chromium may apply zoom to another live page on the same origin.
|
||||
// Read every native value so controls never report an invented factor.
|
||||
for (const current of this.pages.values()) this.emitState(current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron copies the embedder's zoom into every guest when app zoom
|
||||
* changes. App zoom never scaled the old native page, so put each page back
|
||||
* at the zoom it had. Must run right after the embedder zoom changes.
|
||||
*/
|
||||
restorePageZoom(): void {
|
||||
for (const page of this.pages.values()) {
|
||||
if (page.closed || page.webContents.isDestroyed?.()) continue
|
||||
page.webContents.setZoomFactor?.(page.zoomFactor)
|
||||
}
|
||||
}
|
||||
|
||||
find(tabId: string, text: string, options?: WorkspaceBrowserFindOptions): void {
|
||||
const trimmed = text.trim()
|
||||
const page = this.requirePage(tabId)
|
||||
if (!trimmed) {
|
||||
page.view.webContents.stopFindInPage('clearSelection')
|
||||
page.webContents.stopFindInPage('clearSelection')
|
||||
return
|
||||
}
|
||||
page.view.webContents.findInPage(trimmed, options)
|
||||
page.webContents.findInPage(trimmed, options)
|
||||
}
|
||||
|
||||
stopFind(tabId: string): void {
|
||||
this.requirePage(tabId).view.webContents.stopFindInPage('clearSelection')
|
||||
this.requirePage(tabId).webContents.stopFindInPage('clearSelection')
|
||||
}
|
||||
|
||||
async capture(tabId: string, kind: WorkspaceBrowserCaptureKind): Promise<void> {
|
||||
@@ -403,17 +427,6 @@ export class ElectronWorkspaceBrowserService {
|
||||
this.emitFor(page, { type: 'screenshot', tabId: page.tabId, dataUrl, kind })
|
||||
}
|
||||
|
||||
/** Presentation-only image: never enters the screenshot/chat event stream. */
|
||||
async snapshot(tabId: string): Promise<string> {
|
||||
const page = this.requirePage(tabId)
|
||||
const navigationId = page.navigationId
|
||||
const dataUrl = await this.captureDataUrl(page, 'viewport')
|
||||
if (page.closed || navigationId !== page.navigationId) {
|
||||
throw new Error('Browser page changed during snapshot')
|
||||
}
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
async message(tabId: string, payload: unknown): Promise<void> {
|
||||
const page = this.requirePage(tabId)
|
||||
const controls = parseHostMessage(JSON.stringify(payload))
|
||||
@@ -436,7 +449,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
const raw = JSON.stringify(isHostPickerMessage(payload) ? { ...payload, generation: page.pickerGeneration } : payload)
|
||||
const generation = page.pickerGeneration
|
||||
try {
|
||||
await page.view.webContents.executeJavaScript(
|
||||
await page.webContents.executeJavaScript(
|
||||
`globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`,
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -452,7 +465,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
|
||||
async printToPdf(tabId: string): Promise<void> {
|
||||
const page = this.requirePage(tabId)
|
||||
const webContents = page.view.webContents
|
||||
const webContents = page.webContents
|
||||
if (!webContents.printToPDF || !this.writePdf) throw new Error('pdf export unavailable')
|
||||
const filename = workspaceBrowserPdfFilename(webContents.getURL(), webContents.getTitle())
|
||||
const data = await webContents.printToPDF({ printBackground: true })
|
||||
@@ -472,15 +485,19 @@ export class ElectronWorkspaceBrowserService {
|
||||
})
|
||||
}
|
||||
|
||||
/** The only call that ends a page's life. */
|
||||
/**
|
||||
* The only call that ends a page's life. The renderer removes the guest's
|
||||
* element as well; closing here too means a page can never outlive its tab
|
||||
* even if that removal never happens.
|
||||
*/
|
||||
close(tabId: string): void {
|
||||
const page = this.pages.get(tabId)
|
||||
if (!page) return
|
||||
this.pages.delete(tabId)
|
||||
page.closed = true
|
||||
this.detach(page)
|
||||
if (!page.view.webContents.isDestroyed?.()) {
|
||||
page.view.webContents.close?.()
|
||||
this.menu?.cancel(page.tabId)
|
||||
if (!page.webContents.isDestroyed?.()) {
|
||||
page.webContents.close?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,11 +507,6 @@ export class ElectronWorkspaceBrowserService {
|
||||
this.parent = null
|
||||
}
|
||||
|
||||
/** Re-snaps every live page after a display scale-factor or bounds change. */
|
||||
refreshBounds(): void {
|
||||
for (const page of this.pages.values()) this.applyBounds(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an in-page agent message. Returns whether this service owns the
|
||||
* sender, so the caller can fall through to the legacy singleton preview.
|
||||
@@ -506,14 +518,17 @@ export class ElectronWorkspaceBrowserService {
|
||||
return true
|
||||
}
|
||||
|
||||
private openPage(tabId: string, options: WorkspaceBrowserCreateOptions): WorkspaceBrowserPage {
|
||||
const view = this.createView()
|
||||
private openPage(
|
||||
tabId: string,
|
||||
storageId: string,
|
||||
webContents: WorkspaceBrowserWebContentsLike,
|
||||
): WorkspaceBrowserPage {
|
||||
const page: WorkspaceBrowserPage = {
|
||||
tabId,
|
||||
storageId: options.storageId,
|
||||
view,
|
||||
attached: false,
|
||||
requestedBounds: null,
|
||||
storageId,
|
||||
webContents,
|
||||
presented: false,
|
||||
initialEntryCleared: false,
|
||||
zoomFactor: 1,
|
||||
controls: null,
|
||||
controlsSignature: null,
|
||||
@@ -532,12 +547,15 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
this.pages.set(tabId, page)
|
||||
this.installPageListeners(page)
|
||||
this.hookSession(view.webContents.session)
|
||||
this.hookSession(webContents.session)
|
||||
// The guest attached at its embedder's zoom; a page starts at 100% or at
|
||||
// whatever its host was last zoomed to, exactly as a fresh native page did.
|
||||
this.restoreNavigationZoom(page)
|
||||
return page
|
||||
}
|
||||
|
||||
private installPageListeners(page: WorkspaceBrowserPage): void {
|
||||
const webContents = page.view.webContents
|
||||
const webContents = page.webContents
|
||||
|
||||
// Popups become tabs in this window instead of native child windows, which
|
||||
// would escape the workspace and the preload/permission boundary with it.
|
||||
@@ -548,9 +566,19 @@ export class ElectronWorkspaceBrowserService {
|
||||
webContents.on('will-navigate', (event, url) => {
|
||||
if (!isHttpUrl(url)) event.preventDefault()
|
||||
})
|
||||
webContents.on('destroyed', () => {
|
||||
// A guest dies with its element. Unless the tab closed it, the renderer
|
||||
// lost the page (its layer went away) and the tab must offer a retry
|
||||
// instead of addressing a page that no longer exists.
|
||||
if (page.closed || this.pages.get(page.tabId) !== page) return
|
||||
this.pages.delete(page.tabId)
|
||||
page.closed = true
|
||||
this.menu?.cancel(page.tabId)
|
||||
this.emit({ type: 'destroyed', tabId: page.tabId, reason: 'closed' })
|
||||
})
|
||||
|
||||
webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown' || !page.attached || page.closed) return
|
||||
if (input.type !== 'keyDown' || !page.presented || page.closed) return
|
||||
const action = matchWorkspaceShortcut({
|
||||
key: input.key, code: input.code, metaKey: input.meta,
|
||||
ctrlKey: input.control, shiftKey: input.shift, altKey: input.alt,
|
||||
@@ -597,8 +625,17 @@ export class ElectronWorkspaceBrowserService {
|
||||
page.pickerGeneration += 1
|
||||
page.navigationUrl = url
|
||||
page.navigationCommitted = true
|
||||
if (!page.initialEntryCleared && url !== WORKSPACE_BROWSER_INITIAL_SRC) {
|
||||
// The blank document the guest attached with is a real history entry;
|
||||
// a native page had none, so the first page must not be able to go
|
||||
// "Back" to it.
|
||||
page.initialEntryCleared = true
|
||||
webContents.navigationHistory?.clear?.()
|
||||
}
|
||||
this.recordVisit(page, url)
|
||||
this.emitState(page)
|
||||
// Electron re-applies the embedder's zoom once these listeners return.
|
||||
this.defer(() => this.restoreNavigationZoom(page))
|
||||
})
|
||||
webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => {
|
||||
if (isMainFrame === false) return
|
||||
@@ -688,8 +725,8 @@ export class ElectronWorkspaceBrowserService {
|
||||
if (message.type === 'browser-zoom') {
|
||||
// Only the attached page can act as browser chrome. Background pages
|
||||
// cannot modify another tab, and no zoom event enters the chat pipeline.
|
||||
if (page.attached && page.controls) {
|
||||
const current = page.view.webContents.getZoomFactor?.() ?? page.zoomFactor
|
||||
if (page.presented && page.controls) {
|
||||
const current = page.webContents.getZoomFactor?.() ?? page.zoomFactor
|
||||
this.setZoom(page.tabId, message.action === 'reset' ? 1 : Math.round((current + (message.action === 'in' ? 0.1 : -0.1)) * 10) / 10)
|
||||
}
|
||||
return
|
||||
@@ -753,7 +790,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
|
||||
private async clearSelectionOverlay(page: WorkspaceBrowserPage, captureId?: number): Promise<void> {
|
||||
const webContents = page.view.webContents
|
||||
const webContents = page.webContents
|
||||
if (page.closed || webContents.isDestroyed?.()) return
|
||||
try {
|
||||
await webContents.executeJavaScript(`globalThis.__PREVIEW_AGENT_CLEAR_SELECTION_OVERLAY__?.(${captureId === undefined ? '' : JSON.stringify(captureId)})`)
|
||||
@@ -763,7 +800,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
|
||||
private async injectPreviewAgent(page: WorkspaceBrowserPage): Promise<void> {
|
||||
const webContents = page.view.webContents
|
||||
const webContents = page.webContents
|
||||
if (page.closed || webContents.isDestroyed?.()) return
|
||||
page.pickerArmed = false
|
||||
page.persistentPicker = null
|
||||
@@ -777,12 +814,12 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
|
||||
private async syncBrowserControls(page: WorkspaceBrowserPage): Promise<void> {
|
||||
if (!page.controls || page.closed || page.view.webContents.isDestroyed?.()) return
|
||||
if (!page.controls || page.closed || page.webContents.isDestroyed?.()) return
|
||||
const raw = JSON.stringify({ ...page.controls, zoomFactor: page.zoomFactor })
|
||||
if (page.controlsSignature === raw) return
|
||||
page.controlsSignature = raw
|
||||
try {
|
||||
await page.view.webContents.executeJavaScript(`globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`)
|
||||
await page.webContents.executeJavaScript(`globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`)
|
||||
} catch (error) {
|
||||
if (page.controlsSignature === raw) page.controlsSignature = null
|
||||
throw error
|
||||
@@ -793,7 +830,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
page: WorkspaceBrowserPage,
|
||||
kind: WorkspaceBrowserCaptureKind,
|
||||
): Promise<string> {
|
||||
const webContents = page.view.webContents
|
||||
const webContents = page.webContents
|
||||
const hideChrome = async (hidden: boolean) => {
|
||||
if (page.closed || webContents.isDestroyed?.()) return
|
||||
await webContents.executeJavaScript(`globalThis.__PREVIEW_AGENT_SET_CHROME_HIDDEN__?.(${hidden})`)
|
||||
@@ -803,7 +840,11 @@ export class ElectronWorkspaceBrowserService {
|
||||
await hideChrome(true)
|
||||
if (kind === 'full') return await this.captureFullPageDataUrl(page)
|
||||
if (!webContents.capturePage) throw new Error('native browser capture unavailable')
|
||||
const image = await webContents.capturePage()
|
||||
const image = await withTimeout(
|
||||
webContents.capturePage(),
|
||||
this.captureTimeoutMs,
|
||||
'browser capture timed out',
|
||||
)
|
||||
return image.toDataURL()
|
||||
} finally {
|
||||
page.captureCount -= 1
|
||||
@@ -825,7 +866,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
|
||||
private async captureFullPageDataUrlOnce(page: WorkspaceBrowserPage): Promise<string> {
|
||||
const debuggerApi = page.view.webContents.debugger
|
||||
const debuggerApi = page.webContents.debugger
|
||||
if (!debuggerApi) throw new Error('full browser capture unavailable')
|
||||
|
||||
let attachedHere = false
|
||||
@@ -876,42 +917,22 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
}
|
||||
|
||||
private showExclusively(page: WorkspaceBrowserPage): void {
|
||||
// A second attached view would sit on top of the first and swallow its
|
||||
// input, so exactly one page occupies the window's single browser slot.
|
||||
for (const other of this.pages.values()) {
|
||||
if (other !== page) this.detach(other)
|
||||
}
|
||||
if (this.parent && !page.attached) {
|
||||
this.parent.contentView.addChildView(page.view)
|
||||
page.attached = true
|
||||
}
|
||||
page.view.setVisible?.(true)
|
||||
this.applyBounds(page)
|
||||
/**
|
||||
* Put a page back at the zoom Chromium would have given it as a native
|
||||
* page: whatever its host was last zoomed to, otherwise 100%. Electron
|
||||
* instead resets a guest to its embedder's zoom, i.e. the app zoom.
|
||||
*/
|
||||
private restoreNavigationZoom(page: WorkspaceBrowserPage): void {
|
||||
const webContents = page.webContents
|
||||
if (page.closed || webContents.isDestroyed?.() || !webContents.setZoomFactor) return
|
||||
const wanted = this.zoomByHost.get(zoomHostKey(webContents.getURL())) ?? 1
|
||||
const actual = webContents.getZoomFactor?.()
|
||||
// Factors round-trip through zoom levels, so compare with a tolerance.
|
||||
if (actual !== undefined && Math.abs(actual - wanted) < 0.001) return
|
||||
webContents.setZoomFactor(wanted)
|
||||
this.emitState(page)
|
||||
}
|
||||
|
||||
private detach(page: WorkspaceBrowserPage): void {
|
||||
this.menu?.cancel(page.tabId)
|
||||
// A DOM focus request cannot move macOS's native responder out of a
|
||||
// WebContentsView. Capture ownership before hiding/removing the view drops
|
||||
// it, and do not steal focus when a newer page or a host input already owns it.
|
||||
const returnFocus = page.attached && !page.view.webContents.isDestroyed?.() && page.view.webContents.isFocused?.()
|
||||
page.view.setVisible?.(false)
|
||||
if (!page.attached) return
|
||||
this.parent?.contentView.removeChildView(page.view)
|
||||
page.attached = false
|
||||
if (returnFocus && !this.parent?.isDestroyed?.() && !this.parent?.webContents?.isDestroyed?.()) {
|
||||
this.parent?.webContents?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
private applyBounds(page: WorkspaceBrowserPage): void {
|
||||
if (!page.requestedBounds || !this.parent) return
|
||||
const scaleFactor = this.resolveScaleFactor?.(this.parent) ?? 1
|
||||
page.view.setBounds(snapPreviewBoundsToScaleFactor(page.requestedBounds, scaleFactor))
|
||||
}
|
||||
|
||||
private recordVisit(page: WorkspaceBrowserPage, url: string): void {
|
||||
// A visit log, not a back/forward stack: Electron's navigation entries carry
|
||||
// no timestamps, and the native history stays the source of truth for
|
||||
@@ -919,24 +940,27 @@ export class ElectronWorkspaceBrowserService {
|
||||
if (!isHttpUrl(url)) return
|
||||
const last = page.history[page.history.length - 1]
|
||||
if (last?.url === url) return
|
||||
page.history.push({ url, title: page.view.webContents.getTitle(), visitedAt: Date.now() })
|
||||
page.history.push({ url, title: page.webContents.getTitle(), visitedAt: Date.now() })
|
||||
if (page.history.length > MAX_HISTORY_ENTRIES) page.history.shift()
|
||||
this.emitFor(page, { type: 'history', tabId: page.tabId, entries: [...page.history] })
|
||||
}
|
||||
|
||||
private emitState(page: WorkspaceBrowserPage): void {
|
||||
const webContents = page.view.webContents
|
||||
const webContents = page.webContents
|
||||
if (webContents.isDestroyed?.()) return
|
||||
const actualZoom = webContents.getZoomFactor?.()
|
||||
if (actualZoom !== undefined && Number.isFinite(actualZoom) && actualZoom > 0) page.zoomFactor = actualZoom
|
||||
void this.syncBrowserControls(page).catch(error => {
|
||||
if (!page.closed) console.error('Failed to update workspace browser controls', error)
|
||||
})
|
||||
// Until its first real navigation the guest shows the blank document it
|
||||
// attached with. A tab without a page has no address and no title.
|
||||
const blank = webContents.getURL() === WORKSPACE_BROWSER_INITIAL_SRC
|
||||
this.emitFor(page, {
|
||||
type: 'state',
|
||||
tabId: page.tabId,
|
||||
url: webContents.getURL(),
|
||||
title: webContents.getTitle(),
|
||||
url: blank ? '' : webContents.getURL(),
|
||||
title: blank ? '' : webContents.getTitle(),
|
||||
canGoBack: readCanGoBack(webContents),
|
||||
canGoForward: readCanGoForward(webContents),
|
||||
loading: webContents.isLoading(),
|
||||
@@ -960,7 +984,7 @@ export class ElectronWorkspaceBrowserService {
|
||||
private findPageByWebContents(webContents: unknown): WorkspaceBrowserPage | null {
|
||||
if (!webContents) return null
|
||||
for (const page of this.pages.values()) {
|
||||
if (page.view.webContents === webContents) return page
|
||||
if (page.webContents === webContents) return page
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -977,6 +1001,29 @@ export class ElectronWorkspaceBrowserService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromium keys page zoom by host name alone — port and scheme do not count —
|
||||
* or by the whole URL when there is no host (`net::GetHostOrSpecFromURL`).
|
||||
*/
|
||||
function zoomHostKey(url: string): string {
|
||||
try {
|
||||
const { hostname } = new URL(url)
|
||||
return hostname ? hostname.replace(/\.$/, '') : url
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), ms)
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
function readCanGoBack(webContents: WorkspaceBrowserWebContentsLike): boolean {
|
||||
return webContents.navigationHistory?.canGoBack() ?? webContents.canGoBack?.() ?? false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
WORKSPACE_BROWSER_INITIAL_SRC,
|
||||
WORKSPACE_BROWSER_PARTITION,
|
||||
} from '../../src/lib/workspace/browserGuestContract'
|
||||
import {
|
||||
applyWorkspaceBrowserAttachPolicy,
|
||||
installWorkspaceBrowserGuestPolicy,
|
||||
resolveWorkspaceBrowserGuest,
|
||||
} from './workspaceBrowserGuest'
|
||||
|
||||
const PRELOAD = '/app/electron-dist/preview-preload.cjs'
|
||||
const browserAttach = { partition: WORKSPACE_BROWSER_PARTITION, src: WORKSPACE_BROWSER_INITIAL_SRC }
|
||||
|
||||
describe('workspace browser attach policy', () => {
|
||||
it('pins the sandboxed preferences and the preview preload, whatever the element asked for', () => {
|
||||
// Everything a `<webview>` attribute or `webpreferences` string could
|
||||
// request on its way here.
|
||||
const requested: Record<string, unknown> = {
|
||||
preload: '/tmp/evil.js',
|
||||
preloadURL: 'file:///tmp/evil.js',
|
||||
nodeIntegration: true,
|
||||
nodeIntegrationInSubFrames: true,
|
||||
nodeIntegrationInWorker: true,
|
||||
contextIsolation: false,
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true,
|
||||
experimentalFeatures: true,
|
||||
enableBlinkFeatures: 'Foo',
|
||||
disableBlinkFeatures: 'Bar',
|
||||
additionalArguments: ['--evil'],
|
||||
plugins: true,
|
||||
webviewTag: true,
|
||||
zoomFactor: 3,
|
||||
}
|
||||
expect(applyWorkspaceBrowserAttachPolicy(requested, browserAttach, { preload: PRELOAD })).toBe(true)
|
||||
expect(requested).toEqual({
|
||||
preload: PRELOAD,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
experimentalFeatures: false,
|
||||
plugins: false,
|
||||
webviewTag: false,
|
||||
zoomFactor: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the renderer session', { partition: undefined, src: WORKSPACE_BROWSER_INITIAL_SRC }],
|
||||
['another partition', { partition: 'persist:other', src: WORKSPACE_BROWSER_INITIAL_SRC }],
|
||||
['a page the renderer picked', { partition: WORKSPACE_BROWSER_PARTITION, src: 'https://example.com/' }],
|
||||
['a local file', { partition: WORKSPACE_BROWSER_PARTITION, src: 'file:///etc/passwd' }],
|
||||
])('denies a guest for %s', (_name, params) => {
|
||||
const preferences: Record<string, unknown> = { nodeIntegration: true }
|
||||
expect(applyWorkspaceBrowserAttachPolicy(preferences, params, { preload: PRELOAD })).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels a denied attach and confines every attached guest until it is adopted', () => {
|
||||
const handlers = new Map<string, (...args: never[]) => void>()
|
||||
installWorkspaceBrowserGuestPolicy({
|
||||
on: (event, handler) => { handlers.set(event, handler) },
|
||||
}, { preload: PRELOAD })
|
||||
const willAttach = handlers.get('will-attach-webview') as unknown as (
|
||||
event: { preventDefault(): void }, preferences: Record<string, unknown>, params: unknown,
|
||||
) => void
|
||||
const denied = { preventDefault: vi.fn() }
|
||||
willAttach(denied, {}, { partition: 'persist:other', src: WORKSPACE_BROWSER_INITIAL_SRC })
|
||||
expect(denied.preventDefault).toHaveBeenCalledTimes(1)
|
||||
const allowed = { preventDefault: vi.fn() }
|
||||
willAttach(allowed, {}, browserAttach)
|
||||
expect(allowed.preventDefault).not.toHaveBeenCalled()
|
||||
|
||||
let openHandler: ((details: { url: string }) => unknown) | undefined
|
||||
const navigateHandlers: Array<(event: { preventDefault(): void }, url: string) => void> = []
|
||||
const didAttach = handlers.get('did-attach-webview') as unknown as (event: unknown, guest: unknown) => void
|
||||
didAttach({}, {
|
||||
setWindowOpenHandler: (handler: (details: { url: string }) => unknown) => { openHandler = handler },
|
||||
on: (_event: string, handler: (event: { preventDefault(): void }, url: string) => void) => { navigateHandlers.push(handler) },
|
||||
})
|
||||
expect(openHandler?.({ url: 'https://popup.example/' })).toEqual({ action: 'deny' })
|
||||
const blocked = { preventDefault: vi.fn() }
|
||||
navigateHandlers[0]!(blocked, 'file:///etc/passwd')
|
||||
const allowedNavigation = { preventDefault: vi.fn() }
|
||||
navigateHandlers[0]!(allowedNavigation, 'https://example.com/')
|
||||
expect(blocked.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(allowedNavigation.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace browser guest resolution', () => {
|
||||
const host = { id: 1 }
|
||||
const browserSession = { name: 'browser' }
|
||||
const guest = (overrides: Partial<{ type: string, host: unknown, session: unknown, destroyed: boolean }> = {}) => ({
|
||||
isDestroyed: () => overrides.destroyed ?? false,
|
||||
getType: () => overrides.type ?? 'webview',
|
||||
hostWebContents: 'host' in overrides ? overrides.host : host,
|
||||
session: 'session' in overrides ? overrides.session : browserSession,
|
||||
})
|
||||
|
||||
it('adopts a webview of the main window in the browser partition', () => {
|
||||
const page = guest()
|
||||
expect(resolveWorkspaceBrowserGuest(7, { fromId: () => page, host, session: browserSession })).toBe(page)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a malformed id', 0, guest()],
|
||||
['a fractional id', 1.5, guest()],
|
||||
['a string id', '7', guest()],
|
||||
['a missing guest', 7, null],
|
||||
['a destroyed guest', 7, guest({ destroyed: true })],
|
||||
// The renderer's own webContents carries the local access token.
|
||||
['the renderer itself', 7, guest({ type: 'window', host: null })],
|
||||
['another window’s webview', 7, guest({ host: { id: 2 } })],
|
||||
['a webview in another session', 7, guest({ session: { name: 'renderer' } })],
|
||||
])('refuses %s', (_name, id, candidate) => {
|
||||
expect(() => resolveWorkspaceBrowserGuest(id, {
|
||||
fromId: () => candidate,
|
||||
host,
|
||||
session: browserSession,
|
||||
})).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
WORKSPACE_BROWSER_INITIAL_SRC,
|
||||
WORKSPACE_BROWSER_PARTITION,
|
||||
} from '../../src/lib/workspace/browserGuestContract'
|
||||
import { isHttpUrl } from './navigationGuards'
|
||||
|
||||
/**
|
||||
* Attach boundary for workspace browser `<webview>` guests.
|
||||
*
|
||||
* The main window enables `webviewTag` only so the workspace browser can be
|
||||
* composited with the DOM: a native `WebContentsView` always paints above the
|
||||
* page, which is what made menus, dialogs and other pages flicker behind or
|
||||
* underneath it. In exchange, the renderer may now ask for a guest, so every
|
||||
* attach is decided here, deny by default:
|
||||
*
|
||||
* - only the shared browser partition, starting from the fixed blank document;
|
||||
* - every preference the element could have requested is replaced with the
|
||||
* sandboxed set the old `WebContentsView` used, plus the preview preload the
|
||||
* annotation agent relies on.
|
||||
*/
|
||||
|
||||
/** Keys the embedding element may set that must never reach a guest. */
|
||||
const STRIPPED_PREFERENCE_KEYS = [
|
||||
'preloadURL',
|
||||
'enableBlinkFeatures',
|
||||
'disableBlinkFeatures',
|
||||
'additionalArguments',
|
||||
'nodeIntegrationInWorker',
|
||||
] as const
|
||||
|
||||
export type WorkspaceBrowserAttachParams = {
|
||||
partition?: unknown
|
||||
src?: unknown
|
||||
}
|
||||
|
||||
export function applyWorkspaceBrowserAttachPolicy(
|
||||
webPreferences: Record<string, unknown>,
|
||||
params: WorkspaceBrowserAttachParams,
|
||||
options: { preload: string },
|
||||
): boolean {
|
||||
if (params.partition !== WORKSPACE_BROWSER_PARTITION) return false
|
||||
if (params.src !== WORKSPACE_BROWSER_INITIAL_SRC) return false
|
||||
for (const key of STRIPPED_PREFERENCE_KEYS) delete webPreferences[key]
|
||||
Object.assign(webPreferences, {
|
||||
preload: options.preload,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
experimentalFeatures: false,
|
||||
plugins: false,
|
||||
// A page cannot nest a guest of its own.
|
||||
webviewTag: false,
|
||||
// Electron starts a guest at its embedder's zoom. App zoom never applied to
|
||||
// the old native view, so a page still starts at 100%.
|
||||
zoomFactor: 1,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
export type WorkspaceBrowserGuestLike = {
|
||||
isDestroyed(): boolean
|
||||
getType(): string
|
||||
hostWebContents?: unknown
|
||||
session?: unknown
|
||||
}
|
||||
|
||||
export function resolveWorkspaceBrowserGuest<Guest extends WorkspaceBrowserGuestLike>(
|
||||
webContentsId: unknown,
|
||||
options: {
|
||||
fromId: (id: number) => Guest | undefined | null
|
||||
host: unknown
|
||||
session: unknown
|
||||
},
|
||||
): Guest {
|
||||
if (typeof webContentsId !== 'number' || !Number.isSafeInteger(webContentsId) || webContentsId <= 0) {
|
||||
throw new Error('invalid workspace browser guest id')
|
||||
}
|
||||
const guest = options.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) throw new Error('workspace browser guest is gone')
|
||||
// The id travels through the renderer, so it is only a claim. Adopt nothing
|
||||
// that is not a webview of the main window in the browser's own partition —
|
||||
// least of all the renderer itself, whose session carries the local token.
|
||||
if (guest.getType() !== 'webview' || guest.hostWebContents !== options.host || guest.session !== options.session) {
|
||||
throw new Error('not a workspace browser guest of the main window')
|
||||
}
|
||||
return guest
|
||||
}
|
||||
|
||||
type AttachEvent = { preventDefault(): void }
|
||||
type GuestBaseline = {
|
||||
setWindowOpenHandler(handler: (details: { url: string }) => { action: 'deny' }): void
|
||||
on(event: 'will-navigate', handler: (event: AttachEvent, url: string) => void): unknown
|
||||
}
|
||||
|
||||
/** Electron's `WebContents` declares each event as its own overload. */
|
||||
export type WorkspaceBrowserGuestHostLike = {
|
||||
on(event: string, handler: (...args: never[]) => void): unknown
|
||||
}
|
||||
|
||||
export function installWorkspaceBrowserGuestPolicy(
|
||||
host: WorkspaceBrowserGuestHostLike,
|
||||
options: { preload: string },
|
||||
): void {
|
||||
host.on('will-attach-webview', (
|
||||
event: AttachEvent,
|
||||
webPreferences: Record<string, unknown>,
|
||||
params: WorkspaceBrowserAttachParams,
|
||||
) => {
|
||||
if (!applyWorkspaceBrowserAttachPolicy(webPreferences, params, options)) event.preventDefault()
|
||||
})
|
||||
host.on('did-attach-webview', (_event: unknown, guest: GuestBaseline) => {
|
||||
// Until the browser service adopts it, a guest can neither open a window
|
||||
// nor leave http(s). Adoption replaces the popup handler with its own.
|
||||
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
guest.on('will-navigate', (event, url) => {
|
||||
if (!isHttpUrl(url)) event.preventDefault()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function buildWorkspaceBrowserMenuTemplate(
|
||||
]
|
||||
}
|
||||
|
||||
/** One popup belongs to one live page; dismissal never hides its WebContentsView. */
|
||||
/** One popup belongs to one live page; dismissal never hides or reloads that page. */
|
||||
export class WorkspaceBrowserMenuController {
|
||||
private pending: { tabId: string; cancel: () => void } | null = null
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeWebviewBounds } from './computeWebviewBounds'
|
||||
|
||||
describe('computeWebviewBounds', () => {
|
||||
it('maps a DOMRect to logical bounds without rounding away high-DPI fractions', () => {
|
||||
const rect = { left: 100.4, top: 50.6, width: 800.2, height: 600.9 } as DOMRect
|
||||
expect(computeWebviewBounds(rect)).toEqual({ x: 100.4, y: 50.6, width: 800.2, height: 600.9 })
|
||||
})
|
||||
|
||||
it('maps zoomed CSS coordinates back to the native parent coordinate space', () => {
|
||||
const rect = { left: 180, top: 150, width: 420, height: 300 } as DOMRect
|
||||
expect(computeWebviewBounds(rect, 1.25)).toEqual({ x: 225, y: 187.5, width: 525, height: 375 })
|
||||
})
|
||||
|
||||
it('clamps negative/zero sizes to 0', () => {
|
||||
const rect = { left: -5, top: -5, width: -10, height: 0 } as DOMRect
|
||||
expect(computeWebviewBounds(rect)).toEqual({ x: -5, y: -5, width: 0, height: 0 })
|
||||
})
|
||||
})
|
||||
@@ -1,13 +0,0 @@
|
||||
export type WebviewBounds = { x: number; y: number; width: number; height: number }
|
||||
|
||||
export function computeWebviewBounds(
|
||||
rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>,
|
||||
appZoom = 1,
|
||||
): WebviewBounds {
|
||||
return {
|
||||
x: rect.left * appZoom,
|
||||
y: rect.top * appZoom,
|
||||
width: Math.max(0, rect.width * appZoom),
|
||||
height: Math.max(0, rect.height * appZoom),
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceStatusResult } from '../../api/sessions'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import { openLocalFileWithSystem } from '../../lib/systemFileOpen'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
|
||||
import { AssistantMessage } from './AssistantMessage'
|
||||
@@ -50,7 +49,6 @@ beforeEach(() => {
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
apiGetBlob.mockReset().mockRejectedValue(new Error('404'))
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
|
||||
withWorkDir('/repo')
|
||||
vi.mocked(openLocalFileWithSystem).mockReset().mockResolvedValue(undefined)
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
|
||||
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '../../lib/systemFileOpen'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
vi.mock('../../lib/systemFileOpen', () => ({
|
||||
@@ -22,86 +21,12 @@ const gallery = [
|
||||
]
|
||||
|
||||
const reset = () => {
|
||||
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
}
|
||||
|
||||
beforeEach(reset)
|
||||
afterEach(reset)
|
||||
|
||||
describe('ImageGalleryModal · overlay suppression', () => {
|
||||
it('increments overlay count while open and decrements on unmount', () => {
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
|
||||
const { unmount } = render(
|
||||
<ImageGalleryModal
|
||||
open
|
||||
images={images}
|
||||
activeIndex={0}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(useOverlayStore.getState().count).toBe(1)
|
||||
|
||||
unmount()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
})
|
||||
|
||||
it('does not increment when rendered with open=false', () => {
|
||||
const { unmount } = render(
|
||||
<ImageGalleryModal
|
||||
open={false}
|
||||
images={images}
|
||||
activeIndex={0}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
unmount()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
})
|
||||
|
||||
it('toggles count when open prop flips closed → open → closed', () => {
|
||||
const { rerender, unmount } = render(
|
||||
<ImageGalleryModal
|
||||
open={false}
|
||||
images={images}
|
||||
activeIndex={0}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
|
||||
rerender(
|
||||
<ImageGalleryModal
|
||||
open
|
||||
images={images}
|
||||
activeIndex={0}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(useOverlayStore.getState().count).toBe(1)
|
||||
|
||||
rerender(
|
||||
<ImageGalleryModal
|
||||
open={false}
|
||||
images={images}
|
||||
activeIndex={0}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
|
||||
unmount()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageGalleryModal · navigation', () => {
|
||||
it('names both arrows, which an icon-only control otherwise lacks', () => {
|
||||
render(<ImageGalleryModal open images={gallery} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AuthedImage } from './AuthedImage'
|
||||
import { getDesktopHost } from '@/lib/desktopHost'
|
||||
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type GalleryImage = {
|
||||
@@ -39,16 +38,6 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
|
||||
const t = useTranslation()
|
||||
const activeImage = images[activeIndex]
|
||||
|
||||
// Native child webviews (e.g. the in-app browser preview) always render
|
||||
// ABOVE the DOM, so this fullscreen overlay would be partially covered.
|
||||
// Bump the overlay count while open so BrowserSurface can hide the webview.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const { push, pop } = useOverlayStore.getState()
|
||||
push()
|
||||
return () => pop()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || images.length <= 1) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
@@ -97,6 +97,39 @@ describe('ContentRouter tab surfaces', () => {
|
||||
expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-runtime-id', '__session_terminal__session-1')
|
||||
})
|
||||
|
||||
it('keeps one browser page layer for the life of the window, inside the session panel', () => {
|
||||
// A workspace browser page dies when its element leaves the DOM and
|
||||
// reloads when it moves, so the layer holding them must never be
|
||||
// re-created — not on a page switch, a task switch, or with no task open.
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId: 'session-1', title: 'One', type: 'session', status: 'idle' },
|
||||
{ sessionId: 'session-2', title: 'Two', type: 'session', status: 'idle' },
|
||||
{ sessionId: MARKET_TAB_ID, title: 'Market', type: 'market', status: 'idle' },
|
||||
],
|
||||
activeTabId: 'session-1',
|
||||
})
|
||||
render(<ContentRouter />)
|
||||
const layer = screen.getByTestId('workspace-browser-guest-layer')
|
||||
// Same stacking context as the session content: it fades and goes inert
|
||||
// with the panel when another page takes the window.
|
||||
expect(screen.getByTestId('session-tab-panel')).toContainElement(layer)
|
||||
|
||||
act(() => useTabStore.getState().setActiveTab(MARKET_TAB_ID))
|
||||
expect(screen.getByTestId('session-tab-panel')).toHaveAttribute('inert')
|
||||
expect(screen.getByTestId('workspace-browser-guest-layer')).toBe(layer)
|
||||
|
||||
act(() => useTabStore.getState().setActiveTab('session-2'))
|
||||
expect(screen.getByTestId('workspace-browser-guest-layer')).toBe(layer)
|
||||
|
||||
act(() => useTabStore.setState({
|
||||
tabs: [{ sessionId: MARKET_TAB_ID, title: 'Market', type: 'market', status: 'idle' }],
|
||||
activeTabId: MARKET_TAB_ID,
|
||||
}))
|
||||
expect(screen.queryByTestId('active-session')).toBeNull()
|
||||
expect(screen.getByTestId('workspace-browser-guest-layer')).toBe(layer)
|
||||
})
|
||||
|
||||
it('keeps terminal tabs mounted while chat content is active', () => {
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TraceList } from '../../pages/TraceList'
|
||||
import { TraceSession } from '../../pages/TraceSession'
|
||||
import { SubagentRunPage, TeamMemberRunPage } from '../../pages/SubagentRunPage'
|
||||
import { AgentTeamsWorkbenchTab } from '../agentTeams/AgentTeamsWorkbenchTab'
|
||||
import { WorkspaceBrowserGuestLayer } from '../workbench/WorkspaceBrowserGuestLayer'
|
||||
import { returnToTraceList } from '../../lib/traceNavigation'
|
||||
|
||||
export function ContentRouter() {
|
||||
@@ -97,18 +98,24 @@ export function ContentRouter() {
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{retainedSessionId && (
|
||||
<div
|
||||
aria-hidden={!activeSessionId}
|
||||
{...(activeSessionId ? {} : { inert: '' })}
|
||||
data-testid="session-tab-panel"
|
||||
className={`absolute inset-0 flex min-h-0 flex-col overflow-hidden ${
|
||||
activeSessionId ? 'z-10 opacity-100' : 'pointer-events-none z-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/*
|
||||
Always rendered, even with no task to retain: the browser page layer
|
||||
inside it must outlive every task switch, because a page whose element
|
||||
is removed is destroyed and one that is moved reloads.
|
||||
*/}
|
||||
<div
|
||||
aria-hidden={!activeSessionId}
|
||||
{...(activeSessionId ? {} : { inert: '' })}
|
||||
data-testid="session-tab-panel"
|
||||
className={`absolute inset-0 flex min-h-0 flex-col overflow-hidden ${
|
||||
activeSessionId ? 'z-10 opacity-100' : 'pointer-events-none z-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{retainedSessionId ? (
|
||||
<ActiveSession sessionId={retainedSessionId} active={Boolean(activeSessionId)} />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<WorkspaceBrowserGuestLayer />
|
||||
</div>
|
||||
{page && (
|
||||
<div className="absolute inset-0 z-10 flex min-h-0 flex-col overflow-hidden">
|
||||
{page}
|
||||
|
||||
@@ -50,11 +50,9 @@ vi.mock('../../stores/openTargetStore', () => ({
|
||||
}))
|
||||
|
||||
import { OpenProjectMenu } from './OpenProjectMenu'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
|
||||
describe('OpenProjectMenu', () => {
|
||||
beforeEach(() => {
|
||||
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
|
||||
storeMocks.ensureTargets.mockReset()
|
||||
storeMocks.openTarget.mockReset()
|
||||
storeMocks.state = {
|
||||
@@ -109,25 +107,6 @@ describe('OpenProjectMenu', () => {
|
||||
expect(storeMocks.openTarget).toHaveBeenCalledWith('finder', '/repo')
|
||||
})
|
||||
|
||||
it('suppresses the native browser preview while the target menu is open', async () => {
|
||||
storeMocks.state.targets = [
|
||||
{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
|
||||
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
|
||||
]
|
||||
storeMocks.state.primaryTargetId = 'vscode'
|
||||
|
||||
const { unmount } = render(<OpenProjectMenu path="/repo" />)
|
||||
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open project' }))
|
||||
})
|
||||
expect(useOverlayStore.getState().count).toBe(1)
|
||||
|
||||
unmount()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
})
|
||||
|
||||
it('does not render without a path', () => {
|
||||
const { container } = render(<OpenProjectMenu path={null} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createPortal } from 'react-dom'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { TargetIcon } from '@/components/composite/TargetIcon'
|
||||
|
||||
@@ -29,15 +28,6 @@ export function OpenProjectMenu({ path }: Props) {
|
||||
void ensureTargets()
|
||||
}, [ensureTargets, path])
|
||||
|
||||
// The native browser preview always renders above DOM portals. Suppress it
|
||||
// while this menu is open so targets below the browser toolbar stay visible.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const { push, pop } = useOverlayStore.getState()
|
||||
push()
|
||||
return () => pop()
|
||||
}, [open])
|
||||
|
||||
const handleDismiss = useCallback(() => setOpen(false), [])
|
||||
|
||||
useDismissable({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { StrictMode, useRef, useState } from 'react'
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useOverlayStore } from '@/stores/overlayStore'
|
||||
import { WorkspaceAddMenu } from './WorkspaceAddMenu'
|
||||
|
||||
const selected = vi.fn()
|
||||
@@ -21,7 +20,6 @@ function Harness({ last = false, disabled = false }: { last?: boolean; disabled?
|
||||
|
||||
beforeEach(() => {
|
||||
selected.mockClear()
|
||||
useOverlayStore.setState({ count: 0, snapshotCount: 0 })
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
@@ -120,16 +118,11 @@ describe('WorkspaceAddMenu', () => {
|
||||
expect(selected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects one resource and balances native snapshot suppression in StrictMode', () => {
|
||||
const { unmount } = render(<StrictMode><Harness /></StrictMode>)
|
||||
it('selects one resource exactly once in StrictMode', () => {
|
||||
render(<StrictMode><Harness /></StrictMode>)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(useOverlayStore.getState()).toMatchObject({ count: 1, snapshotCount: 1 })
|
||||
fireEvent.click(screen.getByTestId('workspace-menu-browser'))
|
||||
expect(selected).toHaveBeenCalledExactlyOnceWith('browser')
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(useOverlayStore.getState()).toMatchObject({ count: 0, snapshotCount: 0 })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
unmount()
|
||||
expect(useOverlayStore.getState()).toMatchObject({ count: 0, snapshotCount: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useAnchoredPosition } from '@/hooks/useAnchoredPosition'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import type { WorkspaceDock, WorkspaceTabKind } from '@/lib/workspace/types'
|
||||
import { useSuppressBrowserOverlay } from '@/stores/overlayStore'
|
||||
import { useMenuKeyboard } from './menuKeyboard'
|
||||
import { WorkspaceLauncher } from './WorkspaceLauncher'
|
||||
|
||||
@@ -24,7 +23,6 @@ export function WorkspaceAddMenu({
|
||||
}: WorkspaceAddMenuProps) {
|
||||
const t = useTranslation()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
useSuppressBrowserOverlay({ preserveSnapshot: true })
|
||||
const position = useAnchoredPosition({
|
||||
open: true, anchorRef, floatingRef: menuRef,
|
||||
placement: 'bottom-start', offset: 1, viewportMargin: 6, clampHeight: true,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createRef } from 'react'
|
||||
import { WorkspaceBrowserAddressBar } from '@/components/workbench/WorkspaceBrowserAddressBar'
|
||||
import { normalizeBrowserAddress } from '@/lib/workspace/browserAddress'
|
||||
import { useOverlayStore } from '@/stores/overlayStore'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
|
||||
const visits = Array.from({ length: 8 }, (_, index) => ({
|
||||
@@ -19,7 +18,6 @@ const props = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useOverlayStore.setState({ count: 0, snapshotCount: 0 })
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -63,7 +61,6 @@ describe('WorkspaceBrowserAddressBar', () => {
|
||||
fireEvent.change(address, { target: { value: 'in progress' } })
|
||||
rerender(<WorkspaceBrowserAddressBar {...props} currentAddress="" blank active={false} />)
|
||||
expect(address).toHaveValue('in progress')
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
act(() => address.blur())
|
||||
rerender(<WorkspaceBrowserAddressBar {...props} currentAddress="" blank />)
|
||||
act(() => address.focus())
|
||||
@@ -73,19 +70,16 @@ describe('WorkspaceBrowserAddressBar', () => {
|
||||
expect(onNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create actionable suggestions for an unsupported scheme and balances overlays on unmount', () => {
|
||||
const { unmount } = render(<WorkspaceBrowserAddressBar {...props} />)
|
||||
it('does not create actionable suggestions for an unsupported scheme', () => {
|
||||
render(<WorkspaceBrowserAddressBar {...props} />)
|
||||
const address = screen.getByRole('combobox')
|
||||
act(() => address.focus())
|
||||
expect(useOverlayStore.getState().count).toBe(1)
|
||||
fireEvent.change(address, { target: { value: 'javascript:alert(1)' } })
|
||||
expect(screen.queryByRole('listbox')).toBeNull()
|
||||
fireEvent.keyDown(address, { key: 'Enter' })
|
||||
expect(onNavigate).not.toHaveBeenCalled()
|
||||
fireEvent.change(address, { target: { value: 'fixture' } })
|
||||
expect(useOverlayStore.getState().snapshotCount).toBe(1)
|
||||
unmount()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the new address only after navigation is accepted, allowing selection-discard confirmation first', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { IconButton } from '@/components/ui/IconButton'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { normalizeBrowserAddress } from '@/lib/workspace/browserAddress'
|
||||
import { useSuppressBrowserOverlay } from '@/stores/overlayStore'
|
||||
import type { WorkspaceBrowserVisit } from '@/stores/workspaceBrowserStore'
|
||||
|
||||
type AddressSuggestion = { input: string; title: string; url?: string; action?: 'search' | 'visit' }
|
||||
@@ -186,7 +185,6 @@ function AddressSuggestions({ id, items, query, selectedIndex, onHighlight, onSe
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
useSuppressBrowserOverlay({ preserveSnapshot: true })
|
||||
useEffect(() => {
|
||||
listRef.current?.children[selectedIndex]?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [selectedIndex, items])
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from 'react'
|
||||
import { setWorkspaceBrowserGuestLayer } from '@/lib/workspace/browserGuests'
|
||||
|
||||
/**
|
||||
* Where every workspace browser page lives, for the life of the window.
|
||||
*
|
||||
* It must be mounted once and never re-parented: a `<webview>` reloads when
|
||||
* moved and dies when removed. It sits after the session content in the same
|
||||
* stacking context, so a page paints over the workspace like the placeholder it
|
||||
* covers and under every dropdown, dialog and toast, and it fades with the
|
||||
* session panel when another page takes the window.
|
||||
*/
|
||||
export function WorkspaceBrowserGuestLayer() {
|
||||
const register = useCallback((element: HTMLDivElement | null) => {
|
||||
setWorkspaceBrowserGuestLayer(element)
|
||||
}, [])
|
||||
return (
|
||||
<div
|
||||
ref={register}
|
||||
data-testid="workspace-browser-guest-layer"
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -19,13 +19,11 @@ const { host, isAvailable, releaseTab, openExternal, openPath } = vi.hoisted(()
|
||||
goForward: resolved(),
|
||||
reload: resolved(),
|
||||
stop: resolved(),
|
||||
setBounds: resolved(),
|
||||
setVisible: resolved(),
|
||||
setZoom: resolved(),
|
||||
find: resolved(),
|
||||
stopFind: resolved(),
|
||||
capture: resolved(),
|
||||
snapshot: vi.fn().mockResolvedValue('data:image/png;base64,BACKDROP'),
|
||||
message: resolved(),
|
||||
close: resolved(),
|
||||
printToPdf: resolved(),
|
||||
@@ -57,7 +55,7 @@ vi.mock('../../lib/desktopHost', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { WorkspaceBrowserTab } from './WorkspaceBrowserTab'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { installFakeBrowserGuests, isBrowserPageShown } from '../../test/fakeBrowserGuests'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useWorkspaceBrowserStore } from '../../stores/workspaceBrowserStore'
|
||||
import { useWorkspaceStore } from '../../stores/workspaceStore'
|
||||
@@ -125,23 +123,34 @@ async function openMenuItem(action: WorkspaceBrowserMenuAction) {
|
||||
await act(async () => { fireEvent.click(screen.getByTestId('workspace-browser-menu-trigger')) })
|
||||
}
|
||||
|
||||
function pageShown(tab: WorkspaceBrowserTabModel): boolean {
|
||||
return isBrowserPageShown(tab.browserTabId)
|
||||
}
|
||||
|
||||
/** Lets an element attach, register with the host and report back. */
|
||||
async function flushGuests() {
|
||||
for (let tick = 0; tick < 6; tick += 1) await act(async () => {})
|
||||
}
|
||||
|
||||
let disposeGuests: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en', uiZoom: 1 })
|
||||
useWorkspaceStore.setState({ bySession: {}, sideWidth: 860, bottomHeight: 420 })
|
||||
useWorkspaceBrowserStore.setState({ pageByTabId: {}, historyByTabId: {}, downloads: [] })
|
||||
useOverlayStore.setState({ count: 0, snapshotCount: 0 })
|
||||
usePreviewSelectionStore.setState({ bySession: {} })
|
||||
isAvailable.mockReturnValue(true)
|
||||
for (const mock of Object.values(host)) mock.mockReset().mockResolvedValue({ ok: true })
|
||||
host.snapshot.mockResolvedValue('data:image/png;base64,BACKDROP')
|
||||
host.showMenu.mockResolvedValue(null)
|
||||
releaseTab.mockClear()
|
||||
openExternal.mockClear()
|
||||
openPath.mockClear()
|
||||
disposeGuests = installFakeBrowserGuests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
disposeGuests()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -166,14 +175,16 @@ describe('lifecycle readiness', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('focuses a newly created empty address bar once its native page is ready', async () => {
|
||||
it('focuses a newly created empty address bar once its page is ready', async () => {
|
||||
const tab = openBrowserTab(null)
|
||||
const create = deferredCreate()
|
||||
host.create.mockReturnValueOnce(create.promise)
|
||||
render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
const address = screen.getByTestId('workspace-browser-address')
|
||||
expect(address).toBeDisabled()
|
||||
await flushGuests()
|
||||
await act(async () => { create.resolve({ ok: true }) })
|
||||
await flushGuests()
|
||||
expect(address).toBeEnabled()
|
||||
expect(address).toHaveFocus()
|
||||
})
|
||||
@@ -185,61 +196,62 @@ describe('lifecycle readiness', () => {
|
||||
render(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active /></>)
|
||||
const composer = screen.getByRole('textbox', { name: 'Chat composer' })
|
||||
act(() => { composer.focus() })
|
||||
await flushGuests()
|
||||
await act(async () => { create.resolve({ ok: true }) })
|
||||
await flushGuests()
|
||||
expect(composer).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not focus an existing loaded page address bar when snapshots open and close', async () => {
|
||||
it('does not focus an existing loaded page address bar when it is hidden and shown again', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active /></>)
|
||||
const view = await renderReady(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active /></>)
|
||||
const composer = screen.getByRole('textbox', { name: 'Chat composer' })
|
||||
act(() => { composer.focus() })
|
||||
await act(async () => { useOverlayStore.getState().push(true) })
|
||||
act(() => { useOverlayStore.getState().pop(true) })
|
||||
view.rerender(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active={false} /></>)
|
||||
view.rerender(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active /></>)
|
||||
expect(composer).toHaveFocus()
|
||||
})
|
||||
|
||||
it('waits for registration before bounds, visibility or user commands', async () => {
|
||||
it('registers the guest it created with the host, then shows it and takes commands', async () => {
|
||||
const creation = deferredCreate()
|
||||
host.create.mockReturnValueOnce(creation.promise)
|
||||
const tab = openBrowserTab()
|
||||
render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload' }))
|
||||
expect(host.setBounds).not.toHaveBeenCalled()
|
||||
await flushGuests()
|
||||
expect(host.create).toHaveBeenCalledWith(tab.browserTabId, {
|
||||
storageId: tab.storageId,
|
||||
webContentsId: expect.any(Number),
|
||||
url: 'https://example.test/',
|
||||
})
|
||||
expect(pageShown(tab)).toBe(false)
|
||||
expect(host.setVisible).not.toHaveBeenCalled()
|
||||
expect(host.reload).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => creation.resolve({ ok: true }))
|
||||
expect(host.setBounds).toHaveBeenCalledWith(tab.browserTabId, expect.any(Object))
|
||||
await flushGuests()
|
||||
expect(pageShown(tab)).toBe(true)
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
})
|
||||
|
||||
it('uses registered state before a slow initial navigation finishes', async () => {
|
||||
const creation = deferredCreate()
|
||||
host.create.mockReturnValueOnce(creation.promise)
|
||||
const tab = openBrowserTab()
|
||||
render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
expect(host.setVisible).not.toHaveBeenCalled()
|
||||
emit(pageState(tab.browserTabId, { loading: true }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop loading' }))
|
||||
expect(host.stop).toHaveBeenCalledWith(tab.browserTabId)
|
||||
await act(async () => creation.resolve({ ok: true }))
|
||||
})
|
||||
|
||||
it('surfaces failed creation and retries create before accepting navigation', async () => {
|
||||
const creation = deferredCreate()
|
||||
host.create.mockReturnValueOnce(creation.promise)
|
||||
const tab = openBrowserTab()
|
||||
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
await flushGuests()
|
||||
await act(async () => creation.reject(new Error('native creation denied')))
|
||||
await flushGuests()
|
||||
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={currentTab(tab.id)} active />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('native creation denied')
|
||||
expect(host.setBounds).not.toHaveBeenCalled()
|
||||
expect(pageShown(tab)).toBe(false)
|
||||
expect(host.setVisible).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
await waitFor(() => expect(host.create).toHaveBeenCalledTimes(2))
|
||||
await flushGuests()
|
||||
expect(host.create).toHaveBeenCalledTimes(2)
|
||||
// The refused guest was dropped; the retry registered a fresh one.
|
||||
expect(host.create.mock.calls[1]![1].webContentsId).not.toBe(host.create.mock.calls[0]![1].webContentsId)
|
||||
expect(host.reload).not.toHaveBeenCalled()
|
||||
await act(async () => {})
|
||||
const address = screen.getByTestId('workspace-browser-address')
|
||||
fireEvent.change(address, { target: { value: 'https://retry.test/' } })
|
||||
fireEvent.submit(address.closest('form')!)
|
||||
@@ -251,14 +263,15 @@ describe('lifecycle readiness', () => {
|
||||
host.create.mockReturnValueOnce(creation.promise)
|
||||
const tab = openBrowserTab()
|
||||
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
host.setBounds.mockClear()
|
||||
await flushGuests()
|
||||
host.setVisible.mockClear()
|
||||
act(() => useWorkspaceStore.getState().closeTab(SESSION, tab.id))
|
||||
await act(async () => {
|
||||
if (result === 'resolve') creation.resolve({ ok: true })
|
||||
else creation.reject(new Error('closed while loading'))
|
||||
})
|
||||
expect(host.setBounds).not.toHaveBeenCalled()
|
||||
await flushGuests()
|
||||
expect(pageShown(tab)).toBe(false)
|
||||
expect(host.setVisible).not.toHaveBeenCalled()
|
||||
expect(useWorkspaceStore.getState().findBrowserTabOwner(tab.browserTabId)).toBeNull()
|
||||
view.unmount()
|
||||
@@ -271,16 +284,18 @@ describe('lifecycle readiness', () => {
|
||||
const first = openBrowserTab()
|
||||
const second = openBrowserTab('https://second.test/')
|
||||
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={first} active />)
|
||||
await flushGuests()
|
||||
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={second} active />)
|
||||
await act(async () => {})
|
||||
host.setBounds.mockClear()
|
||||
await flushGuests()
|
||||
host.setVisible.mockClear()
|
||||
await act(async () => {
|
||||
if (result === 'resolve') creation.resolve({ ok: true })
|
||||
else creation.reject(new Error('old page failed'))
|
||||
})
|
||||
expect(host.setBounds).not.toHaveBeenCalled()
|
||||
expect(host.setVisible).not.toHaveBeenCalled()
|
||||
await flushGuests()
|
||||
expect(pageShown(first)).toBe(false)
|
||||
expect(pageShown(second)).toBe(true)
|
||||
expect(host.setVisible).not.toHaveBeenCalledWith(first.browserTabId, expect.anything())
|
||||
expect(currentTab(first.id).loadError).toBeNull()
|
||||
expect(currentTab(second.id).loadError).toBeNull()
|
||||
})
|
||||
@@ -290,57 +305,43 @@ describe('lifecycle readiness', () => {
|
||||
host.create.mockReturnValueOnce(creation.promise)
|
||||
const tab = openBrowserTab()
|
||||
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
expect(host.create).toHaveBeenCalledWith(tab.browserTabId, expect.objectContaining({ visible: false }))
|
||||
await flushGuests()
|
||||
view.unmount()
|
||||
await act(async () => creation.resolve({ ok: true }))
|
||||
expect(host.setBounds).not.toHaveBeenCalled()
|
||||
await flushGuests()
|
||||
expect(pageShown(tab)).toBe(false)
|
||||
expect(host.setVisible).not.toHaveBeenCalled()
|
||||
expect(host.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not replay old resize callbacks into the next page identity', async () => {
|
||||
const callbacks: ResizeObserverCallback[] = []
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: ResizeObserverCallback) { callbacks.push(callback) }
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
})
|
||||
it('parks the previous page and draws the next one when the surface switches tabs', async () => {
|
||||
const first = openBrowserTab()
|
||||
const second = openBrowserTab('https://second.test/')
|
||||
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={first} active />)
|
||||
await act(async () => {})
|
||||
const oldCallback = callbacks[0]!
|
||||
const view = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={first} active />)
|
||||
expect(pageShown(first)).toBe(true)
|
||||
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={second} active />)
|
||||
await act(async () => {})
|
||||
host.setBounds.mockClear()
|
||||
act(() => oldCallback([], {} as ResizeObserver))
|
||||
expect(host.setBounds).not.toHaveBeenCalled()
|
||||
await flushGuests()
|
||||
expect(pageShown(first)).toBe(false)
|
||||
expect(pageShown(second)).toBe(true)
|
||||
// Parking is not closing: both pages are still alive.
|
||||
expect(document.querySelectorAll('webview')).toHaveLength(2)
|
||||
expect(host.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an initial navigation failure retryable after registration', async () => {
|
||||
const creation = deferredCreate()
|
||||
host.create.mockReturnValueOnce(creation.promise)
|
||||
const tab = openBrowserTab()
|
||||
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
emit(pageState(tab.browserTabId))
|
||||
await act(async () => creation.reject(new Error('ERR_CONNECTION_REFUSED')))
|
||||
const view = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
// The host reports a failed first load as a `failed` event, which the
|
||||
// event bridge writes onto the tab.
|
||||
act(() => useWorkspaceStore.getState().updateBrowserTab(SESSION, tab.browserTabId, { loadError: 'ERR_CONNECTION_REFUSED' }))
|
||||
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={currentTab(tab.id)} active />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('ERR_CONNECTION_REFUSED')
|
||||
expect(pageShown(tab)).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
expect(host.reload).toHaveBeenCalledWith(tab.browserTabId, { ignoreCache: true })
|
||||
expect(host.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not replace a completed initial navigation with its late promise rejection', async () => {
|
||||
const creation = deferredCreate()
|
||||
host.create.mockReturnValueOnce(creation.promise)
|
||||
const tab = openBrowserTab()
|
||||
render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
emit({ ...pageState(tab.browserTabId), navigationId: 1, navigationOutcome: 'succeeded' } as WorkspaceBrowserEvent)
|
||||
await act(async () => creation.reject(new Error('late initial load rejection')))
|
||||
expect(currentTab(tab.id).loadError).toBeNull()
|
||||
})
|
||||
|
||||
it('does not replace successful navigation B with late rejected navigation A', async () => {
|
||||
const navigation = deferredCreate()
|
||||
host.navigate.mockReturnValueOnce(navigation.promise)
|
||||
@@ -357,7 +358,7 @@ describe('lifecycle readiness', () => {
|
||||
expect(currentTab(tab.id).loadError).toBeNull()
|
||||
})
|
||||
|
||||
it('reports real navigation and geometry errors for a live page', async () => {
|
||||
it('reports real navigation errors for a live page', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
host.navigate.mockRejectedValueOnce(new Error('navigation denied'))
|
||||
@@ -365,15 +366,12 @@ describe('lifecycle readiness', () => {
|
||||
fireEvent.change(address, { target: { value: 'https://denied.test/' } })
|
||||
fireEvent.submit(address.closest('form')!)
|
||||
await waitFor(() => expect(currentTab(tab.id).loadError).toBe('navigation denied'))
|
||||
host.setBounds.mockRejectedValueOnce(new Error('native geometry failed'))
|
||||
act(() => window.dispatchEvent(new Event('resize')))
|
||||
await waitFor(() => expect(currentTab(tab.id).loadError).toBe('native geometry failed'))
|
||||
})
|
||||
})
|
||||
|
||||
async function renderReady(ui: Parameters<typeof render>[0]) {
|
||||
const view = render(ui)
|
||||
await act(async () => {})
|
||||
await flushGuests()
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -478,7 +476,7 @@ describe('native toolbar menu', () => {
|
||||
expect(screen.getByTestId('workspace-browser-find')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['unmount', 'inactive', 'reactivate', 'close', 'replace', 'overlay'] as const)(
|
||||
it.each(['unmount', 'inactive', 'reactivate', 'close', 'replace'] as const)(
|
||||
'ignores a late menu action after %s', async (transition) => {
|
||||
const tab = openBrowserTab()
|
||||
const view = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
@@ -496,7 +494,6 @@ describe('native toolbar menu', () => {
|
||||
const next = openBrowserTab('https://replacement.test/')
|
||||
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={next} active />)
|
||||
}
|
||||
if (transition === 'overlay') act(() => { useOverlayStore.getState().push() })
|
||||
await act(async () => { select('print') })
|
||||
expect(host.printToPdf).not.toHaveBeenCalled()
|
||||
if (transition === 'replace' || transition === 'reactivate') {
|
||||
@@ -586,8 +583,7 @@ describe('page lifetime', () => {
|
||||
// the entire page. Native menus can cover the live guest without hiding it.
|
||||
expect(host.setVisible).not.toHaveBeenCalledWith(tab.browserTabId, false)
|
||||
expect(host.showMenu).toHaveBeenCalledWith(tab.browserTabId, expect.any(Object))
|
||||
expect(host.snapshot).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
|
||||
expect(pageShown(tab)).toBe(true)
|
||||
expect(screen.getByTestId('workspace-browser-menu-trigger')).toHaveAttribute('aria-expanded', 'true')
|
||||
await act(async () => { dismiss(null) })
|
||||
expect(screen.getByTestId('workspace-browser-menu-trigger')).toHaveAttribute('aria-expanded', 'false')
|
||||
@@ -664,148 +660,63 @@ describe('page lifetime', () => {
|
||||
})
|
||||
|
||||
describe('visibility', () => {
|
||||
it('captures a presentation backdrop before hiding the native page for a plus menu and restores the same page', async () => {
|
||||
// The page is composited with the DOM, so nothing that draws over it has to
|
||||
// hide it, snapshot it, or ask it to move — the bug class the native view had.
|
||||
it('keeps the live page drawn under a dialog instead of hiding or snapshotting it', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
let finish!: (url: string) => void
|
||||
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
act(() => {
|
||||
usePreviewSelectionStore.setState({
|
||||
bySession: { [tab.browserTabId]: { items: [{ id: 'one' }], nextNumber: 2 } },
|
||||
} as never)
|
||||
})
|
||||
host.setVisible.mockClear()
|
||||
const createCount = host.create.mock.calls.length
|
||||
|
||||
act(() => { useOverlayStore.getState().push(true) })
|
||||
expect(host.snapshot).toHaveBeenCalledWith(tab.browserTabId)
|
||||
expect(host.setVisible).not.toHaveBeenCalledWith(tab.browserTabId, false)
|
||||
await act(async () => { finish('data:image/png;base64,BACKDROP') })
|
||||
expect(screen.getByTestId('workspace-browser-backdrop')).toHaveAttribute('src', 'data:image/png;base64,BACKDROP')
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
expect(host.capture).not.toHaveBeenCalled()
|
||||
|
||||
act(() => { useOverlayStore.getState().pop(true) })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
|
||||
expect(host.create).toHaveBeenCalledTimes(createCount)
|
||||
expect(host.close).not.toHaveBeenCalled()
|
||||
const address = screen.getByTestId('workspace-browser-address')
|
||||
fireEvent.change(address, { target: { value: 'https://elsewhere.test/' } })
|
||||
fireEvent.submit(address.closest('form')!)
|
||||
// Navigating away would discard annotations, so the page asks first.
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(pageShown(tab)).toBe(true)
|
||||
expect(host.setVisible).not.toHaveBeenCalled()
|
||||
expect(host.navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hide or paint a stale capture after the plus menu has already closed', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
let finish!: (url: string) => void
|
||||
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
act(() => { useOverlayStore.getState().push(true) })
|
||||
act(() => { useOverlayStore.getState().pop(true) })
|
||||
host.setVisible.mockClear()
|
||||
await act(async () => { finish('data:image/png;base64,STALE') })
|
||||
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
|
||||
expect(host.setVisible).not.toHaveBeenCalledWith(tab.browserTabId, false)
|
||||
})
|
||||
|
||||
it('keeps menus usable after capture failure without turning it into a page load error', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
host.snapshot.mockRejectedValueOnce(new Error('capture unavailable'))
|
||||
await act(async () => { useOverlayStore.getState().push(true) })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
expect(currentTab(tab.id).loadError).toBeFalsy()
|
||||
act(() => { useOverlayStore.getState().pop(true) })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
})
|
||||
|
||||
it('lets an ordinary modal hide immediately while a snapshot is pending', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
let finish!: (url: string) => void
|
||||
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
act(() => { useOverlayStore.getState().push(true) })
|
||||
act(() => { useOverlayStore.getState().push() })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
await act(async () => { finish('data:image/png;base64,STALE') })
|
||||
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
})
|
||||
|
||||
it('bounds capture waiting so a stalled native snapshot cannot trap the menu', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
let finish!: (url: string) => void
|
||||
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
act(() => { useOverlayStore.getState().push(true) })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(800) })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
await act(async () => { finish('data:image/png;base64,TOO_LATE') })
|
||||
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
|
||||
act(() => { useOverlayStore.getState().pop(true) })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('discards a pending backdrop when the browser tab is deactivated', async () => {
|
||||
const tab = openBrowserTab()
|
||||
const { rerender } = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
let finish!: (url: string) => void
|
||||
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
act(() => { useOverlayStore.getState().push(true) })
|
||||
rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active={false} />)
|
||||
await act(async () => { finish('data:image/png;base64,OTHER_TAB') })
|
||||
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
expect(host.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears an already displayed backdrop when the underlying page navigates', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
await act(async () => { useOverlayStore.getState().push(true) })
|
||||
expect(screen.getByTestId('workspace-browser-backdrop')).toBeInTheDocument()
|
||||
emit({ type: 'state', tabId: tab.browserTabId, navigationId: 2, url: 'https://next.example/', title: '', loading: true, canGoBack: false, canGoForward: false })
|
||||
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
})
|
||||
|
||||
it('attaches the page only while its tab is the active one', async () => {
|
||||
it('draws the page only while its tab is the active one', async () => {
|
||||
const tab = openBrowserTab()
|
||||
const { rerender } = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
expect(pageShown(tab)).toBe(true)
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
|
||||
rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active={false} />)
|
||||
expect(pageShown(tab)).toBe(false)
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
|
||||
rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
expect(pageShown(tab)).toBe(true)
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
expect(host.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides the page while a fullscreen DOM overlay is up', async () => {
|
||||
// A native view always paints above the DOM, so an image modal opened over
|
||||
// the workspace would otherwise be covered by the page.
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
|
||||
act(() => { useOverlayStore.getState().push() })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
|
||||
act(() => { useOverlayStore.getState().pop() })
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
})
|
||||
|
||||
it('hides the page while its own downloads overlay is open', async () => {
|
||||
// Same reason, for the overlays this component draws itself: the downloads
|
||||
// and history sheets are DOM, and the page would paint straight over them.
|
||||
it('replaces the page with its own downloads sheet while that is open', async () => {
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
|
||||
await openMenuItem('downloads')
|
||||
expect(screen.getByTestId('workspace-browser-panel-downloads')).toBeInTheDocument()
|
||||
expect(pageShown(tab)).toBe(false)
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(pageShown(tab)).toBe(true)
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
})
|
||||
|
||||
it('shows the empty state, not a blank page, for a tab without an address', async () => {
|
||||
const tab = openBrowserTab(null)
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
expect(pageShown(tab)).toBe(false)
|
||||
expect(document.querySelectorAll('webview')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation controls', () => {
|
||||
@@ -1123,7 +1034,6 @@ describe('browser address suggestions', () => {
|
||||
expect(options[0]).toHaveTextContent('ChatCut latest')
|
||||
expect(options[1]).toHaveTextContent('Chain Sheet')
|
||||
expect(screen.getByTestId('workspace-browser-address')).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(useOverlayStore.getState().snapshotCount).toBe(1)
|
||||
})
|
||||
|
||||
it('filters by title and URL, starts with search, and uses ArrowDown plus Enter to open a history item', async () => {
|
||||
@@ -1154,7 +1064,6 @@ describe('browser address suggestions', () => {
|
||||
fireEvent.pointerDown(option)
|
||||
fireEvent.click(option)
|
||||
expect(host.navigate).toHaveBeenCalledWith(tab.browserTabId, 'https://chain.test/')
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
})
|
||||
|
||||
it('submits the search row but lets IME composition finish first', async () => {
|
||||
@@ -1169,20 +1078,22 @@ describe('browser address suggestions', () => {
|
||||
expect(host.navigate).toHaveBeenCalledWith(tab.browserTabId, 'https://www.google.com/search?q=%E7%95%8C%E9%9D%A2%E8%AE%BE%E8%AE%A1')
|
||||
})
|
||||
|
||||
it('keeps the native page behind a presentation snapshot while suggestions are open and restores it on Escape', async () => {
|
||||
it('drops its suggestions over the live page and closes them on Escape without touching the page', async () => {
|
||||
seedVisits()
|
||||
const tab = openBrowserTab()
|
||||
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
|
||||
host.setVisible.mockClear()
|
||||
const address = screen.getByTestId('workspace-browser-address')
|
||||
act(() => address.focus())
|
||||
await waitFor(() => expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false))
|
||||
expect(host.snapshot).toHaveBeenCalledWith(tab.browserTabId)
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
// The list paints over the page; the page itself never had to move.
|
||||
expect(pageShown(tab)).toBe(true)
|
||||
fireEvent.change(address, { target: { value: 'unfinished' } })
|
||||
fireEvent.keyDown(address, { key: 'Escape' })
|
||||
expect(address).toHaveValue(tab.url)
|
||||
expect(screen.queryByRole('listbox')).toBeNull()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
expect(pageShown(tab)).toBe(true)
|
||||
expect(host.setVisible).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -18,7 +17,6 @@ import { WorkspaceBrowserAddressBar } from '@/components/workbench/WorkspaceBrow
|
||||
import { WorkspaceBrowserSelectionBar } from '@/components/workbench/WorkspaceBrowserSelectionBar'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { computeWebviewBounds } from '../browser/computeWebviewBounds'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
||||
import { formatBytes } from '../../lib/formatBytes'
|
||||
@@ -28,7 +26,11 @@ import {
|
||||
isWorkspaceBrowserAvailable,
|
||||
workspaceBrowserHost,
|
||||
} from '../../lib/workspace/browserHost'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import {
|
||||
ensureWorkspaceBrowserGuest,
|
||||
isWorkspaceBrowserGuestRegistered,
|
||||
placeWorkspaceBrowserGuest,
|
||||
} from '../../lib/workspace/browserGuests'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import { useWorkspaceBrowserStore } from '../../stores/workspaceBrowserStore'
|
||||
@@ -43,7 +45,6 @@ import type { WorkspaceBrowserTab as WorkspaceBrowserTabModel } from '../../lib/
|
||||
const MIN_ZOOM = MIN_APP_ZOOM
|
||||
const MAX_ZOOM = MAX_APP_ZOOM
|
||||
const ZOOM_STEP = 0.1
|
||||
const PRESENTATION_SNAPSHOT_TIMEOUT_MS = 800
|
||||
|
||||
type BrowserPanel = 'downloads' | 'history' | null
|
||||
|
||||
@@ -69,10 +70,11 @@ function resolveNavigationUrl(input: string, sessionId: string): string {
|
||||
/**
|
||||
* One page, addressed by its own `browserTabId`.
|
||||
*
|
||||
* React here only decides *where the page is drawn*. Creating it, navigating it
|
||||
* and destroying it are the controller's and the host's business — which is why
|
||||
* unmounting this component (hiding the panel, switching tab, switching task)
|
||||
* hides the view and nothing more.
|
||||
* React here only decides *where the page is drawn*: the stage below is the
|
||||
* placeholder the page's `<webview>` is positioned over. Creating it,
|
||||
* navigating it and destroying it are the controller's and the host's business
|
||||
* — which is why unmounting this component (hiding the panel, switching tab,
|
||||
* switching task) parks the page and nothing more.
|
||||
*/
|
||||
export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowserTabProps) {
|
||||
const t = useTranslation()
|
||||
@@ -91,20 +93,19 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const appZoom = useSettingsStore((state) => state.uiZoom)
|
||||
const theme = useUIStore((state) => state.theme)
|
||||
const overlayCount = useOverlayStore((state) => state.count)
|
||||
const snapshotOverlayCount = useOverlayStore((state) => state.snapshotCount)
|
||||
const nativePresentedRef = useRef(false)
|
||||
const [presentationSnapshot, setPresentationSnapshot] = useState<string | null>(null)
|
||||
const available = useMemo(() => isWorkspaceBrowserAvailable(), [])
|
||||
const browserTabId = tab.browserTabId
|
||||
const [registeredId, setRegisteredId] = useState<string | null>(null)
|
||||
// Bumped when this page's registration settles; readiness itself is read
|
||||
// from the guest registry, which also knows when a page was lost.
|
||||
const [, setRegistrationSettled] = useState(0)
|
||||
const [createAttempt, setCreateAttempt] = useState(0)
|
||||
const lifetimeRef = useRef<{ id: string; ready: boolean; cancelled: boolean } | null>(null)
|
||||
const navigationRequestRef = useRef(0)
|
||||
const appZoomRef = useRef(appZoom)
|
||||
appZoomRef.current = appZoom
|
||||
const page = useWorkspaceBrowserStore((state) => state.pageByTabId[browserTabId])
|
||||
const ready = page?.registered === true || registeredId === browserTabId
|
||||
// Only a page the host adopted can take commands. A page registered by an
|
||||
// earlier mount is ready at once, so re-activating a tab does not flash a
|
||||
// disabled toolbar.
|
||||
const ready = isWorkspaceBrowserGuestRegistered(browserTabId)
|
||||
const initialAddressFocusRef = useRef({ browserTabId, pending: !tab.url && !ready, activated: false })
|
||||
if (initialAddressFocusRef.current.browserTabId !== browserTabId) {
|
||||
initialAddressFocusRef.current = { browserTabId, pending: !tab.url && !ready, activated: false }
|
||||
@@ -116,7 +117,7 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
|
||||
const visits = useMemo(() => Object.values(historyByTabId).flatMap((entries) => entries ?? []), [historyByTabId])
|
||||
const downloads = useWorkspaceBrowserStore((state) => state.downloads)
|
||||
const loading = page?.loading ?? false
|
||||
menuAllowedRef.current = active && overlayCount === 0 && !pendingNavigation
|
||||
menuAllowedRef.current = active && !pendingNavigation
|
||||
|
||||
const currentAddress = page?.url || tab.url || ''
|
||||
const annotationActive = page?.annotationActive ?? false
|
||||
@@ -151,13 +152,13 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
|
||||
menuRequestRef.current = null
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}, [active, overlayCount, pendingNavigation])
|
||||
}, [active, pendingNavigation])
|
||||
|
||||
const stillOwned = useCallback(() => useWorkspaceStore.getState().findBrowserTabOwner(browserTabId)?.sessionId === sessionId, [browserTabId, sessionId])
|
||||
const canCommand = useCallback(() => {
|
||||
const lifetime = lifetimeRef.current
|
||||
return lifetime?.id === browserTabId && !lifetime.cancelled && stillOwned() &&
|
||||
(lifetime.ready || useWorkspaceBrowserStore.getState().pageByTabId[browserTabId]?.registered === true)
|
||||
(lifetime.ready || isWorkspaceBrowserGuestRegistered(browserTabId))
|
||||
}, [browserTabId, stillOwned])
|
||||
const reportHostError = useCallback((error: unknown) => {
|
||||
const lifetime = lifetimeRef.current
|
||||
@@ -180,18 +181,9 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
|
||||
reportHostError(error)
|
||||
})
|
||||
}
|
||||
const reportBounds = useCallback(() => {
|
||||
if (!canCommand()) return
|
||||
const element = stageRef.current
|
||||
if (!element) return
|
||||
void workspaceBrowserHost.setBounds(
|
||||
browserTabId,
|
||||
computeWebviewBounds(element.getBoundingClientRect(), appZoomRef.current),
|
||||
).catch(reportHostError)
|
||||
}, [browserTabId, canCommand, reportHostError])
|
||||
|
||||
// Create once per page identity. `storageId` travels with it so a restored
|
||||
// tab reopens the same page rather than a blank one.
|
||||
// tab reopens the same page rather than a blank one. A page that already
|
||||
// exists is only re-registered, never re-navigated.
|
||||
useEffect(() => {
|
||||
if (!available || !stillOwned()) return
|
||||
menuRequestRef.current = null
|
||||
@@ -200,32 +192,25 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
|
||||
const lifetime = { id: browserTabId, ready: false, cancelled: false }
|
||||
lifetimeRef.current = lifetime
|
||||
const request = ++navigationRequestRef.current
|
||||
const navigationId = useWorkspaceBrowserStore.getState().pageByTabId[browserTabId]?.navigationId ?? 0
|
||||
const element = stageRef.current
|
||||
void workspaceBrowserHost.create(browserTabId, {
|
||||
storageId: tab.storageId,
|
||||
// A delayed completion must never attach an abandoned tab over its replacement.
|
||||
visible: false,
|
||||
...(tab.url ? { url: tab.url } : {}),
|
||||
...(element
|
||||
? { bounds: computeWebviewBounds(element.getBoundingClientRect(), appZoom) }
|
||||
: {}),
|
||||
}).then((result) => {
|
||||
if (lifetime.cancelled || !stillOwned() || !result.ok) return
|
||||
void ensureWorkspaceBrowserGuest(browserTabId, async (webContentsId) => {
|
||||
const result = await workspaceBrowserHost.create(browserTabId, {
|
||||
storageId: tab.storageId,
|
||||
webContentsId,
|
||||
...(tab.url ? { url: tab.url } : {}),
|
||||
})
|
||||
if (!result.ok) throw new Error(t('workspace.browser.unavailableTitle'))
|
||||
}).then(() => {
|
||||
if (lifetime.cancelled || !stillOwned()) return
|
||||
lifetime.ready = true
|
||||
setRegisteredId(browserTabId)
|
||||
reportBounds()
|
||||
setRegistrationSettled((count) => count + 1)
|
||||
}).catch((error: unknown) => {
|
||||
if (lifetime.cancelled || !stillOwned() || request !== navigationRequestRef.current) return
|
||||
const current = useWorkspaceBrowserStore.getState().pageByTabId[browserTabId]
|
||||
// The initial load may reject after the user has already navigated again.
|
||||
if (current && (current.navigationId > navigationId + 1 || current.navigationOutcome === 'succeeded')) return
|
||||
reportHostError(error)
|
||||
})
|
||||
// Deliberately NOT closing on unmount: the page belongs to the tab, and the
|
||||
// tab outlives this component. `closeTab` is the only thing that ends it.
|
||||
return () => {
|
||||
const registered = lifetime.ready || useWorkspaceBrowserStore.getState().pageByTabId[browserTabId]?.registered === true
|
||||
const registered = lifetime.ready || isWorkspaceBrowserGuestRegistered(browserTabId)
|
||||
lifetime.cancelled = true
|
||||
if (registered && stillOwned()) {
|
||||
void workspaceBrowserHost.setVisible(browserTabId, false).catch((error: unknown) => {
|
||||
@@ -235,98 +220,43 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
|
||||
})
|
||||
}
|
||||
}
|
||||
// The URL/bounds are initial inputs, not a reason to recreate a live page.
|
||||
// The URL is an initial input, not a reason to recreate a live page.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [browserTabId, createAttempt])
|
||||
|
||||
/*
|
||||
A `WebContentsView` always paints above the DOM, so "is this page on screen"
|
||||
has to account for everything the app might want to draw over it — and the
|
||||
teardown is load-bearing.
|
||||
The page is a `<webview>` composited with the DOM, so menus, dialogs and
|
||||
other pages draw over it on their own. What remains is deciding when this
|
||||
surface shows it at all — and the teardown is load-bearing.
|
||||
|
||||
Without the cleanup, unmounting leaves the page attached at its last bounds:
|
||||
switching to a file tab, opening the `+` picker, hiding the workspace or
|
||||
switching tasks would each leave a live page floating over whatever replaced
|
||||
it. The surface renders only the active tab, so unmount is the *normal* way
|
||||
a browser tab goes off screen, not an edge case.
|
||||
The surface renders only the active tab, so unmount is the *normal* way a
|
||||
browser tab goes off screen: switching to a file tab, hiding the workspace
|
||||
or switching tasks. Releasing the placement parks the page (it keeps its
|
||||
state); without that it would stay drawn over whatever replaced it.
|
||||
|
||||
Full-page panels and the retry overlay still replace the guest. The toolbar
|
||||
menu uses the host's native popup layer, so opening it must not detach or
|
||||
hide the live page.
|
||||
Full-page panels and the retry overlay replace the page, and a tab with no
|
||||
address shows its empty state instead.
|
||||
*/
|
||||
const pageCanBePresented = active &&
|
||||
const pageVisible = active &&
|
||||
panel === null &&
|
||||
!pendingNavigation &&
|
||||
!tab.loadError &&
|
||||
Boolean(tab.url)
|
||||
const pageVisible = pageCanBePresented && overlayCount === 0
|
||||
const snapshotRequested = pageCanBePresented && overlayCount > 0 && overlayCount === snapshotOverlayCount
|
||||
const navigationId = page?.navigationId ?? 0
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!available || !stage) return
|
||||
return placeWorkspaceBrowserGuest(browserTabId, stage, pageVisible && ready)
|
||||
}, [available, browserTabId, pageVisible, ready])
|
||||
|
||||
// Only the shown page acts as browser chrome in the host (shortcuts, the zoom
|
||||
// capsule), and a native menu of a page that goes away has to close with it.
|
||||
useLayoutEffect(() => {
|
||||
if (!canCommand()) return
|
||||
let cancelled = false
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
const present = (visible: boolean) => {
|
||||
nativePresentedRef.current = visible
|
||||
void workspaceBrowserHost.setVisible(browserTabId, visible).catch(reportHostError)
|
||||
}
|
||||
if (snapshotRequested && nativePresentedRef.current) {
|
||||
// Capture while the native page is still attached. Its WebContentsView
|
||||
// would otherwise cover a DOM menu, but detaching first can capture an
|
||||
// empty frame. This image is presentation-only and never becomes a chat
|
||||
// attachment. Ordinary modal overlays still hide immediately below.
|
||||
const finish = (dataUrl: string | null) => {
|
||||
if (cancelled || settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
// Commit the already-decoded image before the IPC detaches the view.
|
||||
flushSync(() => setPresentationSnapshot(dataUrl))
|
||||
present(false)
|
||||
}
|
||||
timeout = setTimeout(() => finish(null), PRESENTATION_SNAPSHOT_TIMEOUT_MS)
|
||||
void workspaceBrowserHost.snapshot(browserTabId).then(async (dataUrl) => {
|
||||
if (cancelled || settled) return
|
||||
if (!dataUrl?.startsWith('data:image/png;base64,')) { finish(null); return }
|
||||
const image = new Image()
|
||||
image.src = dataUrl
|
||||
if (image.decode) await image.decode()
|
||||
finish(dataUrl)
|
||||
}).catch(() => finish(null))
|
||||
} else {
|
||||
setPresentationSnapshot(null)
|
||||
present(pageVisible)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
// Navigation invalidates an image even when the page identity is reused.
|
||||
}, [browserTabId, canCommand, navigationId, pageVisible, ready, reportHostError, snapshotRequested])
|
||||
void workspaceBrowserHost.setVisible(browserTabId, pageVisible).catch(reportHostError)
|
||||
}, [browserTabId, canCommand, pageVisible, ready, reportHostError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !ready) return
|
||||
const element = stageRef.current
|
||||
if (!element) return
|
||||
let cancelled = false
|
||||
const update = () => { if (!cancelled) reportBounds() }
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(element)
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
cancelled = true
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [active, ready, reportBounds])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (active) reportBounds()
|
||||
}, [active, appZoom, ready, reportBounds])
|
||||
|
||||
// The capsule must be drawn inside the native page, not behind it in React.
|
||||
// The host retains this configuration and replays it after each navigation.
|
||||
// The capsule is drawn inside the page itself, so it scrolls and zooms with
|
||||
// it. The host retains this configuration and replays it after each navigation.
|
||||
useEffect(() => {
|
||||
if (!ready || !available || !active || !canCommand()) return
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
@@ -635,17 +565,7 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
|
||||
) : null}
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden" data-testid="workspace-browser-stage">
|
||||
<div ref={stageRef} className="absolute inset-0" />
|
||||
{presentationSnapshot ? (
|
||||
<img
|
||||
data-testid="workspace-browser-backdrop"
|
||||
src={presentationSnapshot}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
|
||||
/>
|
||||
) : null}
|
||||
<div ref={stageRef} data-testid="workspace-browser-placeholder" className="absolute inset-0" />
|
||||
{!tab.url && !tab.loadError ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-center">
|
||||
<Globe size={32} strokeWidth={1.75} aria-hidden="true" className="mb-2 text-[var(--color-text-tertiary)]" />
|
||||
|
||||
@@ -12,10 +12,8 @@ const mocks = vi.hoisted(() => ({
|
||||
workspaceBrowserHost: {
|
||||
create: vi.fn(async () => ({ ok: true })),
|
||||
setVisible: vi.fn(async () => ({ ok: true })),
|
||||
setBounds: vi.fn(async () => ({ ok: true })),
|
||||
close: vi.fn(async () => ({ ok: true })),
|
||||
message: vi.fn(async () => ({ ok: true })),
|
||||
snapshot: vi.fn(async (): Promise<string | null> => null),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -67,6 +65,7 @@ import { WorkspaceSurface, useWorkspaceBrowserEventBridge } from './WorkspaceSur
|
||||
import { WorkspaceHeaderProvider } from '../layout/WorkspaceHeaderContext'
|
||||
import { TabBar } from '../layout/TabBar'
|
||||
import type { WorkspaceBrowserTab } from '../../lib/workspace/types'
|
||||
import { installFakeBrowserGuests } from '../../test/fakeBrowserGuests'
|
||||
|
||||
const SESSION = 'session-a'
|
||||
|
||||
@@ -84,10 +83,14 @@ beforeEach(() => {
|
||||
mocks.releaseWorkspaceBrowserTab.mockClear()
|
||||
mocks.isWorkspaceBrowserAvailable.mockReturnValue(false)
|
||||
Object.values(mocks.workspaceBrowserHost).forEach(mock => mock.mockClear())
|
||||
disposeGuests = installFakeBrowserGuests()
|
||||
})
|
||||
|
||||
let disposeGuests: () => void
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
disposeGuests()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
@@ -116,6 +119,32 @@ it('reveals successful navigation and ignores old failures and stopped error doc
|
||||
expect(useWorkspaceStore.getState().getTab(SESSION, tabId)).toMatchObject({ loadError: null, url: 'https://good.test/' })
|
||||
})
|
||||
|
||||
it('reloads a crashed page in place but rebuilds a page whose guest was lost', async () => {
|
||||
mocks.isWorkspaceBrowserAvailable.mockReturnValue(true)
|
||||
const tabId = useWorkspaceStore.getState().openTarget(SESSION, { kind: 'browser', url: 'https://lost.test/' })!
|
||||
const browserTabId = (useWorkspaceStore.getState().getTab(SESSION, tabId) as WorkspaceBrowserTab).browserTabId
|
||||
renderSurface()
|
||||
await waitFor(() => expect(screen.getByTestId('workspace-browser-address')).toBeEnabled())
|
||||
renderHook(() => useWorkspaceBrowserEventBridge(true))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const emitEvent = (event: unknown) => act(() => { mocks.subscribeWorkspaceBrowserEvents.mock.calls[0] })
|
||||
const [firstGuest] = Array.from(document.querySelectorAll('webview'))
|
||||
|
||||
// A crashed guest still exists: retrying reloads it.
|
||||
emitEvent({ type: 'destroyed', tabId: browserTabId, reason: 'crashed' })
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
expect(firstGuest!.isConnected).toBe(true)
|
||||
|
||||
// A lost guest is gone: retrying must build and register a new page.
|
||||
emitEvent({ type: 'destroyed', tabId: browserTabId, reason: 'closed' })
|
||||
expect(firstGuest!.isConnected).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
await waitFor(() => expect(mocks.workspaceBrowserHost.create).toHaveBeenCalledTimes(2))
|
||||
const [first, second] = mocks.workspaceBrowserHost.create.mock.calls as unknown as Array<[string, { webContentsId: number, url?: string }]>
|
||||
expect(second![1].webContentsId).not.toBe(first![1].webContentsId)
|
||||
expect(second![1].url).toBe('https://lost.test/')
|
||||
})
|
||||
|
||||
it('updates downloads after close without resurrecting page state', async () => {
|
||||
const tabId = useWorkspaceStore.getState().openTarget(SESSION, { kind: 'browser' })!
|
||||
const browserTabId = (useWorkspaceStore.getState().getTab(SESSION, tabId) as WorkspaceBrowserTab).browserTabId
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useWorkspaceBrowserStore } from '../../stores/workspaceBrowserStore'
|
||||
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
|
||||
import { openWorkspaceTarget, workspaceOpen } from '../../lib/workspace/openTarget'
|
||||
import { subscribeWorkspaceBrowserEvents } from '../../lib/workspace/browserHost'
|
||||
import { forgetLostWorkspaceBrowserGuest } from '../../lib/workspace/browserGuests'
|
||||
import type { WorkspaceCloseScope, WorkspaceDock, WorkspaceTabKind } from '../../lib/workspace/types'
|
||||
import { WorkspaceTabStrip } from './WorkspaceTabStrip'
|
||||
import { WorkspaceLauncher } from './WorkspaceLauncher'
|
||||
@@ -335,6 +336,9 @@ export function useWorkspaceBrowserEventBridge(enabled: boolean) {
|
||||
})
|
||||
break
|
||||
case 'destroyed':
|
||||
// A crashed guest still exists and reloads in place. A closed one is
|
||||
// gone, so its retry has to build a new page.
|
||||
if (event.reason === 'closed') forgetLostWorkspaceBrowserGuest(event.tabId)
|
||||
store.updateBrowserTab(owner.sessionId, event.tabId, {
|
||||
// A reason code is not a message. It reaches the error overlay, so
|
||||
// it has to be a translated sentence in all five languages.
|
||||
|
||||
@@ -214,3 +214,36 @@ describe('useDismissable', () => {
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDismissable with embedded pages', () => {
|
||||
// A page in a `<webview>` never reports its pointer events to the app's
|
||||
// document; clicking into it only blurs the window and moves focus onto the
|
||||
// element. That is the outside click a menu over the workspace browser sees.
|
||||
function focusEmbedded(element: HTMLElement) {
|
||||
element.tabIndex = -1
|
||||
element.focus()
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
}
|
||||
|
||||
it('dismisses when focus moves into an embedded page outside the overlay', async () => {
|
||||
const onDismiss = vi.fn()
|
||||
render(<Harness onDismiss={onDismiss} />)
|
||||
const page = document.createElement('webview')
|
||||
document.body.appendChild(page)
|
||||
focusEmbedded(page)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(onDismiss).toHaveBeenCalledWith('outside')
|
||||
page.remove()
|
||||
})
|
||||
|
||||
it('stays open when the window blurs for another app or for a frame inside the overlay', async () => {
|
||||
const onDismiss = vi.fn()
|
||||
render(<Harness onDismiss={onDismiss} />)
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
const frame = document.createElement('iframe')
|
||||
screen.getByTestId('panel').appendChild(frame)
|
||||
focusEmbedded(frame)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,7 +97,25 @@ export function useDismissable({
|
||||
}
|
||||
const handleResize = () => onDismiss('resize')
|
||||
|
||||
// A page inside a `<webview>` or `<iframe>` never reports its pointer
|
||||
// events to this document: clicking into it only takes focus away from
|
||||
// the window. Focus landing in embedded content outside the overlay is
|
||||
// that same outside click. Read after the blur settles — Chromium blurs
|
||||
// the window before it moves `activeElement` onto the frame.
|
||||
let blurTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const handleWindowBlur = () => {
|
||||
clearTimeout(blurTimer)
|
||||
blurTimer = setTimeout(() => {
|
||||
const active = document.activeElement
|
||||
if (!active || (active.tagName !== 'WEBVIEW' && active.tagName !== 'IFRAME')) return
|
||||
if (refs.some((ref) => ref.current?.contains(active))) return
|
||||
if (triggerRef?.current?.contains(active)) return
|
||||
onDismiss('outside')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
document.addEventListener(event, handlePointer, capture)
|
||||
window.addEventListener('blur', handleWindowBlur)
|
||||
if (closeOnEscape) document.addEventListener('keydown', handleKey, stopEscapePropagation)
|
||||
if (closeOnViewportChange) {
|
||||
// Capture on scroll: scroll events from nested containers do not bubble.
|
||||
@@ -106,6 +124,8 @@ export function useDismissable({
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(blurTimer)
|
||||
window.removeEventListener('blur', handleWindowBlur)
|
||||
document.removeEventListener(event, handlePointer, capture)
|
||||
document.removeEventListener('keydown', handleKey, stopEscapePropagation)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
|
||||
@@ -327,13 +327,11 @@ export const browserHost: DesktopHost = {
|
||||
async goForward() {},
|
||||
async reload() {},
|
||||
async stop() {},
|
||||
async setBounds() {},
|
||||
async setVisible() {},
|
||||
async setZoom() {},
|
||||
async find() {},
|
||||
async stopFind() {},
|
||||
async capture() {},
|
||||
async snapshot() { return null },
|
||||
async message() {},
|
||||
async printToPdf() {},
|
||||
async showMenu() { return null },
|
||||
|
||||
@@ -72,19 +72,17 @@ describe('desktop host contract', () => {
|
||||
// externally" fallback, so a rejection here would only ever surface as an
|
||||
// unhandled error behind that fallback.
|
||||
expect(browserHost.capabilities.workspaceBrowser).toBe(false)
|
||||
await expect(browserHost.browser.create('wb-1', { storageId: 'wsb-1' })).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.create('wb-1', { storageId: 'wsb-1', webContentsId: 7 })).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.navigate('wb-1', 'https://example.com')).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.goBack('wb-1')).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.goForward('wb-1')).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.reload('wb-1', { ignoreCache: true })).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.stop('wb-1')).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.setBounds('wb-1', { x: 0, y: 0, width: 10, height: 10 })).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.setVisible('wb-1', false)).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.setZoom('wb-1', 1.25)).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.find('wb-1', 'invoice')).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.stopFind('wb-1')).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.capture('wb-1', 'viewport')).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.snapshot('wb-1')).resolves.toBeNull()
|
||||
await expect(browserHost.browser.message('wb-1', { v: 1, type: 'exit-picker' })).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.printToPdf('wb-1')).resolves.toBeUndefined()
|
||||
await expect(browserHost.browser.showMenu('wb-1', {
|
||||
|
||||
@@ -231,15 +231,13 @@ describe('electron desktop host', () => {
|
||||
await host.browser.create('wb-1', {
|
||||
storageId: 'wsb-1',
|
||||
url: 'https://example.com',
|
||||
bounds: { x: 0, y: 40, width: 800, height: 600 },
|
||||
visible: false,
|
||||
webContentsId: 7,
|
||||
})
|
||||
await host.browser.navigate('wb-1', 'https://example.com/next')
|
||||
await host.browser.goBack('wb-1')
|
||||
await host.browser.goForward('wb-1')
|
||||
await host.browser.reload('wb-1', { ignoreCache: true })
|
||||
await host.browser.stop('wb-1')
|
||||
await host.browser.setBounds('wb-1', { x: 1, y: 2, width: 3, height: 4 })
|
||||
await host.browser.setVisible('wb-1', false)
|
||||
await host.browser.setZoom('wb-1', 1.25)
|
||||
await host.browser.find('wb-1', 'invoice', { matchCase: true })
|
||||
@@ -254,16 +252,14 @@ describe('electron desktop host', () => {
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserCreate, {
|
||||
tabId: 'wb-1',
|
||||
storageId: 'wsb-1',
|
||||
webContentsId: 7,
|
||||
url: 'https://example.com',
|
||||
bounds: { x: 0, y: 40, width: 800, height: 600 },
|
||||
visible: false,
|
||||
}],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserNavigate, { tabId: 'wb-1', url: 'https://example.com/next' }],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserGoBack, { tabId: 'wb-1' }],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserGoForward, { tabId: 'wb-1' }],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserReload, { tabId: 'wb-1', ignoreCache: true }],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserStop, { tabId: 'wb-1' }],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetBounds, { tabId: 'wb-1', bounds: { x: 1, y: 2, width: 3, height: 4 } }],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetVisible, { tabId: 'wb-1', visible: false }],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetZoom, { tabId: 'wb-1', factor: 1.25 }],
|
||||
[ELECTRON_IPC_CHANNELS.workspaceBrowserFind, { tabId: 'wb-1', text: 'invoice', options: { matchCase: true } }],
|
||||
@@ -282,13 +278,12 @@ describe('electron desktop host', () => {
|
||||
expect(host.capabilities.workspaceBrowser).toBe(true)
|
||||
})
|
||||
|
||||
it('returns a presentation snapshot through its own addressed IPC without requesting a chat capture', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue('data:image/png;base64,BACKDROP')
|
||||
it('refuses to register a page without the guest it names', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(undefined)
|
||||
const host = createElectronHost({ invoke, subscribe: vi.fn() })
|
||||
await expect(host.browser.snapshot('wb-1')).resolves.toBe('data:image/png;base64,BACKDROP')
|
||||
expect(invoke.mock.calls).toEqual([[ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot, { tabId: 'wb-1' }]])
|
||||
await expect(host.browser.snapshot('')).rejects.toThrow('Invalid Electron IPC payload')
|
||||
expect(invoke).toHaveBeenCalledTimes(1)
|
||||
await expect(host.browser.create('wb-1', { storageId: 'wsb-1', webContentsId: 0 }))
|
||||
.rejects.toThrow('Invalid Electron IPC payload')
|
||||
expect(invoke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an unaddressed browser call before it reaches Electron IPC', async () => {
|
||||
|
||||
@@ -202,9 +202,8 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
||||
create: (tabId, options) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserCreate, {
|
||||
tabId,
|
||||
storageId: options.storageId,
|
||||
webContentsId: options.webContentsId,
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
...(options.bounds === undefined ? {} : { bounds: options.bounds }),
|
||||
...(options.visible === undefined ? {} : { visible: options.visible }),
|
||||
}),
|
||||
navigate: (tabId, url) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserNavigate, { tabId, url }),
|
||||
goBack: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserGoBack, { tabId }),
|
||||
@@ -214,7 +213,6 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
||||
...(options?.ignoreCache === undefined ? {} : { ignoreCache: options.ignoreCache }),
|
||||
}),
|
||||
stop: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserStop, { tabId }),
|
||||
setBounds: (tabId, bounds) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserSetBounds, { tabId, bounds }),
|
||||
setVisible: (tabId, visible) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserSetVisible, { tabId, visible }),
|
||||
setZoom: (tabId, factor) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserSetZoom, { tabId, factor }),
|
||||
find: (tabId, text, options) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserFind, {
|
||||
@@ -224,7 +222,6 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
||||
}),
|
||||
stopFind: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserStopFind, { tabId }),
|
||||
capture: (tabId, kind) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserCapture, { tabId, kind }),
|
||||
snapshot: (tabId) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot, { tabId }),
|
||||
message: (tabId, payload) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserMessage, { tabId, payload }),
|
||||
printToPdf: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserPrintToPdf, { tabId }),
|
||||
close: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserClose, { tabId }),
|
||||
|
||||
@@ -194,8 +194,6 @@ export type PreviewHostMessage = PreviewCaptureMessage | PreviewPickerMessage |
|
||||
* Multi-page browser host. Every method names its page, so a second page never
|
||||
* navigates the first and unmounting a React surface never destroys a page.
|
||||
*/
|
||||
export type WorkspaceBrowserBounds = PreviewBounds
|
||||
|
||||
export type WorkspaceBrowserCaptureKind = 'full' | 'viewport'
|
||||
|
||||
export type WorkspaceBrowserMenuAction =
|
||||
@@ -524,23 +522,21 @@ export type DesktopHost = {
|
||||
}
|
||||
browser: {
|
||||
showMenu(tabId: string, options: WorkspaceBrowserMenuOptions): Promise<WorkspaceBrowserMenuAction | null>
|
||||
/** Registers the page's `<webview>` guest with the host and starts `url`. */
|
||||
create(
|
||||
tabId: string,
|
||||
options: { storageId: string; url?: string; bounds?: WorkspaceBrowserBounds; visible?: boolean },
|
||||
options: { storageId: string; url?: string; webContentsId: number },
|
||||
): Promise<void>
|
||||
navigate(tabId: string, url: string): Promise<void>
|
||||
goBack(tabId: string): Promise<void>
|
||||
goForward(tabId: string): Promise<void>
|
||||
reload(tabId: string, options?: { ignoreCache?: boolean }): Promise<void>
|
||||
stop(tabId: string): Promise<void>
|
||||
setBounds(tabId: string, bounds: WorkspaceBrowserBounds): Promise<void>
|
||||
setVisible(tabId: string, visible: boolean): Promise<void>
|
||||
setZoom(tabId: string, factor: number): Promise<void>
|
||||
find(tabId: string, text: string, options?: WorkspaceBrowserFindOptions): Promise<void>
|
||||
stopFind(tabId: string): Promise<void>
|
||||
capture(tabId: string, kind: WorkspaceBrowserCaptureKind): Promise<void>
|
||||
/** Read-only presentation backdrop, with no screenshot or composer event. */
|
||||
snapshot(tabId: string): Promise<string | null>
|
||||
message(tabId: string, payload: PreviewHostMessage): Promise<void>
|
||||
printToPdf(tabId: string): Promise<void>
|
||||
close(tabId: string): Promise<void>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shared between the renderer, which creates each page's `<webview>`, and the
|
||||
* Electron host, which only adopts guests that match this contract.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One persistent partition for every workspace browser page of this user.
|
||||
*
|
||||
* Codex does the same with `persist:codex-browser-app`: a login performed in
|
||||
* one tab has to be there in the next one. The per-tab `storageId` is a *page
|
||||
* restore identity* — which page to reopen after a restart — and must never be
|
||||
* turned into a partition name, or every tab would get its own cookie jar.
|
||||
*/
|
||||
export const WORKSPACE_BROWSER_PARTITION = 'persist:cc-haha-browser-app'
|
||||
|
||||
/**
|
||||
* A `<webview>` creates no guest until it has a `src`. The renderer always
|
||||
* starts from this document and the host performs every real navigation, so a
|
||||
* renderer never chooses what a page loads by writing an attribute.
|
||||
*/
|
||||
export const WORKSPACE_BROWSER_INITIAL_SRC = 'about:blank'
|
||||
@@ -0,0 +1,282 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Registry = typeof import('./browserGuests')
|
||||
|
||||
// The registry is module state on purpose (pages outlive every component), so
|
||||
// each test gets a fresh copy.
|
||||
let registry: Registry
|
||||
let layer: HTMLDivElement
|
||||
let nextId = 40
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
registry = await import('./browserGuests')
|
||||
layer = document.createElement('div')
|
||||
document.body.appendChild(layer)
|
||||
rect(layer, { left: 100, top: 50, width: 1000, height: 800 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
registry.setWorkspaceBrowserGuestLayer(null)
|
||||
document.body.innerHTML = ''
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function rect(element: HTMLElement, box: { left: number, top: number, width: number, height: number }) {
|
||||
element.getBoundingClientRect = () => ({
|
||||
...box, x: box.left, y: box.top, right: box.left + box.width, bottom: box.top + box.height, toJSON: () => box,
|
||||
}) as DOMRect
|
||||
}
|
||||
|
||||
function webviews(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('webview'))
|
||||
}
|
||||
|
||||
/** What Electron does once a guest exists: it becomes addressable, then ready. */
|
||||
function attach(webview: HTMLElement, id = nextId++): number {
|
||||
Object.assign(webview, { getWebContentsId: () => id })
|
||||
webview.dispatchEvent(new Event('dom-ready'))
|
||||
return id
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('workspace browser guests', () => {
|
||||
it('creates one blank, partitioned webview per page and registers the guest it got', async () => {
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const register = vi.fn(async (_id: number) => {})
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', register)
|
||||
await settle()
|
||||
|
||||
const [webview] = webviews()
|
||||
expect(webview).toBeDefined()
|
||||
// Exactly what the host's attach policy admits; anything else is denied.
|
||||
expect(webview!.getAttribute('partition')).toBe('persist:cc-haha-browser-app')
|
||||
expect(webview!.getAttribute('src')).toBe('about:blank')
|
||||
// Without it Chromium drops popups before the host can turn them into tabs.
|
||||
expect(webview!.hasAttribute('allowpopups')).toBe(true)
|
||||
expect(register).not.toHaveBeenCalled()
|
||||
|
||||
const id = attach(webview!)
|
||||
await ready
|
||||
expect(register).toHaveBeenCalledWith(id)
|
||||
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(true)
|
||||
|
||||
// A remounted tab asks again: same page, no second element, no second registration.
|
||||
await registry.ensureWorkspaceBrowserGuest('tab-a', register)
|
||||
expect(webviews()).toEqual([webview])
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads the guest id only once the page is ready, and only the first time', async () => {
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const register = vi.fn(async (_id: number) => {})
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', register)
|
||||
await settle()
|
||||
const [webview] = webviews()
|
||||
attach(webview!, 7)
|
||||
await ready
|
||||
// `dom-ready` repeats on every navigation of the page.
|
||||
attach(webview!, 8)
|
||||
await settle()
|
||||
expect(register.mock.calls).toEqual([[7]])
|
||||
})
|
||||
|
||||
it('creates nothing until the layer exists', async () => {
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
await settle()
|
||||
expect(webviews()).toHaveLength(0)
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
await settle()
|
||||
expect(webviews()).toHaveLength(1)
|
||||
expect(layer.contains(webviews()[0]!)).toBe(true)
|
||||
attach(webviews()[0]!)
|
||||
await ready
|
||||
})
|
||||
|
||||
it('drops a page the host refused, so a retry starts from a fresh element', async () => {
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const first = registry.ensureWorkspaceBrowserGuest('tab-a', async () => { throw new Error('not adopted') })
|
||||
await settle()
|
||||
const refused = webviews()[0]!
|
||||
attach(refused)
|
||||
await expect(first).rejects.toThrow('not adopted')
|
||||
expect(refused.isConnected).toBe(false)
|
||||
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(false)
|
||||
|
||||
const register = vi.fn(async (_id: number) => {})
|
||||
const retry = registry.ensureWorkspaceBrowserGuest('tab-a', register)
|
||||
await settle()
|
||||
const fresh = webviews()[0]!
|
||||
expect(fresh).not.toBe(refused)
|
||||
attach(fresh)
|
||||
await retry
|
||||
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('gives up on a guest that never attaches', async () => {
|
||||
vi.useFakeTimers()
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
const outcome = expect(ready).rejects.toThrow('failed to attach')
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
await outcome
|
||||
expect(webviews()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('draws the page over its placeholder on whole device pixels, and parks it at the same size', async () => {
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const stage = document.createElement('div')
|
||||
document.body.appendChild(stage)
|
||||
rect(stage, { left: 600.3, top: 152.2, width: 480.26, height: 640.74 })
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
await settle()
|
||||
attach(webviews()[0]!)
|
||||
await ready
|
||||
|
||||
const release = registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
|
||||
const slot = webviews()[0]!.parentElement!
|
||||
expect(slot.style).toMatchObject({ left: '500.5px', top: '102px', width: '480.5px', height: '640.5px' })
|
||||
expect(slot.style.visibility).toBe('visible')
|
||||
expect(slot.style.pointerEvents).toBe('auto')
|
||||
|
||||
release()
|
||||
expect(slot.style.visibility).toBe('hidden')
|
||||
expect(slot.style.pointerEvents).toBe('none')
|
||||
// Parking never resizes the page, so hiding it does not reflow it.
|
||||
expect(slot.style.width).toBe('480.5px')
|
||||
expect(webviews()[0]!.isConnected).toBe(true)
|
||||
})
|
||||
|
||||
it('applies a placement made before the page existed, and never moves the element', async () => {
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const first = document.createElement('div')
|
||||
const second = document.createElement('div')
|
||||
document.body.append(first, second)
|
||||
rect(first, { left: 100, top: 50, width: 300, height: 200 })
|
||||
rect(second, { left: 400, top: 60, width: 500, height: 400 })
|
||||
registry.placeWorkspaceBrowserGuest('tab-a', first, true)
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
await settle()
|
||||
const webview = webviews()[0]!
|
||||
const slot = webview.parentElement!
|
||||
expect(slot.style).toMatchObject({ left: '0px', top: '0px', width: '300px', height: '200px', visibility: 'visible' })
|
||||
attach(webview)
|
||||
await ready
|
||||
|
||||
// A webview that changes parents reloads its page; only the slot moves.
|
||||
registry.placeWorkspaceBrowserGuest('tab-a', second, true)
|
||||
expect(webview.parentElement).toBe(slot)
|
||||
expect(slot.parentElement).toBe(layer)
|
||||
expect(slot.style).toMatchObject({ left: '300px', top: '10px', width: '500px', height: '400px' })
|
||||
})
|
||||
|
||||
it('lets a stale release leave a newer placement alone', async () => {
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const stage = document.createElement('div')
|
||||
document.body.appendChild(stage)
|
||||
rect(stage, { left: 100, top: 50, width: 300, height: 200 })
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
await settle()
|
||||
attach(webviews()[0]!)
|
||||
await ready
|
||||
const releaseOld = registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
|
||||
registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
|
||||
releaseOld()
|
||||
expect(webviews()[0]!.parentElement!.style.visibility).toBe('visible')
|
||||
})
|
||||
|
||||
it('follows its placeholder when the placeholder resizes', async () => {
|
||||
const observers: Array<{ callback: () => void, targets: Element[] }> = []
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
targets: Element[] = []
|
||||
constructor(public callback: () => void) { observers.push(this) }
|
||||
observe(target: Element) { this.targets.push(target) }
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
})
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const stage = document.createElement('div')
|
||||
document.body.appendChild(stage)
|
||||
rect(stage, { left: 100, top: 50, width: 300, height: 200 })
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
await settle()
|
||||
attach(webviews()[0]!)
|
||||
await ready
|
||||
registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
|
||||
rect(stage, { left: 100, top: 86, width: 300, height: 164 })
|
||||
observers.find(observer => observer.targets.includes(stage))!.callback()
|
||||
expect(webviews()[0]!.parentElement!.style).toMatchObject({ top: '36px', height: '164px' })
|
||||
})
|
||||
|
||||
it('ends a page by removing its element, and forgets a page the host lost', async () => {
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
for (const tabId of ['closed', 'lost']) {
|
||||
const ready = registry.ensureWorkspaceBrowserGuest(tabId, async () => {})
|
||||
await settle()
|
||||
attach(webviews().at(-1)!)
|
||||
await ready
|
||||
}
|
||||
const [closed, lost] = webviews()
|
||||
registry.disposeWorkspaceBrowserGuest('closed')
|
||||
registry.forgetLostWorkspaceBrowserGuest('lost')
|
||||
expect(closed!.isConnected).toBe(false)
|
||||
expect(lost!.isConnected).toBe(false)
|
||||
expect(registry.isWorkspaceBrowserGuestRegistered('closed')).toBe(false)
|
||||
expect(registry.isWorkspaceBrowserGuestRegistered('lost')).toBe(false)
|
||||
})
|
||||
|
||||
it('rebuilds a page whose element went away with an old layer instead of moving it', async () => {
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
await settle()
|
||||
const old = webviews()[0]!
|
||||
attach(old)
|
||||
await ready
|
||||
layer.remove()
|
||||
const next = document.createElement('div')
|
||||
document.body.appendChild(next)
|
||||
registry.setWorkspaceBrowserGuestLayer(next)
|
||||
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(false)
|
||||
const again = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
await settle()
|
||||
const rebuilt = Array.from(next.querySelectorAll('webview'))[0]!
|
||||
expect(rebuilt).not.toBe(old)
|
||||
attach(rebuilt as HTMLElement)
|
||||
await again
|
||||
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an app pointer gesture from being taken by a page it crosses', async () => {
|
||||
registry.setWorkspaceBrowserGuestLayer(layer)
|
||||
const stage = document.createElement('div')
|
||||
document.body.appendChild(stage)
|
||||
rect(stage, { left: 100, top: 50, width: 300, height: 200 })
|
||||
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
|
||||
await settle()
|
||||
attach(webviews()[0]!)
|
||||
await ready
|
||||
registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
|
||||
const slot = webviews()[0]!.parentElement!
|
||||
|
||||
// A drag that starts in the app (e.g. the panel resize handle).
|
||||
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
expect(slot.style.pointerEvents).toBe('none')
|
||||
expect(slot.style.visibility).toBe('visible')
|
||||
window.dispatchEvent(new Event('pointerup'))
|
||||
expect(slot.style.pointerEvents).toBe('auto')
|
||||
|
||||
// A release outside the window never arrives; a plain move ends it.
|
||||
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
const move = new Event('pointermove', { bubbles: true })
|
||||
Object.defineProperty(move, 'buttons', { value: 0 })
|
||||
document.body.dispatchEvent(move)
|
||||
expect(slot.style.pointerEvents).toBe('auto')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
WORKSPACE_BROWSER_INITIAL_SRC,
|
||||
WORKSPACE_BROWSER_PARTITION,
|
||||
} from './browserGuestContract'
|
||||
|
||||
/**
|
||||
* Renderer side of the workspace browser: one `<webview>` per page.
|
||||
*
|
||||
* Why a `<webview>` and not a native view: a native view always paints above
|
||||
* the DOM, so every menu, dialog and page that should cover it had to hide it
|
||||
* or swap in a screenshot — which flickered, and was missed by every overlay
|
||||
* that did not opt in. A webview is composited with the page like any element.
|
||||
*
|
||||
* Why this module and not React: a webview that leaves the DOM destroys its
|
||||
* page, and one that is *moved* reloads it. React unmounts a tab's surface
|
||||
* whenever the tab, the task or the panel changes, so the element can never
|
||||
* live inside that surface. It lives in a layer that is mounted once, is never
|
||||
* re-parented, and is only positioned over whatever placeholder currently
|
||||
* shows the page. The page's lifetime is `dispose`, nothing else.
|
||||
*/
|
||||
|
||||
const ATTACH_TIMEOUT_MS = 10_000
|
||||
|
||||
type WebviewElement = HTMLElement & { getWebContentsId(): number }
|
||||
|
||||
type GuestRecord = {
|
||||
tabId: string
|
||||
slot: HTMLDivElement
|
||||
webview: WebviewElement
|
||||
webContentsId: number | null
|
||||
attach: Promise<number> | null
|
||||
registration: Promise<void> | null
|
||||
registered: boolean
|
||||
}
|
||||
|
||||
type Placement = {
|
||||
token: object
|
||||
stage: HTMLElement
|
||||
visible: boolean
|
||||
observer: ResizeObserver | null
|
||||
}
|
||||
|
||||
let layer: HTMLElement | null = null
|
||||
let layerObserver: ResizeObserver | null = null
|
||||
let layerWaiters: Array<(element: HTMLElement) => void> = []
|
||||
const guests = new Map<string, GuestRecord>()
|
||||
const placements = new Map<string, Placement>()
|
||||
let gesture = false
|
||||
let gestureListenersInstalled = false
|
||||
|
||||
/**
|
||||
* The layer is rendered once, inside the session panel's stacking context, so
|
||||
* a page paints above the workspace like the placeholder it covers and below
|
||||
* every dropdown, dialog and toast — and fades with the panel when another page
|
||||
* replaces it.
|
||||
*/
|
||||
export function setWorkspaceBrowserGuestLayer(element: HTMLElement | null): void {
|
||||
if (element === layer) return
|
||||
layerObserver?.disconnect()
|
||||
layerObserver = null
|
||||
layer = element
|
||||
// Guests are never moved here: a webview that changes parents reloads.
|
||||
// Pages whose element went away with an old layer are disconnected, which
|
||||
// is how `ensureWorkspaceBrowserGuest` knows to rebuild them; pages still
|
||||
// waiting for a layer attach to this one.
|
||||
if (!element) return
|
||||
installGestureListeners()
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
layerObserver = new ResizeObserver(() => applyAllPlacements())
|
||||
layerObserver.observe(element)
|
||||
}
|
||||
const waiters = layerWaiters
|
||||
layerWaiters = []
|
||||
for (const resolve of waiters) resolve(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the page's webview if it has none and registers it with the host.
|
||||
* Repeated calls for a live page resolve without touching it.
|
||||
*/
|
||||
export function ensureWorkspaceBrowserGuest(
|
||||
tabId: string,
|
||||
register: (webContentsId: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let record = guests.get(tabId)
|
||||
if (record && !record.slot.isConnected) {
|
||||
forget(record)
|
||||
record = undefined
|
||||
}
|
||||
if (record?.registration) return record.registration
|
||||
const current = record ?? createGuest(tabId)
|
||||
const registration = attachGuest(current).then(webContentsId => register(webContentsId)).then(
|
||||
() => {
|
||||
if (guests.get(tabId) === current) current.registered = true
|
||||
},
|
||||
(error: unknown) => {
|
||||
// The host adopted nothing, so nothing addresses this guest. Drop it and
|
||||
// let the next attempt start from a fresh element.
|
||||
if (guests.get(tabId) === current) dispose(current)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
current.registration = registration
|
||||
return registration
|
||||
}
|
||||
|
||||
export function isWorkspaceBrowserGuestRegistered(tabId: string): boolean {
|
||||
const record = guests.get(tabId)
|
||||
return Boolean(record?.registered && record.slot.isConnected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the page over `stage` while `visible`, otherwise parks it. Returns a
|
||||
* release that parks the page only if no later placement replaced this one.
|
||||
* A placement may precede the page's creation; it applies once the page exists.
|
||||
*/
|
||||
export function placeWorkspaceBrowserGuest(
|
||||
tabId: string,
|
||||
stage: HTMLElement,
|
||||
visible: boolean,
|
||||
): () => void {
|
||||
placements.get(tabId)?.observer?.disconnect()
|
||||
const token = {}
|
||||
const observer = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => applyPlacement(tabId))
|
||||
observer?.observe(stage)
|
||||
placements.set(tabId, { token, stage, visible, observer })
|
||||
applyPlacement(tabId)
|
||||
return () => {
|
||||
const placement = placements.get(tabId)
|
||||
if (placement?.token !== token) return
|
||||
placement.observer?.disconnect()
|
||||
placements.delete(tabId)
|
||||
applyPlacement(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the page: removing its element destroys the guest. */
|
||||
export function disposeWorkspaceBrowserGuest(tabId: string): void {
|
||||
const record = guests.get(tabId)
|
||||
if (record) dispose(record)
|
||||
placements.get(tabId)?.observer?.disconnect()
|
||||
placements.delete(tabId)
|
||||
}
|
||||
|
||||
/** The host reported the guest gone; forget it so a retry builds a new one. */
|
||||
export function forgetLostWorkspaceBrowserGuest(tabId: string): void {
|
||||
const record = guests.get(tabId)
|
||||
if (record) dispose(record)
|
||||
}
|
||||
|
||||
function createGuest(tabId: string): GuestRecord {
|
||||
const slot = document.createElement('div')
|
||||
slot.dataset.workspaceBrowserSlot = tabId
|
||||
Object.assign(slot.style, {
|
||||
position: 'absolute',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
overflow: 'hidden',
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
const webview = document.createElement('webview') as WebviewElement
|
||||
// Attributes the host's attach policy requires; it denies anything else.
|
||||
webview.setAttribute('partition', WORKSPACE_BROWSER_PARTITION)
|
||||
// Without it Chromium blocks `window.open` and `target=_blank` before the
|
||||
// host's popup handler can turn them into tabs. The host still denies them.
|
||||
webview.setAttribute('allowpopups', '')
|
||||
webview.setAttribute('src', WORKSPACE_BROWSER_INITIAL_SRC)
|
||||
Object.assign(webview.style, { display: 'flex', width: '100%', height: '100%', border: '0' })
|
||||
slot.appendChild(webview)
|
||||
const record: GuestRecord = {
|
||||
tabId,
|
||||
slot,
|
||||
webview,
|
||||
webContentsId: null,
|
||||
attach: null,
|
||||
registration: null,
|
||||
registered: false,
|
||||
}
|
||||
guests.set(tabId, record)
|
||||
return record
|
||||
}
|
||||
|
||||
function attachGuest(record: GuestRecord): Promise<number> {
|
||||
if (record.webContentsId !== null) return Promise.resolve(record.webContentsId)
|
||||
if (record.attach) return record.attach
|
||||
record.attach = waitForLayer().then(target => new Promise<number>((resolve, reject) => {
|
||||
if (guests.get(record.tabId) !== record) {
|
||||
reject(new Error('Browser page was closed before it attached'))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
record.webview.removeEventListener('dom-ready', onReady)
|
||||
reject(new Error('Browser page failed to attach'))
|
||||
}, ATTACH_TIMEOUT_MS)
|
||||
// Only now is a guest id readable; `did-attach` fires too early for it.
|
||||
// `dom-ready` repeats on every navigation, so take the first one only.
|
||||
function onReady() {
|
||||
clearTimeout(timer)
|
||||
record.webview.removeEventListener('dom-ready', onReady)
|
||||
try {
|
||||
const id = record.webview.getWebContentsId()
|
||||
record.webContentsId = id
|
||||
resolve(id)
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
record.webview.addEventListener('dom-ready', onReady)
|
||||
target.appendChild(record.slot)
|
||||
applyPlacement(record.tabId)
|
||||
}))
|
||||
return record.attach
|
||||
}
|
||||
|
||||
function waitForLayer(): Promise<HTMLElement> {
|
||||
if (layer) return Promise.resolve(layer)
|
||||
return new Promise(resolve => { layerWaiters.push(resolve) })
|
||||
}
|
||||
|
||||
function dispose(record: GuestRecord): void {
|
||||
record.slot.remove()
|
||||
forget(record)
|
||||
}
|
||||
|
||||
function forget(record: GuestRecord): void {
|
||||
if (guests.get(record.tabId) === record) guests.delete(record.tabId)
|
||||
}
|
||||
|
||||
function applyAllPlacements(): void {
|
||||
for (const tabId of guests.keys()) applyPlacement(tabId)
|
||||
}
|
||||
|
||||
function applyPlacement(tabId: string): void {
|
||||
const record = guests.get(tabId)
|
||||
if (!record) return
|
||||
const placement = placements.get(tabId)
|
||||
if (!placement || !layer || !placement.stage.isConnected) {
|
||||
// Parked pages keep their last size, so hiding never reflows the page.
|
||||
setShown(record, false)
|
||||
return
|
||||
}
|
||||
const stage = placement.stage.getBoundingClientRect()
|
||||
const origin = layer.getBoundingClientRect()
|
||||
const ratio = window.devicePixelRatio || 1
|
||||
// Whole device pixels keep text crisp, as the native view's bounds did.
|
||||
const snap = (value: number) => Math.round(value * ratio) / ratio
|
||||
Object.assign(record.slot.style, {
|
||||
left: `${snap(stage.left - origin.left)}px`,
|
||||
top: `${snap(stage.top - origin.top)}px`,
|
||||
width: `${snap(stage.width)}px`,
|
||||
height: `${snap(stage.height)}px`,
|
||||
})
|
||||
setShown(record, placement.visible && stage.width > 0 && stage.height > 0)
|
||||
}
|
||||
|
||||
function setShown(record: GuestRecord, shown: boolean): void {
|
||||
record.slot.style.visibility = shown ? 'visible' : 'hidden'
|
||||
record.slot.dataset.shown = shown ? 'true' : 'false'
|
||||
record.slot.style.pointerEvents = shown && !gesture ? 'auto' : 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer gesture that starts in the app (dragging a resize handle, a tab,
|
||||
* a text selection) must keep receiving its moves and its release when the
|
||||
* pointer crosses a page: a guest otherwise takes them, and the drag stalls
|
||||
* with its button still "held". Pages ignore the pointer until it ends.
|
||||
* A gesture that starts inside a page is never seen here, so pages keep it.
|
||||
*/
|
||||
function installGestureListeners(): void {
|
||||
if (gestureListenersInstalled) return
|
||||
gestureListenersInstalled = true
|
||||
document.addEventListener('pointerdown', () => setGesture(true), true)
|
||||
window.addEventListener('pointerup', () => setGesture(false), true)
|
||||
window.addEventListener('pointercancel', () => setGesture(false), true)
|
||||
window.addEventListener('blur', () => setGesture(false))
|
||||
// A release outside the window never arrives; the next plain move ends it.
|
||||
document.addEventListener('pointermove', (event) => {
|
||||
if (gesture && event.buttons === 0) setGesture(false)
|
||||
}, true)
|
||||
}
|
||||
|
||||
function setGesture(active: boolean): void {
|
||||
if (gesture === active) return
|
||||
gesture = active
|
||||
for (const record of guests.values()) {
|
||||
setShown(record, record.slot.dataset.shown === 'true')
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,45 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceBrowserMenuOptions } from '../desktopHost/types'
|
||||
|
||||
const mock = vi.hoisted(() => ({ available: true, showMenu: vi.fn() }))
|
||||
const mock = vi.hoisted(() => ({ available: true, showMenu: vi.fn(), close: vi.fn(async () => {}) }))
|
||||
vi.mock('../desktopHost', () => ({
|
||||
getDesktopHost: () => ({ capabilities: { workspaceBrowser: mock.available }, browser: { showMenu: mock.showMenu } }),
|
||||
getDesktopHost: () => ({
|
||||
capabilities: { workspaceBrowser: mock.available },
|
||||
browser: { showMenu: mock.showMenu, close: mock.close },
|
||||
}),
|
||||
}))
|
||||
|
||||
import { workspaceBrowserHost } from './browserHost'
|
||||
import { releaseWorkspaceBrowserTab, workspaceBrowserHost } from './browserHost'
|
||||
import { ensureWorkspaceBrowserGuest, isWorkspaceBrowserGuestRegistered } from './browserGuests'
|
||||
import { installFakeBrowserGuests } from '../../test/fakeBrowserGuests'
|
||||
|
||||
const options: WorkspaceBrowserMenuOptions = {
|
||||
x: 20, y: 44, zoomFactor: 1, hasPage: true, canOpenExternal: true,
|
||||
labels: { find: 'Find', print: 'Print', zoom: 'Zoom', zoomIn: 'Larger', zoomOut: 'Smaller', zoomReset: 'Reset', capture: 'Capture', pickElement: 'Pick', downloads: 'Downloads', history: 'History', openExternal: 'External' },
|
||||
}
|
||||
|
||||
let disposeGuests: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
mock.available = true
|
||||
mock.showMenu.mockReset()
|
||||
mock.close.mockClear()
|
||||
disposeGuests = installFakeBrowserGuests()
|
||||
})
|
||||
|
||||
afterEach(() => disposeGuests())
|
||||
|
||||
it('ends a closed tab\'s page by removing its element and releasing it in the host', async () => {
|
||||
await ensureWorkspaceBrowserGuest('wb-a', async () => {})
|
||||
const [webview] = Array.from(document.querySelectorAll('webview'))
|
||||
expect(isWorkspaceBrowserGuestRegistered('wb-a')).toBe(true)
|
||||
|
||||
releaseWorkspaceBrowserTab('wb-a')
|
||||
|
||||
// Removing the element is what destroys a `<webview>` guest.
|
||||
expect(webview!.isConnected).toBe(false)
|
||||
expect(isWorkspaceBrowserGuestRegistered('wb-a')).toBe(false)
|
||||
expect(mock.close).toHaveBeenCalledWith('wb-a')
|
||||
})
|
||||
|
||||
it('preserves selected actions and cancellation instead of discarding the host result', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getDesktopHost } from '../desktopHost'
|
||||
import { usePreviewSelectionStore } from '../../stores/previewSelectionStore'
|
||||
import { disposeWorkspaceBrowserGuest } from './browserGuests'
|
||||
import type {
|
||||
WorkspaceBrowserBounds,
|
||||
WorkspaceBrowserCaptureKind,
|
||||
WorkspaceBrowserEvent,
|
||||
WorkspaceBrowserFindOptions,
|
||||
@@ -17,7 +17,8 @@ import type { PreviewHostMessage } from '../desktopHost'
|
||||
* old singleton `previewBridge`: with one implicit page, showing a second link
|
||||
* silently navigated the first, and unmounting the React surface closed it.
|
||||
* Here React unmounting only stops *drawing* a page — the page dies when its
|
||||
* tab is closed, and nothing else.
|
||||
* tab is closed, and nothing else. Drawing itself is `browserGuests`' job; the
|
||||
* host only adopts the page's `<webview>` and runs what happens inside it.
|
||||
*
|
||||
* On hosts without a native browser (plain desktop browser, H5) every method
|
||||
* resolves to a typed failure instead of a silent success, so callers can show
|
||||
@@ -54,7 +55,7 @@ export const workspaceBrowserHost = {
|
||||
},
|
||||
create: (
|
||||
tabId: string,
|
||||
options: { storageId: string; url?: string; bounds?: WorkspaceBrowserBounds; visible?: boolean },
|
||||
options: { storageId: string; url?: string; webContentsId: number },
|
||||
) => call((api) => api.create(tabId, options)),
|
||||
navigate: (tabId: string, url: string) => call((api) => api.navigate(tabId, url)),
|
||||
goBack: (tabId: string) => call((api) => api.goBack(tabId)),
|
||||
@@ -62,8 +63,6 @@ export const workspaceBrowserHost = {
|
||||
reload: (tabId: string, options?: { ignoreCache?: boolean }) =>
|
||||
call((api) => api.reload(tabId, options)),
|
||||
stop: (tabId: string) => call((api) => api.stop(tabId)),
|
||||
setBounds: (tabId: string, bounds: WorkspaceBrowserBounds) =>
|
||||
call((api) => api.setBounds(tabId, bounds)),
|
||||
setVisible: (tabId: string, visible: boolean) => call((api) => api.setVisible(tabId, visible)),
|
||||
setZoom: (tabId: string, factor: number) => call((api) => api.setZoom(tabId, factor)),
|
||||
find: (tabId: string, text: string, options?: WorkspaceBrowserFindOptions) =>
|
||||
@@ -71,10 +70,6 @@ export const workspaceBrowserHost = {
|
||||
stopFind: (tabId: string) => call((api) => api.stopFind(tabId)),
|
||||
capture: (tabId: string, kind: WorkspaceBrowserCaptureKind) =>
|
||||
call((api) => api.capture(tabId, kind)),
|
||||
snapshot: async (tabId: string): Promise<string | null> => {
|
||||
const api = host()
|
||||
return api ? api.snapshot(tabId) : null
|
||||
},
|
||||
message: (tabId: string, payload: PreviewHostMessage) =>
|
||||
call((api) => api.message(tabId, payload)),
|
||||
close: (tabId: string) => call((api) => api.close(tabId)),
|
||||
@@ -88,6 +83,7 @@ export const workspaceBrowserHost = {
|
||||
*/
|
||||
export function releaseWorkspaceBrowserTab(browserTabId: string): void {
|
||||
usePreviewSelectionStore.getState().clear(browserTabId)
|
||||
disposeWorkspaceBrowserGuest(browserTabId)
|
||||
void workspaceBrowserHost.close(browserTabId).catch(() => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,27 @@ const teamApiMocks = vi.hoisted(() => ({
|
||||
sendMemberMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
const browserHostMocks = vi.hoisted(() => ({
|
||||
available: false,
|
||||
create: vi.fn(async () => ({ ok: true as const })),
|
||||
setVisible: vi.fn(async () => ({ ok: true as const })),
|
||||
message: vi.fn(async () => ({ ok: true as const })),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/workspace/browserHost', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/workspace/browserHost')>()
|
||||
return {
|
||||
...actual,
|
||||
isWorkspaceBrowserAvailable: () => browserHostMocks.available,
|
||||
workspaceBrowserHost: {
|
||||
...actual.workspaceBrowserHost,
|
||||
create: browserHostMocks.create,
|
||||
setVisible: browserHostMocks.setVisible,
|
||||
message: browserHostMocks.message,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../api/sessions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/sessions')>()
|
||||
return {
|
||||
@@ -98,6 +119,7 @@ vi.mock('./TerminalSettings', () => ({
|
||||
}))
|
||||
|
||||
import { ActiveSession } from './ActiveSession'
|
||||
import { installFakeBrowserGuests, isBrowserPageShown } from '../test/fakeBrowserGuests'
|
||||
import { createDefaultSessionState, useChatStore } from '../stores/chatStore'
|
||||
import { useCLITaskStore } from '../stores/cliTaskStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
@@ -133,9 +155,18 @@ beforeEach(() => {
|
||||
teamApiMocks.sendMemberMessage.mockReset()
|
||||
})
|
||||
|
||||
let disposeGuests: (() => void) | null = null
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
disposeGuests?.()
|
||||
disposeGuests = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
browserHostMocks.available = false
|
||||
for (const mock of [browserHostMocks.create, browserHostMocks.setVisible, browserHostMocks.message]) {
|
||||
mock.mockClear()
|
||||
}
|
||||
viewportMocks.isMobile = false
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
@@ -230,6 +261,47 @@ describe('ActiveSession task polling', () => {
|
||||
expect(useChatStore.getState().sessions['__settings__']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parks the workspace browser page while another page covers the retained session', async () => {
|
||||
// ContentRouter hides a retained session with opacity only. The old native
|
||||
// page ignored CSS and kept painting over the market page; the page must
|
||||
// also stop acting as browser chrome (shortcuts) while it is off screen.
|
||||
disposeGuests = installFakeBrowserGuests()
|
||||
browserHostMocks.available = true
|
||||
const sessionId = 'browser-retained-session'
|
||||
useSessionStore.setState({
|
||||
sessions: [{ id: sessionId, title: 'Browser', createdAt: '', modifiedAt: '', messageCount: 1, projectPath: '/repo', workDir: '/repo', workDirExists: true }],
|
||||
activeSessionId: sessionId,
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId, title: 'Browser', type: 'session', status: 'idle' },
|
||||
{ sessionId: '__market__', title: 'Market', type: 'market', status: 'idle' },
|
||||
],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useChatStore.setState({ sessions: { [sessionId]: { ...createDefaultSessionState(), connectionState: 'connected', historyStatus: 'ready', historyHydrated: true } } })
|
||||
const tabId = useWorkspaceStore.getState().openTarget(sessionId, { kind: 'browser', url: 'https://example.test/' })!
|
||||
const tab = useWorkspaceStore.getState().getTab(sessionId, tabId)
|
||||
if (tab?.kind !== 'browser') throw new Error('expected a browser tab')
|
||||
useWorkspaceStore.getState().setLayout(sessionId, 'split')
|
||||
|
||||
const { rerender } = render(<ActiveSession sessionId={sessionId} active />)
|
||||
await waitFor(() => expect(browserHostMocks.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true))
|
||||
expect(isBrowserPageShown(tab.browserTabId)).toBe(true)
|
||||
|
||||
act(() => useTabStore.getState().setActiveTab('__market__'))
|
||||
rerender(<ActiveSession sessionId={sessionId} active={false} />)
|
||||
expect(browserHostMocks.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
|
||||
expect(isBrowserPageShown(tab.browserTabId)).toBe(false)
|
||||
|
||||
act(() => useTabStore.getState().setActiveTab(sessionId))
|
||||
rerender(<ActiveSession sessionId={sessionId} active />)
|
||||
expect(browserHostMocks.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
|
||||
expect(isBrowserPageShown(tab.browserTabId)).toBe(true)
|
||||
// Hiding is presentation only; the page itself must survive the round trip.
|
||||
expect(browserHostMocks.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows cleaned worktrees as retained history and uses the source project for tools', () => {
|
||||
const sessionId = 'cleaned-worktree-session'
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
|
||||
@@ -810,11 +810,17 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
|
||||
onClick={() => useWorkspaceStore.getState().setLayout(activeTabId, 'hidden')} />
|
||||
<span className="text-sm text-[var(--color-text-secondary)]">{t('sideChat.title')}</span>
|
||||
</div>}
|
||||
{/*
|
||||
ContentRouter keeps this page mounted under settings/market with
|
||||
only opacity-0. A native browser page ignores CSS, so the dock
|
||||
must be told it is off screen or it stays attached over them.
|
||||
*/}
|
||||
<WorkspaceSurface
|
||||
sessionId={activeTabId}
|
||||
dock="side"
|
||||
cwd={getSessionTerminalCwd(session) ?? ''}
|
||||
reviewUnavailableReason={workspaceIsGitRepo === false ? t('workspace.launcher.reviewNeedsGit') : null}
|
||||
visible={active}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PreviewBrowserControlsMessage } from '../lib/desktopHost/types'
|
||||
type ZoomControlsConfig = Omit<PreviewBrowserControlsMessage, 'type' | 'v'>
|
||||
export type BrowserZoomAction = 'out' | 'in' | 'reset'
|
||||
|
||||
/** Lives inside the native page, above its contents; renderer z-index cannot cover a WebContentsView. */
|
||||
/** Lives inside the page, above its contents, so it scrolls, zooms and captures with the page itself. */
|
||||
export function createZoomControls(onAction: (action: BrowserZoomAction) => void) {
|
||||
const host = document.createElement('div')
|
||||
host.dataset.workspaceBrowserZoom = 'true'
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useOverlayStore } from './overlayStore'
|
||||
|
||||
const reset = () => {
|
||||
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
|
||||
}
|
||||
|
||||
describe('overlayStore', () => {
|
||||
beforeEach(reset)
|
||||
|
||||
it('starts at count 0', () => {
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
})
|
||||
|
||||
it('tracks snapshot overlays separately so ordinary modals keep immediate hiding', () => {
|
||||
const { push, pop } = useOverlayStore.getState()
|
||||
push(true)
|
||||
push()
|
||||
expect(useOverlayStore.getState()).toMatchObject({ count: 2, snapshotCount: 1 })
|
||||
pop()
|
||||
expect(useOverlayStore.getState()).toMatchObject({ count: 1, snapshotCount: 1 })
|
||||
pop(true)
|
||||
expect(useOverlayStore.getState()).toMatchObject({ count: 0, snapshotCount: 0 })
|
||||
})
|
||||
|
||||
it('push increments by 1', () => {
|
||||
useOverlayStore.getState().push()
|
||||
expect(useOverlayStore.getState().count).toBe(1)
|
||||
useOverlayStore.getState().push()
|
||||
expect(useOverlayStore.getState().count).toBe(2)
|
||||
})
|
||||
|
||||
it('pop decrements by 1', () => {
|
||||
const { push, pop } = useOverlayStore.getState()
|
||||
push()
|
||||
push()
|
||||
pop()
|
||||
expect(useOverlayStore.getState().count).toBe(1)
|
||||
})
|
||||
|
||||
it('pop at 0 stays clamped at 0', () => {
|
||||
useOverlayStore.getState().pop()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
useOverlayStore.getState().pop()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
})
|
||||
|
||||
it('balances pushes and pops back to 0', () => {
|
||||
const { push, pop } = useOverlayStore.getState()
|
||||
push(); push(); push()
|
||||
expect(useOverlayStore.getState().count).toBe(3)
|
||||
pop(); pop(); pop()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
// extra pop is still clamped
|
||||
pop()
|
||||
expect(useOverlayStore.getState().count).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* Tracks how many fullscreen DOM overlays (image preview modals, etc.) are
|
||||
* currently mounted. A native child webview (e.g. the in-app browser preview)
|
||||
* always renders ABOVE the DOM, so it covers any fullscreen overlay; surfaces
|
||||
* driving such webviews read this count and hide the webview while count > 0.
|
||||
*
|
||||
* Reusable: any fullscreen overlay can opt in via `push()` / `pop()` (or the
|
||||
* helper hook `useSuppressBrowserOverlay()` below).
|
||||
*/
|
||||
type OverlayStore = {
|
||||
count: number
|
||||
snapshotCount: number
|
||||
push: (preserveSnapshot?: boolean) => void
|
||||
pop: (preserveSnapshot?: boolean) => void
|
||||
}
|
||||
|
||||
export const useOverlayStore = create<OverlayStore>((set) => ({
|
||||
count: 0,
|
||||
snapshotCount: 0,
|
||||
push: (preserveSnapshot = false) => set((state) => ({
|
||||
count: state.count + 1,
|
||||
snapshotCount: state.snapshotCount + (preserveSnapshot ? 1 : 0),
|
||||
})),
|
||||
pop: (preserveSnapshot = false) => set((state) => ({
|
||||
count: Math.max(0, state.count - 1),
|
||||
snapshotCount: Math.max(0, state.snapshotCount - (preserveSnapshot ? 1 : 0)),
|
||||
})),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Mount-scoped helper: increments the overlay count on mount, decrements on
|
||||
* unmount. Pairs cleanly with strict-mode double-invoke because each effect
|
||||
* run does exactly one inc + one dec.
|
||||
*/
|
||||
export function useSuppressBrowserOverlay({ preserveSnapshot = false }: { preserveSnapshot?: boolean } = {}) {
|
||||
useEffect(() => {
|
||||
const { push, pop } = useOverlayStore.getState()
|
||||
push(preserveSnapshot)
|
||||
return () => pop(preserveSnapshot)
|
||||
}, [preserveSnapshot])
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { vi } from 'vitest'
|
||||
import { setWorkspaceBrowserGuestLayer } from '../lib/workspace/browserGuests'
|
||||
|
||||
/**
|
||||
* The real workspace browser page registry and layer, with only Electron's part
|
||||
* simulated: every `<webview>` the registry inserts becomes a guest with an id
|
||||
* that reports ready, and the page placeholder gets a size (jsdom lays nothing
|
||||
* out, and a page is only drawn over a placeholder that has one).
|
||||
*
|
||||
* Call in `beforeEach`; call the returned function in `afterEach`.
|
||||
*/
|
||||
export function installFakeBrowserGuests(): () => void {
|
||||
const layer = document.createElement('div')
|
||||
layer.dataset.testid = 'fake-browser-guest-layer'
|
||||
document.body.appendChild(layer)
|
||||
setWorkspaceBrowserGuestLayer(layer)
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
for (const node of record.addedNodes) {
|
||||
const webview = node instanceof HTMLElement ? node.querySelector('webview') : null
|
||||
if (!webview) continue
|
||||
const id = nextGuestId++
|
||||
Object.assign(webview, { getWebContentsId: () => id })
|
||||
webview.dispatchEvent(new Event('dom-ready'))
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(layer, { childList: true })
|
||||
const original = HTMLElement.prototype.getBoundingClientRect
|
||||
const measure = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.testid !== 'workspace-browser-placeholder') return original.call(this)
|
||||
const box = { left: 0, top: 52, width: 800, height: 600 }
|
||||
return { ...box, x: box.left, y: box.top, right: box.left + box.width, bottom: box.top + box.height, toJSON: () => box } as DOMRect
|
||||
})
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
measure.mockRestore()
|
||||
setWorkspaceBrowserGuestLayer(null)
|
||||
layer.remove()
|
||||
}
|
||||
}
|
||||
|
||||
let nextGuestId = 1
|
||||
|
||||
/** Whether the page's `<webview>` is drawn over its placeholder right now. */
|
||||
export function isBrowserPageShown(browserTabId: string): boolean {
|
||||
const slot = document.querySelector<HTMLElement>(`[data-workspace-browser-slot="${browserTabId}"]`)
|
||||
return slot?.dataset.shown === 'true'
|
||||
}
|
||||
Reference in New Issue
Block a user