Files
orca/src/main/browser/browser-manager.ts
T
Brennan Benson 419151dddd fix(browser): present Firefox UA on Google auth hosts to end ~1h sign-in staleness (#12884)
Google binds a signed-in session to the browser identity that created it, and
rejected Orca's Electron/Chrome-shaped UA on accounts.google.com — so sessions
died at ~1h and re-copying cookies only reset the same losing clock.

Present a Firefox identity scoped to the Google/YouTube sign-in hosts at every
layer Google probes: the request headers (with sec-ch-ua* stripped, since real
Firefox sends none), the per-WebContents UA that auth JS reads, and the CDP
emulation override a viewport preset installs. All three must agree — a
disagreement between them is a sharper bot signal than any single wrong UA.

Native-UA profiles opt out at every layer, including popup child windows and
after a browser-native cookie import.

Validated live against a real account: freshness cookies issued and rotating on
a ~10-min cadence, session still signed in and self-renewing past 10 hours,
against a pre-fix baseline of death at ~1h.

Closes #11518.
2026-08-07 01:29:37 -07:00

2245 lines
84 KiB
TypeScript

/* eslint-disable max-lines -- Why: single privileged facade for guest registration, authorization, and lifecycle cleanup; keeps the browser security boundary in one file. */
import { randomUUID } from 'node:crypto'
import { shell, webContents } from 'electron'
import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants'
import {
normalizeBrowserNavigationUrl,
normalizeExternalBrowserUrl,
redactKagiSessionToken,
toSecureCertificateEndpoint
} from '../../shared/browser-url'
import type {
BrowserDownloadFinishedEvent,
BrowserDownloadProgressEvent,
BrowserDownloadRequestedEvent,
BrowserPermissionDeniedEvent,
BrowserPopupEvent
} from '../../shared/browser-guest-events'
import type {
BrowserGrabCancelReason,
BrowserGrabPayload,
BrowserGrabRect,
BrowserGrabResult,
BrowserGrabScreenshot
} from '../../shared/browser-grab-types'
import { buildGuestOverlayScript } from './grab-guest-script'
import { clampGrabPayload } from './browser-grab-payload'
import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot'
import { BrowserGrabSessionController } from './browser-grab-session-controller'
import { browserDownloadDestinationReservations } from './browser-download-destination'
import {
resolveRendererWebContents,
setupGrabShortcutForwarding,
setupGuestContextMenu,
setupGuestMouseWheelZoomForwarding,
setupGuestShortcutForwarding
} from './browser-guest-ui'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window'
import {
BROWSER_CLICKED_LINK_ROUTING_WORLD_ID,
buildBrowserClickedLinkRoutingScript,
buildBrowserIframeClickedLinkRoutingScript
} from './browser-clicked-link-routing'
import { cleanElectronUserAgent } from './browser-session-ua'
import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
import { buildViewportUserAgentOverride } from './browser-viewport-user-agent'
import type {
BrowserViewportOverride,
BrowserCertificateFailure,
BrowserLoadError,
BrowserSessionUserAgentMode
} from '../../shared/types'
import {
type BrowserAnnotationViewportBridgeOptions,
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
buildBrowserAnnotationViewportBridgeScript
} from '../../shared/browser-annotation-viewport-bridge'
import type { KeybindingOverrides } from '../../shared/keybindings'
import {
BrowserCertificateTrustController,
type ManagedBrowserGuestContext
} from './browser-certificate-trust-controller'
const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000
function isChromiumInternalErrorUrl(url: string): boolean {
return url.startsWith('chrome-error://')
}
function resolveWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
fallbackValue: T
): Promise<{ value: T; timedOut: boolean }> {
let timeoutId: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => {
timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs)
})
return Promise.race([
promise.then((value) => ({ value, timedOut: false })),
timeoutPromise
]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId)
}
})
}
function releaseAutomationVisibilityToken(renderer: Electron.WebContents, token: string): void {
if (renderer.isDestroyed()) {
return
}
renderer
.executeJavaScript(
`(function() {
var bridge = window.__orcaBrowserAutomationVisibility;
if (!bridge || typeof bridge.release !== 'function') return false;
return bridge.release(${JSON.stringify(token)});
})()`
)
.catch(() => {})
}
function cleanupLateAutomationVisibilityToken(
renderer: Electron.WebContents,
acquirePromise: Promise<unknown>
): void {
acquirePromise
.then((lateToken) => {
if (typeof lateToken !== 'string' || lateToken.length === 0) {
return
}
// Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable.
releaseAutomationVisibilityToken(renderer, lateToken)
})
.catch(() => {})
}
function createNoopRestoreForTimedOutAutomationAcquire(
renderer: Electron.WebContents,
acquirePromise: Promise<unknown>,
timedOut: boolean
): () => void {
if (timedOut) {
cleanupLateAutomationVisibilityToken(renderer, acquirePromise)
}
return () => {}
}
function isAutomationVisibilityToken(token: unknown): token is string {
return typeof token === 'string' && token.length > 0
}
export type BrowserGuestRegistration = {
browserPageId?: string
browserTabId?: string
workspaceId?: string
worktreeId?: string
sessionProfileId?: string | null
userAgentMode?: BrowserSessionUserAgentMode
webContentsId: number
rendererWebContentsId: number
}
type PendingPermissionEvent = Omit<BrowserPermissionDeniedEvent, 'browserPageId'>
type PendingPopupEvent = Omit<BrowserPopupEvent, 'browserPageId'>
type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted'
type PopupOwnerContext = {
browserTabId: string
rootGuestWebContentsId: number
}
type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
}
const SAFE_POPUP_WINDOW_OPTIONS = {
alwaysOnTop: false,
closable: true,
focusable: true,
frame: true,
fullscreen: false,
kiosk: false,
modal: false,
movable: true,
opacity: 1,
show: true,
simpleFullscreen: false,
skipTaskbar: false,
titleBarStyle: 'default',
transparent: false,
// Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation.
webPreferences: {
allowRunningInsecureContent: false,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
sandbox: true,
webviewTag: false
}
} satisfies Electron.BrowserWindowConstructorOptions
type ActiveDownload = {
downloadId: string
guestWebContentsId: number
browserTabId: string | null
rendererWebContentsId: number | null
origin: string
filename: string
totalBytes: number | null
mimeType: string | null
item: Electron.DownloadItem
savePath: string
reservationKey: string | null
receivedBytes: number
transientState: BrowserDownloadProgressEvent['state']
terminalEvent: BrowserDownloadFinishedEvent | null
startedSent: boolean
cleanup: (() => void) | null
}
function safeOrigin(rawUrl: string): string {
const external = normalizeExternalBrowserUrl(rawUrl)
const urlToParse = external ?? rawUrl
try {
return new URL(urlToParse).origin
} catch {
return external ?? 'unknown'
}
}
export class BrowserManager {
private settingsResolver:
| (() => {
keybindings?: KeybindingOverrides
mobileEmulatorEnabled?: boolean
})
| null = null
private readonly webContentsIdByTabId = new Map<string, number>()
// Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event.
private readonly tabIdByWebContentsId = new Map<number, string>()
private readonly popupOwnerContextByGuestId = new Map<number, PopupOwnerContext>()
// Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture.
private readonly workspaceIdByPageId = new Map<string, string>()
private readonly sessionProfileIdByPageId = new Map<string, string | null>()
private readonly userAgentModeByPageId = new Map<string, BrowserSessionUserAgentMode>()
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
// Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state.
private readonly viewportOpsByTabId = new Map<string, Promise<unknown>>()
// Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation
// can re-issue it against the target URL's identity.
private readonly viewportUaOverrideMobileByTabId = new Map<string, boolean>()
// Why: the in-flight main-frame navigation target, held only until commit or failure — getURL()
// still reports the outgoing page until then. See resolveTabNavigationUrl.
private readonly pendingNavigationByGuestId = new Map<number, PendingMainFrameNavigation>()
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
private readonly mouseWheelZoomCleanupByTabId = new Map<string, () => void>()
private readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
private readonly worktreeIdByTabId = new Map<string, string>()
private readonly policyAttachedGuestIds = new Set<number>()
private readonly offscreenGuestIds = new Set<number>()
private readonly policyCleanupByGuestId = new Map<number, () => void>()
private readonly clickedLinkFrameNameByGuestId = new Map<number, string>()
private readonly loadErrorsByGuestId = new Map<number, BrowserLoadError>()
// Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav.
private readonly clearedLoadErrorsByGuestId = new Map<number, BrowserLoadError>()
private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null
private certificateTrustController: BrowserCertificateTrustController | null = null
private shouldForwardDictationShortcut: (() => boolean) | null = null
private readonly pendingLoadFailuresByGuestId = new Map<
number,
{ code: number; description: string; validatedUrl: string }
>()
private readonly pendingPermissionEventsByGuestId = new Map<number, PendingPermissionEvent[]>()
private readonly pendingPopupEventsByGuestId = new Map<number, PendingPopupEvent[]>()
private readonly pendingDownloadIdsByGuestId = new Map<number, string[]>()
private readonly downloadsById = new Map<string, ActiveDownload>()
private readonly grabSessionController = new BrowserGrabSessionController()
setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void {
this.shouldForwardDictationShortcut = predicate
}
setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void {
this.browserGuestStateChangedListener = listener
}
setCertificateTrustController(controller: BrowserCertificateTrustController): void {
this.certificateTrustController = controller
}
installCertificateRequestGuard(session: Electron.Session): void {
this.certificateTrustController?.installSessionRequestGuard(session)
}
removeCertificateRequestGuard(session: Electron.Session): void {
this.certificateTrustController?.removeSessionRequestGuard(session)
}
setSettingsResolver(
resolver: () => {
keybindings?: KeybindingOverrides
mobileEmulatorEnabled?: boolean
}
): void {
this.settingsResolver = resolver
}
// Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context.
private injectAntiDetection(guest: Electron.WebContents): () => void {
let disposed = false
let reattachTimer: ReturnType<typeof setTimeout> | null = null
const attach = (): void => {
if (disposed || guest.isDestroyed()) {
return
}
try {
if (!guest.debugger.isAttached()) {
guest.debugger.attach('1.3')
}
void guest.debugger
.sendCommand('Page.enable', {})
.then(() =>
guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
source: ANTI_DETECTION_SCRIPT
})
)
.catch(() => {})
} catch {
/* best-effort — debugger may be unavailable */
}
}
// Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides.
const onDetach = (): void => {
if (!disposed && !guest.isDestroyed() && reattachTimer === null) {
reattachTimer = setTimeout(() => {
reattachTimer = null
attach()
}, 500)
}
}
try {
attach()
guest.debugger.on('detach', onDetach)
} catch {
/* best-effort */
}
return () => {
disposed = true
if (reattachTimer !== null) {
clearTimeout(reattachTimer)
reattachTimer = null
}
try {
guest.debugger.off('detach', onDetach)
} catch {
/* guest may already be destroyed */
}
}
}
private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null {
return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null
}
private resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null {
const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId)
if (browserTabId) {
return { browserTabId, rootGuestWebContentsId: guestWebContentsId }
}
const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId)
if (
inherited &&
this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId
) {
return inherited
}
this.popupOwnerContextByGuestId.delete(guestWebContentsId)
return null
}
private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null {
const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId)
if (!rendererWebContentsId) {
return null
}
const renderer = webContents.fromId(rendererWebContentsId)
if (!renderer || renderer.isDestroyed()) {
return null
}
return renderer
}
// Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out.
async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> {
const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
if (!browserPageId) {
return () => {}
}
const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId
const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null
const renderer = this.resolveRendererForBrowserTab(browserPageId)
if (!renderer || renderer.isDestroyed()) {
return () => {}
}
const prev = await renderer
.executeJavaScript(
`(function() {
var store = window.__store;
if (!store) return null;
var state = store.getState();
var prevTabType = state.activeTabType;
var prevActiveWorktreeId = state.activeWorktreeId || null;
var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null;
var prevActiveBrowserPageId = null;
var prevFocusedGroupTabId = null;
var targetWorktreeId = ${JSON.stringify(worktreeId)};
var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)};
var browserPageId = ${JSON.stringify(browserPageId)};
var browserTabsByWorktree = state.browserTabsByWorktree || {};
if (prevActiveWorktreeId) {
var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId];
var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || [];
for (var pg = 0; pg < prevGroups.length; pg++) {
if (prevGroups[pg].id === prevFocusedGroupId) {
prevFocusedGroupTabId = prevGroups[pg].activeTabId;
break;
}
}
}
if (prevActiveBrowserWorkspaceId) {
for (var prevWtId in browserTabsByWorktree) {
var prevBrowserTabs = browserTabsByWorktree[prevWtId] || [];
for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) {
if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) {
prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null;
break;
}
}
if (prevActiveBrowserPageId) break;
}
}
if (
targetWorktreeId &&
prevActiveWorktreeId !== targetWorktreeId &&
typeof state.setActiveWorktree === 'function'
) {
state.setActiveWorktree(targetWorktreeId);
state = store.getState();
}
var foundWorkspace = null;
for (var wtId in browserTabsByWorktree) {
var tabs = browserTabsByWorktree[wtId] || [];
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].id === browserWorkspaceId) {
foundWorkspace = tabs[i];
if (!targetWorktreeId) {
targetWorktreeId = wtId;
}
break;
}
}
if (foundWorkspace) break;
}
var hasTargetPage = false;
var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || [];
for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) {
if (targetPages[pageIndex].id === browserPageId) {
hasTargetPage = true;
break;
}
}
if (foundWorkspace) {
if (typeof state.setActiveBrowserTab === 'function') {
state.setActiveBrowserTab(browserWorkspaceId);
state = store.getState();
} else {
var allTabs = state.unifiedTabsByWorktree || {};
var found = null;
for (var unifiedWtId in allTabs) {
var unifiedTabs = allTabs[unifiedWtId] || [];
for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) {
if (
unifiedTabs[unifiedIndex].contentType === 'browser' &&
unifiedTabs[unifiedIndex].entityId === browserWorkspaceId
) {
found = unifiedTabs[unifiedIndex];
break;
}
}
if (found) break;
}
if (found) {
state.activateTab(found.id);
}
state.setActiveTabType('browser');
state = store.getState();
}
// Why: activating the workspace alone is not enough for screenshot
// capture when a browser workspace contains multiple pages. The
// compositor only paints the currently mounted page guest.
if (
hasTargetPage &&
foundWorkspace.activePageId !== browserPageId &&
typeof state.setActiveBrowserPage === 'function'
) {
state.setActiveBrowserPage(browserWorkspaceId, browserPageId);
state = store.getState();
}
}
return {
prevTabType: prevTabType,
prevActiveWorktreeId: prevActiveWorktreeId,
prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId,
prevActiveBrowserPageId: prevActiveBrowserPageId,
prevFocusedGroupTabId: prevFocusedGroupTabId,
targetWorktreeId: targetWorktreeId,
targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null,
targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null
};
})()`
)
.catch(() => null)
const needsRestore =
prev &&
(prev.prevTabType !== 'browser' ||
prev.prevActiveWorktreeId !== prev.targetWorktreeId ||
prev.prevFocusedGroupTabId !== null ||
prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId ||
prev.prevActiveBrowserPageId !== prev.targetBrowserPageId)
if (!needsRestore) {
return () => {}
}
return () => {
if (!prev || !renderer || renderer.isDestroyed()) {
return
}
renderer
.executeJavaScript(
`(function() {
var store = window.__store;
if (!store) return;
var state = store.getState();
if (
${JSON.stringify(prev?.prevActiveWorktreeId)} &&
${JSON.stringify(prev?.prevActiveWorktreeId)} !==
${JSON.stringify(prev?.targetWorktreeId)} &&
typeof state.setActiveWorktree === 'function'
) {
state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)});
state = store.getState();
}
if (
${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} &&
${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !==
${JSON.stringify(prev?.targetBrowserWorkspaceId)} &&
typeof state.setActiveBrowserTab === 'function'
) {
state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)});
state = store.getState();
}
if (
${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} &&
${JSON.stringify(prev?.prevActiveBrowserPageId)} &&
${JSON.stringify(prev?.prevActiveBrowserPageId)} !==
${JSON.stringify(prev?.targetBrowserPageId)} &&
typeof state.setActiveBrowserPage === 'function'
) {
// Why: Orca remembers the last browser workspace/page even when
// the user is currently in terminal/editor view. Screenshot prep
// temporarily switches that hidden browser selection state, so
// restore it independently of the visible tab type.
state.setActiveBrowserPage(
${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)},
${JSON.stringify(prev?.prevActiveBrowserPageId)}
);
state = store.getState();
}
if (
${JSON.stringify(prev?.prevTabType)} !== 'browser' &&
${JSON.stringify(prev?.prevFocusedGroupTabId)}
) {
state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)});
}
if (${JSON.stringify(prev?.prevTabType)} !== 'browser') {
state.setActiveTabType(${JSON.stringify(prev?.prevTabType)});
}
})()`
)
.catch(() => {})
}
}
async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> {
const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
if (!browserPageId) {
return () => {}
}
const renderer = this.resolveRendererForBrowserTab(browserPageId)
if (!renderer || renderer.isDestroyed()) {
return () => {}
}
// Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab.
const acquirePromise = renderer
.executeJavaScript(
`(async function() {
var bridge = window.__orcaBrowserAutomationVisibility;
if (!bridge || typeof bridge.acquire !== 'function') return null;
return await bridge.acquire(${JSON.stringify(browserPageId)});
})()`
)
.catch(() => null)
const { value: token, timedOut } = await resolveWithTimeout(
acquirePromise,
AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS,
null
)
if (!isAutomationVisibilityToken(token)) {
return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut)
}
return () => {
releaseAutomationVisibilityToken(renderer, token)
}
}
attachGuestPolicies(
guest: Electron.WebContents,
inheritedOwnerContext: PopupOwnerContext | null = null
): void {
if (this.policyAttachedGuestIds.has(guest.id)) {
return
}
this.policyAttachedGuestIds.add(guest.id)
if (inheritedOwnerContext) {
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
}
// Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior.
const clickedLinkFrameName = inheritedOwnerContext
? null
: `__orca_clicked_link_foreground_${randomUUID()}`
if (clickedLinkFrameName) {
this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName)
}
let clickedLinkRoutingActive = Boolean(clickedLinkFrameName)
// Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes.
const disposeAntiDetection = this.injectAntiDetection(guest)
// Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty.
guest.setBackgroundThrottling(false)
const installClickedLinkRouting = (): void => {
if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) {
return
}
// Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts.
void guest
.executeJavaScriptInIsolatedWorld(
BROWSER_CLICKED_LINK_ROUTING_WORLD_ID,
[
{
// Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing.
code: buildBrowserClickedLinkRoutingScript(
clickedLinkFrameName,
process.platform === 'darwin'
)
}
],
false
)
.catch(() => {})
}
if (clickedLinkFrameName) {
guest.on('dom-ready', installClickedLinkRouting)
}
const pendingIframeRoutingInstalls = new Map<Electron.WebFrameMain, () => void>()
const iframeFrameNameByFrame = new Map<Electron.WebFrameMain, string>()
const iframeFrameByFrameName = new Map<string, Electron.WebFrameMain>()
const clearIframeFrameName = (frame: Electron.WebFrameMain): void => {
const name = iframeFrameNameByFrame.get(frame)
if (!name) {
return
}
iframeFrameNameByFrame.delete(frame)
iframeFrameByFrameName.delete(name)
}
const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => {
clearIframeFrameName(frame)
if (!clickedLinkRoutingActive || frame.isDestroyed()) {
return
}
const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}`
iframeFrameNameByFrame.set(frame, name)
iframeFrameByFrameName.set(name, frame)
// Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next.
void frame
.executeJavaScript(
buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'),
false
)
.catch(() => {
if (iframeFrameNameByFrame.get(frame) === name) {
clearIframeFrameName(frame)
}
})
}
const handleFrameCreated = (
_event: Electron.Event,
{ frame }: Electron.FrameCreatedDetails
): void => {
if (!clickedLinkFrameName || !frame || frame.parent === null) {
return
}
for (const knownFrame of iframeFrameNameByFrame.keys()) {
if (knownFrame.isDestroyed()) {
clearIframeFrameName(knownFrame)
}
}
const installAfterDomReady = (): void => {
pendingIframeRoutingInstalls.delete(frame)
installIframeClickedLinkRouting(frame)
}
pendingIframeRoutingInstalls.set(frame, installAfterDomReady)
frame.once('dom-ready', installAfterDomReady)
}
if (clickedLinkFrameName) {
guest.on('frame-created', handleFrameCreated)
}
const handleDidCreateWindow = (window: Electron.BrowserWindow): void => {
// Why: popup descendants inherit the opener's owner context but must not replace its primary registration.
this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id))
}
guest.on('did-create-window', handleDidCreateWindow)
guest.setWindowOpenHandler(({ url, frameName }) => {
const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
const browserUrl = normalizeBrowserNavigationUrl(url)
const externalUrl = normalizeExternalBrowserUrl(url)
const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id)
const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined
let isClickedLink = Boolean(
expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName
)
if (!isClickedLink && iframeFrame) {
isClickedLink = true
clearIframeFrameName(iframeFrame)
queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame))
}
if (isClickedLink) {
if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) {
this.forwardOrQueuePopupEvent(guest.id, {
origin: safeOrigin(browserUrl),
action: 'opened-in-orca'
})
}
// Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click.
return { action: 'deny' }
}
// Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths.
const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL)
if (browserTabId && canOpenAsChild) {
// Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome.
return {
action: 'allow',
overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS,
// Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable.
createWindow: (options: PopupChildWindowOptions) =>
this.createPopupChildWindowWithOriginBar(guest, url, options)
}
} else if (externalUrl) {
// Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser.
void shell.openExternal(redactKagiSessionToken(externalUrl))
this.forwardOrQueuePopupEvent(guest.id, {
origin: safeOrigin(externalUrl),
action: 'opened-external'
})
} else {
// Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata.
this.forwardOrQueuePopupEvent(guest.id, {
origin: safeOrigin(url),
action: 'blocked'
})
}
return { action: 'deny' }
})
const navigationGuard = (event: Electron.Event, url: string): boolean => {
// Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones.
if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) {
return true
}
// Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS.
if (url.startsWith('file:')) {
event.preventDefault()
return false
}
if (!normalizeBrowserNavigationUrl(url)) {
// Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs.
event.preventDefault()
return false
}
return true
}
const willRedirectHandler = (
event: Electron.Event,
url: string,
_isInPlace: boolean,
isMainFrame: boolean
): void => {
if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) {
return
}
this.updatePendingNavigationForRedirect(guest.id, url)
this.applyGoogleAuthUserAgent(guest, url)
}
const didFailLoadHandler = (
_event: Electron.Event,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean
): void => {
if (!isMainFrame) {
return
}
// Why: a nav that never committed must not leave its target standing as the tab's host.
const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL)
if (failedNavigationWasCurrent) {
// The attempted host never committed, so restore every UA layer to the document that remains.
this.applyGoogleAuthUserAgent(guest, guest.getURL())
}
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
const certificateFailure = browserPageId
? this.certificateTrustController?.getFailure(browserPageId)
: null
if (
certificateFailure &&
toSecureCertificateEndpoint(validatedURL || guest.getURL()) ===
toSecureCertificateEndpoint(certificateFailure.origin)
) {
// Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy.
return
}
if (errorCode === -3) {
// Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost.
const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id)
if (clearedError !== undefined) {
this.clearedLoadErrorsByGuestId.delete(guest.id)
this.loadErrorsByGuestId.set(guest.id, clearedError)
this.forwardOrQueueGuestLoadFailure(guest.id, clearedError)
this.notifyBrowserGuestStateChanged(guest.id)
}
return
}
this.clearedLoadErrorsByGuestId.delete(guest.id)
const loadError = this.buildLoadError(
errorCode,
errorDescription || 'This site could not be reached.',
validatedURL || guest.getURL() || 'about:blank'
)
this.loadErrorsByGuestId.set(guest.id, loadError)
this.forwardOrQueueGuestLoadFailure(guest.id, loadError)
this.notifyBrowserGuestStateChanged(guest.id)
}
const didStartNavigationHandler = (
_event: Electron.Event,
url: string,
_isInPlace: boolean,
isMainFrame: boolean
): void => {
if (!isMainFrame || isChromiumInternalErrorUrl(url)) {
return
}
// Why: getURL() still reports the previous committed URL until this navigation commits, so
// every UA writer must read the in-flight target or they disagree about the tab's host.
this.startPendingNavigation(guest.id, url)
this.applyGoogleAuthUserAgent(guest, url)
this.certificateTrustController?.onMainFrameNavigationStarted(guest.id)
// Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it.
this.pendingLoadFailuresByGuestId.delete(guest.id)
const activeError = this.loadErrorsByGuestId.get(guest.id)
if (activeError === undefined) {
// Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure.
this.clearedLoadErrorsByGuestId.delete(guest.id)
return
}
this.clearedLoadErrorsByGuestId.set(guest.id, activeError)
this.loadErrorsByGuestId.delete(guest.id)
this.notifyBrowserGuestStateChanged(guest.id)
}
const didNavigateHandler = (_event: Electron.Event, url: string): void => {
// Why: once committed, getURL() reports this url, so the pending target is redundant.
this.pendingNavigationByGuestId.delete(guest.id)
// Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it.
this.clearedLoadErrorsByGuestId.delete(guest.id)
this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url)
}
guest.on('will-navigate', navigationGuard)
guest.on('will-redirect', willRedirectHandler)
guest.on('did-start-navigation', didStartNavigationHandler)
guest.on('did-navigate', didNavigateHandler)
guest.on('did-fail-load', didFailLoadHandler)
const handleDestroyed = (): void => {
// Why: guests can die before renderer registration, else attach-time closures leak until shutdown.
this.cleanupGuestPolicyAttachment(guest.id)
}
guest.on('destroyed', handleDestroyed)
// Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC.
this.policyCleanupByGuestId.set(guest.id, () => {
disposeAntiDetection()
try {
guest.off('destroyed', handleDestroyed)
guest.off('did-create-window', handleDidCreateWindow)
if (clickedLinkFrameName) {
clickedLinkRoutingActive = false
guest.off('dom-ready', installClickedLinkRouting)
guest.off('frame-created', handleFrameCreated)
for (const [frame, install] of pendingIframeRoutingInstalls) {
if (!frame.isDestroyed()) {
frame.off('dom-ready', install)
}
}
pendingIframeRoutingInstalls.clear()
iframeFrameNameByFrame.clear()
iframeFrameByFrameName.clear()
}
} catch {
// guest may already be destroyed
}
if (!guest.isDestroyed()) {
guest.off('will-navigate', navigationGuard)
guest.off('will-redirect', willRedirectHandler)
guest.off('did-start-navigation', didStartNavigationHandler)
guest.off('did-navigate', didNavigateHandler)
guest.off('did-fail-load', didFailLoadHandler)
}
})
}
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
// Restores the session's base identity off the auth hosts. Native-UA profiles opt out
// of the whole clean-UA path, so they keep their untouched identity everywhere.
private applyGoogleAuthUserAgent(guest: Electron.WebContents, url: string): void {
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
// Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct
// lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA.
// That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so
// the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox.
const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
// Session state is authoritative before renderer registration and after a native profile imports a source UA.
const mode =
getBrowserSessionUserAgentMode(guest.session) ??
(ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined)
if (mode === 'native') {
return
}
const firefoxUa = googleAuthUserAgent()
const currentUa = guest.getUserAgent()
if (isGoogleAuthUrl(url)) {
if (currentUa !== firefoxUa) {
guest.setUserAgent(firefoxUa)
}
} else if (currentUa === firefoxUa) {
// Only restore when the auth-host override is actually in place, so normal
// navigation never touches the session UA.
guest.setUserAgent(guest.session.getUserAgent())
}
// Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of
// its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport.
if (browserPageId) {
this.reapplyViewportUserAgentOverride(guest, browserPageId, url)
}
}
private startPendingNavigation(guestId: number, url: string): void {
const pending = this.pendingNavigationByGuestId.get(guestId)
this.pendingNavigationByGuestId.set(guestId, {
currentUrl: url,
supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : []
})
}
private updatePendingNavigationForRedirect(guestId: number, url: string): void {
const pending = this.pendingNavigationByGuestId.get(guestId)
if (!pending) {
this.pendingNavigationByGuestId.set(guestId, {
currentUrl: url,
supersededUrls: []
})
return
}
pending.currentUrl = url
}
private failPendingNavigation(guestId: number, failedUrl: string): boolean {
const pending = this.pendingNavigationByGuestId.get(guestId)
if (!pending) {
return false
}
const supersededIndex = pending.supersededUrls.indexOf(failedUrl)
if (supersededIndex !== -1) {
pending.supersededUrls.splice(supersededIndex, 1)
return false
}
if (pending.currentUrl !== failedUrl) {
return false
}
this.pendingNavigationByGuestId.delete(guestId)
return true
}
// Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host
// the tab is leaving, not the one it is entering. Every UA writer must resolve the host through
// here or two writers racing the same navigation will pick opposite identities.
private resolveTabNavigationUrl(guest: Electron.WebContents): string {
return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL()
}
// Why: Emulation.setUserAgentOverride is set once and stands across every later navigation,
// outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an
// auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the
// request header says Firefox — the two-layer disagreement this scope exists to remove.
private reapplyViewportUserAgentOverride(
guest: Electron.WebContents,
browserTabId: string,
url: string
): void {
const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId)
if (mobile === undefined) {
return
}
// Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the
// later-issued write wins. What matters is that both writers resolve the SAME host, which they
// now do via the navigation target rather than the stale committed URL.
void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {})
}
private async sendViewportUserAgentOverride(
guest: Electron.WebContents,
mobile: boolean,
url?: string
): Promise<void> {
if (guest.isDestroyed() || !guest.debugger.isAttached()) {
return
}
await guest.debugger.sendCommand(
'Emulation.setUserAgentOverride',
buildViewportUserAgentOverride({
url: url ?? this.resolveTabNavigationUrl(guest),
mobile,
baseUserAgent: cleanElectronUserAgent(guest.getUserAgent())
})
)
}
private createPopupChildWindowWithOriginBar(
openerGuest: Electron.WebContents,
targetUrl: string,
options: PopupChildWindowOptions
): Electron.WebContents {
const popup = openPopupWithOriginBar(options, targetUrl)
// Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here.
this.attachGuestPolicies(
popup.contentWebContents,
this.resolvePopupOwnerContext(openerGuest.id)
)
this.forwardOrQueuePopupEvent(openerGuest.id, {
origin: safeOrigin(targetUrl),
action: 'opened-in-orca'
})
// Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups.
const closePopupWithOpener = (): void => popup.close()
openerGuest.once('destroyed', closePopupWithOpener)
popup.onClosed(() => {
if (!openerGuest.isDestroyed()) {
openerGuest.off('destroyed', closePopupWithOpener)
}
})
return popup.contentWebContents
}
private retireStaleGuestWebContents(previousWebContentsId: number): void {
// Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session.
this.cleanupGuestPolicyAttachment(previousWebContentsId)
}
private cleanupGuestPolicyAttachment(guestWebContentsId: number): void {
const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId)
const isPrimaryGuest = browserTabId !== undefined
if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) {
this.webContentsIdByTabId.delete(browserTabId)
}
this.tabIdByWebContentsId.delete(guestWebContentsId)
this.certificateTrustController?.onGuestRetired(guestWebContentsId)
const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId)
if (policyCleanup) {
policyCleanup()
this.policyCleanupByGuestId.delete(guestWebContentsId)
}
this.policyAttachedGuestIds.delete(guestWebContentsId)
this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId)
this.offscreenGuestIds.delete(guestWebContentsId)
this.popupOwnerContextByGuestId.delete(guestWebContentsId)
this.pendingNavigationByGuestId.delete(guestWebContentsId)
// Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child.
if (isPrimaryGuest) {
for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) {
if (owner.rootGuestWebContentsId === guestWebContentsId) {
this.popupOwnerContextByGuestId.delete(popupGuestId)
}
}
}
this.pendingLoadFailuresByGuestId.delete(guestWebContentsId)
this.loadErrorsByGuestId.delete(guestWebContentsId)
this.clearedLoadErrorsByGuestId.delete(guestWebContentsId)
this.pendingPermissionEventsByGuestId.delete(guestWebContentsId)
this.pendingPopupEventsByGuestId.delete(guestWebContentsId)
this.cancelPendingDownloadsForGuest(guestWebContentsId)
}
registerGuest({
browserPageId,
browserTabId: legacyBrowserTabId,
workspaceId,
worktreeId,
sessionProfileId,
userAgentMode,
webContentsId,
rendererWebContentsId
}: BrowserGuestRegistration): boolean {
const browserTabId = browserPageId ?? legacyBrowserTabId
if (!browserTabId) {
return false
}
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
this.cancelGrabOp(browserTabId, 'evicted')
const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId)
if (previousCleanup) {
previousCleanup()
this.contextMenuCleanupByTabId.delete(browserTabId)
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
return false
}
// Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests.
if (guest.getType() !== 'webview') {
return false
}
if (!this.policyAttachedGuestIds.has(webContentsId)) {
// Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview.
return false
}
const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId)
if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) {
this.retireStaleGuestWebContents(previousWebContentsId)
}
this.webContentsIdByTabId.set(browserTabId, webContentsId)
this.tabIdByWebContentsId.set(webContentsId, browserTabId)
if (workspaceId) {
this.workspaceIdByPageId.set(browserTabId, workspaceId)
}
this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null)
if (userAgentMode) {
this.userAgentModeByPageId.set(browserTabId, userAgentMode)
} else {
this.userAgentModeByPageId.delete(browserTabId)
}
this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId)
if (worktreeId) {
this.worktreeIdByTabId.set(browserTabId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId)
this.setupContextMenu(browserTabId, guest)
this.setupGrabShortcut(browserTabId, guest)
this.setupShortcutForwarding(browserTabId, guest)
this.setupMouseWheelZoomForwarding(browserTabId, guest)
this.flushPendingLoadFailure(browserTabId, webContentsId)
this.flushPendingPermissionEvents(browserTabId, webContentsId)
this.flushPendingPopupEvents(browserTabId, webContentsId)
this.flushPendingDownloadRequests(browserTabId, webContentsId)
return true
}
unregisterGuest(browserTabId: string): void {
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
this.cancelGrabOp(browserTabId, 'evicted')
// Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC.
const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId)
if (guestWebContentsId !== undefined) {
this.cleanupGuestPolicyAttachment(guestWebContentsId)
}
const cleanup = this.contextMenuCleanupByTabId.get(browserTabId)
if (cleanup) {
cleanup()
this.contextMenuCleanupByTabId.delete(browserTabId)
}
const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId)
if (shortcutCleanup) {
shortcutCleanup()
this.grabShortcutCleanupByTabId.delete(browserTabId)
}
const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId)
if (fwdCleanup) {
fwdCleanup()
this.shortcutForwardingCleanupByTabId.delete(browserTabId)
}
const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId)
if (mouseWheelZoomCleanup) {
mouseWheelZoomCleanup()
this.mouseWheelZoomCleanupByTabId.delete(browserTabId)
}
// Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them.
for (const [downloadId, download] of this.downloadsById.entries()) {
if (download.browserTabId === browserTabId && !download.terminalEvent) {
this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.')
}
}
const wcId = this.webContentsIdByTabId.get(browserTabId)
if (wcId !== undefined) {
this.tabIdByWebContentsId.delete(wcId)
}
this.webContentsIdByTabId.delete(browserTabId)
this.rendererWebContentsIdByTabId.delete(browserTabId)
this.workspaceIdByPageId.delete(browserTabId)
this.sessionProfileIdByPageId.delete(browserTabId)
this.userAgentModeByPageId.delete(browserTabId)
this.worktreeIdByTabId.delete(browserTabId)
// Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest.
this.viewportOpsByTabId.delete(browserTabId)
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
if (wcId !== undefined) {
this.pendingNavigationByGuestId.delete(wcId)
}
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
// Why: headless orca serve has no <webview> window; back pages with offscreen WebContents and skip the webview-only setup.
registerOffscreenGuest({
browserPageId,
worktreeId,
sessionProfileId,
userAgentMode,
webContentsId
}: {
browserPageId: string
worktreeId?: string
sessionProfileId?: string | null
userAgentMode?: BrowserSessionUserAgentMode
webContentsId: number
}): void {
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
return
}
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
this.offscreenGuestIds.add(webContentsId)
this.attachGuestPolicies(guest)
const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId)
if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) {
this.retireStaleGuestWebContents(previousWebContentsId)
}
this.webContentsIdByTabId.set(browserPageId, webContentsId)
this.tabIdByWebContentsId.set(webContentsId, browserPageId)
this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null)
if (userAgentMode) {
this.userAgentModeByPageId.set(browserPageId, userAgentMode)
} else {
this.userAgentModeByPageId.delete(browserPageId)
}
if (worktreeId) {
this.worktreeIdByTabId.set(browserPageId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
}
unregisterAll(): void {
// Cancel all active grab ops before tearing down registrations
this.grabSessionController.cancelAll('evicted')
for (const downloadId of this.downloadsById.keys()) {
this.cancelDownloadInternal(downloadId, 'Orca is shutting down.')
}
browserDownloadDestinationReservations.clear()
for (const browserTabId of this.webContentsIdByTabId.keys()) {
this.unregisterGuest(browserTabId)
}
this.policyAttachedGuestIds.clear()
this.offscreenGuestIds.clear()
// Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here.
for (const cleanup of this.policyCleanupByGuestId.values()) {
cleanup()
}
this.policyCleanupByGuestId.clear()
this.clickedLinkFrameNameByGuestId.clear()
this.tabIdByWebContentsId.clear()
this.popupOwnerContextByGuestId.clear()
this.worktreeIdByTabId.clear()
this.sessionProfileIdByPageId.clear()
this.userAgentModeByPageId.clear()
this.viewportUaOverrideMobileByTabId.clear()
this.pendingNavigationByGuestId.clear()
this.pendingLoadFailuresByGuestId.clear()
this.loadErrorsByGuestId.clear()
this.clearedLoadErrorsByGuestId.clear()
this.pendingPermissionEventsByGuestId.clear()
this.pendingPopupEventsByGuestId.clear()
this.pendingDownloadIdsByGuestId.clear()
this.mouseWheelZoomCleanupByTabId.clear()
this.annotationViewportBridgeOpsByTabId.clear()
}
getGuestWebContentsId(browserTabId: string): number | null {
return this.webContentsIdByTabId.get(browserTabId) ?? null
}
getWebContentsIdByTabId(): Map<string, number> {
return this.webContentsIdByTabId
}
getWorktreeIdForTab(browserTabId: string): string | undefined {
return this.worktreeIdByTabId.get(browserTabId)
}
getSessionProfileIdForTab(browserTabId: string): string | null {
return this.sessionProfileIdByPageId.get(browserTabId) ?? null
}
getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null {
const webContentsId = this.webContentsIdByTabId.get(browserPageId)
return webContentsId === undefined
? null
: (this.loadErrorsByGuestId.get(webContentsId) ?? null)
}
getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null {
return this.certificateTrustController?.getFailure(browserPageId) ?? null
}
getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null {
if (this.popupOwnerContextByGuestId.has(webContentsId)) {
return null
}
const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null
const offscreen = this.offscreenGuestIds.has(webContentsId)
if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) {
return null
}
if (!offscreen) {
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') {
return null
}
}
return {
browserPageId,
worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null,
sessionProfileId: browserPageId
? (this.sessionProfileIdByPageId.get(browserPageId) ?? null)
: null,
owner: offscreen ? 'offscreen' : 'desktop-webview'
}
}
// Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it.
private buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError {
return {
code,
description,
validatedUrl: redactKagiSessionToken(rawUrl)
}
}
notifyCertificateFailureChanged(
webContentsId: number,
failure: BrowserCertificateFailure | null,
navigationUrl?: string
): void {
if (failure && navigationUrl) {
const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, navigationUrl)
this.loadErrorsByGuestId.set(webContentsId, loadError)
this.forwardOrQueueGuestLoadFailure(webContentsId, loadError)
}
const browserPageId = this.tabIdByWebContentsId.get(webContentsId)
if (!browserPageId) {
return
}
if (this.offscreenGuestIds.has(webContentsId)) {
this.notifyBrowserGuestStateChanged(webContentsId)
return
}
const renderer = this.resolveRendererForBrowserTab(browserPageId)
renderer?.send('browser:certificate-failure-changed', { browserPageId, failure })
}
private notifyBrowserGuestStateChanged(webContentsId: number): void {
if (!this.offscreenGuestIds.has(webContentsId)) {
return
}
const browserPageId = this.tabIdByWebContentsId.get(webContentsId)
const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null
if (worktreeId) {
// Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception.
try {
this.browserGuestStateChangedListener?.(worktreeId)
} catch (error) {
console.error('[browser-manager] browserGuestStateChanged listener failed', error)
}
}
}
notifyPermissionDenied(args: {
guestWebContentsId: number
permission: string
rawUrl: string
}): void {
this.forwardOrQueuePermissionDenied(args.guestWebContentsId, {
permission: args.permission,
origin: safeOrigin(args.rawUrl)
})
}
handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void {
const { guestWebContentsId, item } = args
const downloadId = randomUUID()
const requestedFilename = (() => {
try {
return item.getFilename() || 'download'
} catch {
return 'download'
}
})()
const totalBytes = (() => {
try {
const total = item.getTotalBytes()
return total > 0 ? total : null
} catch {
return null
}
})()
const mimeType = (() => {
try {
const mime = item.getMimeType()
return mime || null
} catch {
return null
}
})()
const origin = (() => {
try {
return safeOrigin(item.getURL())
} catch {
return 'unknown'
}
})()
const destination = (() => {
try {
return browserDownloadDestinationReservations.reserve(requestedFilename)
} catch (error) {
console.error('[browser-download] Failed to choose download destination:', error)
return null
}
})()
const fallbackSavePath = destination?.savePath ?? ''
const download: ActiveDownload = {
downloadId,
guestWebContentsId,
browserTabId: null,
rendererWebContentsId: null,
origin,
filename: destination?.filename ?? requestedFilename,
totalBytes,
mimeType,
item,
savePath: fallbackSavePath,
reservationKey: destination?.reservationKey ?? null,
receivedBytes: 0,
transientState: null,
terminalEvent: null,
startedSent: false,
cleanup: null
}
this.downloadsById.set(downloadId, download)
const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
if (browserTabId) {
this.bindDownloadToTab(downloadId, browserTabId)
} else {
const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? []
pending.push(downloadId)
this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending)
}
if (!destination) {
this.finishDownloadInternal(downloadId, 'failed', 'Could not choose a Downloads file name.')
try {
item.cancel()
} catch {
// Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure.
}
return
}
try {
item.setSavePath(destination.savePath)
} catch (error) {
console.error('[browser-download] Failed to set download destination:', error)
this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.')
try {
item.cancel()
} catch {
// Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal.
}
return
}
const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => {
download.receivedBytes = this.getDownloadReceivedBytes(download.item)
download.transientState = state
this.sendDownloadProgress(download.browserTabId, {
browserPageId: download.browserTabId ?? undefined,
downloadId: download.downloadId,
receivedBytes: download.receivedBytes,
totalBytes: download.totalBytes,
state
})
}
const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => {
const status: BrowserDownloadFinishedEvent['status'] =
state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed'
this.finishDownloadInternal(
download.downloadId,
status,
status === 'failed'
? state === 'interrupted'
? 'Download was interrupted.'
: 'Download failed.'
: null
)
}
download.cleanup = (): void => {
try {
download.item.off('updated', updatedHandler)
download.item.off('done', doneHandler)
} catch {
// Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main.
}
}
item.on('updated', updatedHandler)
item.once('done', doneHandler)
if (browserTabId) {
this.sendDownloadStarted(downloadId)
}
}
cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean {
const download = this.downloadsById.get(args.downloadId)
if (!download || download.rendererWebContentsId !== args.senderWebContentsId) {
return false
}
this.cancelDownloadInternal(args.downloadId, 'Canceled.')
return true
}
// Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup.
async openDevTools(browserTabId: string): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
guest.openDevTools({ mode: 'detach' })
return true
}
// Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared.
async setViewportOverride(
browserTabId: string,
override: BrowserViewportOverride | null
): Promise<boolean> {
// Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins.
const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetViewportOverrideImpl(browserTabId, override))
this.viewportOpsByTabId.set(browserTabId, next)
try {
return await next
} finally {
// Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization.
if (this.viewportOpsByTabId.get(browserTabId) === next) {
this.viewportOpsByTabId.delete(browserTabId)
}
}
}
async setAnnotationViewportBridge(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
): Promise<boolean> {
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
} finally {
if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) {
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
}
}
private async doSetAnnotationViewportBridgeImpl(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
try {
// Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it.
await guest.executeJavaScriptInIsolatedWorld(
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
[{ code: buildBrowserAnnotationViewportBridgeScript(options) }],
false
)
return true
} catch {
return false
}
}
private async doSetViewportOverrideImpl(
browserTabId: string,
override: BrowserViewportOverride | null
): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
try {
if (!guest.debugger.isAttached()) {
guest.debugger.attach('1.3')
}
} catch (err) {
// Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable.
console.warn('[browser-manager] setViewportOverride: failed to attach debugger', {
browserTabId,
webContentsId,
error: err instanceof Error ? err.message : String(err)
})
return false
}
const dbg = guest.debugger
try {
if (override) {
await dbg.sendCommand('Emulation.setDeviceMetricsOverride', {
width: override.width,
height: override.height,
deviceScaleFactor: override.deviceScaleFactor,
mobile: override.mobile
})
await dbg.sendCommand('Emulation.setTouchEmulationEnabled', {
enabled: override.mobile,
maxTouchPoints: override.mobile ? 5 : 0
})
// Why: viewport sizing must not override a profile's explicit native-UA identity.
if (this.userAgentModeByPageId.get(browserTabId) !== 'native') {
// Navigation must see the preset intent while the final CDP command is in flight.
this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile)
// Why: same sender as the navigation path, so both resolve the tab's host identically.
await this.sendViewportUserAgentOverride(guest, override.mobile)
}
} else {
await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {})
await dbg.sendCommand('Emulation.setTouchEmulationEnabled', {
enabled: false,
maxTouchPoints: 0
})
const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId)
// A navigation after this point must not re-install the override behind the clear.
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
// Why: passing an empty string restores the session default UA.
try {
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
} catch (error) {
if (trackedMobile !== undefined) {
this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile)
}
throw error
}
}
return true
} catch {
return false
}
}
// --- Browser Context Grab — main-owned operations ---
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
getAuthorizedGuest(
browserTabId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
return null
}
const guestId = this.webContentsIdByTabId.get(browserTabId)
if (guestId == null) {
return null
}
const guest = webContents.fromId(guestId)
if (!guest || guest.isDestroyed()) {
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return null
}
return guest
}
/** Returns true if a grab operation is currently active for this tab. */
hasActiveGrabOp(browserTabId: string): boolean {
return this.grabSessionController.hasActiveGrabOp(browserTabId)
}
/** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */
async setGrabMode(
browserTabId: string,
enabled: boolean,
guest: Electron.WebContents
): Promise<boolean> {
if (!enabled) {
const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId)
this.cancelGrabOp(browserTabId, 'user')
if (hadActiveGrabOp) {
return true
}
try {
await guest.executeJavaScript(buildGuestOverlayScript('teardown'))
return true
} catch {
return false
}
}
// Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe.
try {
await guest.executeJavaScript(buildGuestOverlayScript('arm'))
return true
} catch {
return false
}
}
/**
* Await a single grab selection on the given tab; resolves once on click, cancel, or error.
*
* Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click.
*/
awaitGrabSelection(
browserTabId: string,
opId: string,
guest: Electron.WebContents
): Promise<BrowserGrabResult> {
return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest)
}
/** Cancel an active grab operation for the given tab. */
cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void {
this.grabSessionController.cancelGrabOp(browserTabId, reason)
}
/** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */
async captureSelectionScreenshot(
_browserTabId: string,
rect: BrowserGrabRect,
guest: Electron.WebContents
): Promise<BrowserGrabScreenshot | null> {
return captureGrabSelectionScreenshot(rect, guest)
}
/** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */
async extractHoverPayload(
_browserTabId: string,
guest: Electron.WebContents
): Promise<BrowserGrabPayload | null> {
try {
const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover'))
if (!rawPayload || typeof rawPayload !== 'object') {
return null
}
return clampGrabPayload(rawPayload)
} catch {
return null
}
}
private setupContextMenu(browserTabId: string, guest: Electron.WebContents): void {
this.contextMenuCleanupByTabId.set(
browserTabId,
setupGuestContextMenu({
browserTabId,
guest,
resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId)
})
)
}
// Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works.
private setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void {
const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId)
if (previousCleanup) {
previousCleanup()
this.grabShortcutCleanupByTabId.delete(browserTabId)
}
this.grabShortcutCleanupByTabId.set(
browserTabId,
setupGrabShortcutForwarding({
browserTabId,
guest,
resolveRenderer: (tabId) =>
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId),
hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId),
getKeybindings: () => this.settingsResolver?.().keybindings
})
)
}
// Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts.
private setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void {
const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId)
if (previousCleanup) {
previousCleanup()
this.shortcutForwardingCleanupByTabId.delete(browserTabId)
}
this.shortcutForwardingCleanupByTabId.set(
browserTabId,
setupGuestShortcutForwarding({
browserTabId,
guest,
resolveRenderer: (tabId) =>
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId),
shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false,
isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false,
getKeybindings: () => this.settingsResolver?.().keybindings,
resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null,
resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null
})
)
}
private setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void {
const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId)
if (previousCleanup) {
previousCleanup()
this.mouseWheelZoomCleanupByTabId.delete(browserTabId)
}
this.mouseWheelZoomCleanupByTabId.set(
browserTabId,
setupGuestMouseWheelZoomForwarding({
browserTabId,
guest,
resolveRenderer: (tabId) =>
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId)
})
)
}
private forwardOrQueueGuestLoadFailure(
guestWebContentsId: number,
loadError: { code: number; description: string; validatedUrl: string }
): void {
const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId)
if (!browserTabId) {
// Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it.
this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError)
return
}
this.sendGuestLoadFailure(browserTabId, loadError)
}
private forwardOrQueuePermissionDenied(
guestWebContentsId: number,
event: PendingPermissionEvent
): void {
const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
if (!browserTabId) {
const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? []
pending.push(event)
if (pending.length > 5) {
pending.shift()
}
this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending)
return
}
this.sendPermissionDenied(browserTabId, event)
}
private flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void {
const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId)
if (!pending?.length) {
return
}
this.pendingPermissionEventsByGuestId.delete(guestWebContentsId)
for (const event of pending) {
this.sendPermissionDenied(browserTabId, event)
}
}
private sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void {
const renderer = this.resolveRendererForBrowserTab(browserTabId)
if (!renderer) {
return
}
renderer.send('browser:permission-denied', {
browserPageId: browserTabId,
...event
} satisfies BrowserPermissionDeniedEvent)
}
private forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void {
const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
if (!browserTabId) {
const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? []
pending.push(event)
if (pending.length > 5) {
pending.shift()
}
this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending)
return
}
this.sendPopupEvent(browserTabId, event)
}
private flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void {
const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId)
if (!pending?.length) {
return
}
this.pendingPopupEventsByGuestId.delete(guestWebContentsId)
for (const event of pending) {
this.sendPopupEvent(browserTabId, event)
}
}
private sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void {
const renderer = this.resolveRendererForBrowserTab(browserTabId)
if (!renderer) {
return
}
renderer.send('browser:popup', {
browserPageId: browserTabId,
...event
} satisfies BrowserPopupEvent)
}
private bindDownloadToTab(downloadId: string, browserTabId: string): void {
const download = this.downloadsById.get(downloadId)
if (!download) {
return
}
download.browserTabId = browserTabId
download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null
}
private flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void {
const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId)
if (!pending?.length) {
return
}
this.pendingDownloadIdsByGuestId.delete(guestWebContentsId)
for (const downloadId of pending) {
this.bindDownloadToTab(downloadId, browserTabId)
this.flushDownloadSnapshot(downloadId)
}
}
private flushDownloadSnapshot(downloadId: string): void {
const download = this.downloadsById.get(downloadId)
if (!download) {
return
}
this.sendDownloadStarted(downloadId)
if (download.receivedBytes > 0 || download.transientState) {
this.sendDownloadProgress(download.browserTabId, {
browserPageId: download.browserTabId ?? undefined,
downloadId: download.downloadId,
receivedBytes: download.receivedBytes,
totalBytes: download.totalBytes,
state: download.transientState
})
}
if (download.terminalEvent) {
this.sendDownloadFinished(download.browserTabId, {
...download.terminalEvent,
browserPageId: download.browserTabId ?? undefined
})
this.downloadsById.delete(downloadId)
}
}
private sendDownloadStarted(downloadId: string): void {
const download = this.downloadsById.get(downloadId)
if (!download?.browserTabId) {
return
}
if (download.startedSent) {
return
}
const renderer = this.resolveRendererForBrowserTab(download.browserTabId)
if (!renderer) {
return
}
renderer.send('browser:download-requested', {
browserPageId: download.browserTabId,
downloadId: download.downloadId,
origin: download.origin,
filename: download.filename,
totalBytes: download.totalBytes,
mimeType: download.mimeType,
savePath: download.savePath,
status: 'downloading'
} satisfies BrowserDownloadRequestedEvent)
download.startedSent = true
}
private sendDownloadProgress(
browserTabId: string | null,
payload: BrowserDownloadProgressEvent
): void {
if (!browserTabId) {
return
}
const renderer = this.resolveRendererForBrowserTab(browserTabId)
if (!renderer) {
return
}
renderer.send('browser:download-progress', payload)
}
private sendDownloadFinished(
browserTabId: string | null,
payload: BrowserDownloadFinishedEvent
): void {
if (!browserTabId) {
return
}
const renderer = this.resolveRendererForBrowserTab(browserTabId)
if (!renderer) {
return
}
renderer.send('browser:download-finished', payload)
}
private cancelDownloadInternal(downloadId: string, reason: string): void {
const download = this.downloadsById.get(downloadId)
if (!download) {
return
}
if (download.cleanup) {
download.cleanup()
download.cleanup = null
}
const shouldSendCancel = !download.terminalEvent
try {
download.item.cancel()
} catch {
// Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative.
}
if (shouldSendCancel) {
this.finishDownloadInternal(downloadId, 'canceled', reason || null)
return
}
this.downloadsById.delete(downloadId)
}
private finishDownloadInternal(
downloadId: string,
status: BrowserDownloadFinishedEvent['status'],
error: string | null
): void {
const download = this.downloadsById.get(downloadId)
if (!download || download.terminalEvent) {
return
}
if (download.cleanup) {
download.cleanup()
download.cleanup = null
}
browserDownloadDestinationReservations.release(download.reservationKey)
download.reservationKey = null
const event: BrowserDownloadFinishedEvent = {
browserPageId: download.browserTabId ?? undefined,
downloadId: download.downloadId,
status,
savePath: download.savePath || null,
error
}
download.terminalEvent = event
if (download.browserTabId) {
this.sendDownloadStarted(downloadId)
this.sendDownloadFinished(download.browserTabId, event)
this.downloadsById.delete(downloadId)
}
}
private cancelPendingDownloadsForGuest(guestWebContentsId: number): void {
const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId)
this.pendingDownloadIdsByGuestId.delete(guestWebContentsId)
if (!pending?.length) {
return
}
for (const downloadId of pending) {
const download = this.downloadsById.get(downloadId)
if (!download) {
continue
}
if (download.terminalEvent) {
this.downloadsById.delete(downloadId)
continue
}
this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.')
const afterCancel = this.downloadsById.get(downloadId)
if (afterCancel?.terminalEvent && !afterCancel.browserTabId) {
this.downloadsById.delete(downloadId)
}
}
}
private getDownloadReceivedBytes(item: Electron.DownloadItem): number {
try {
return Math.max(0, item.getReceivedBytes())
} catch {
return 0
}
}
private flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void {
const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId)
if (!pending) {
return
}
this.pendingLoadFailuresByGuestId.delete(guestWebContentsId)
this.sendGuestLoadFailure(browserTabId, pending)
}
private sendGuestLoadFailure(
browserTabId: string,
loadError: { code: number; description: string; validatedUrl: string }
): void {
const renderer = this.resolveRendererForBrowserTab(browserTabId)
if (!renderer) {
return
}
// Why: redact Kagi session tokens before the renderer persists validatedUrl to disk.
renderer.send('browser:guest-load-failed', {
browserPageId: browserTabId,
loadError: {
...loadError,
validatedUrl: redactKagiSessionToken(loadError.validatedUrl)
}
})
}
private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean {
const renderer = this.resolveRendererForBrowserTab(browserTabId)
if (!renderer) {
return false
}
const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl)
if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) {
return false
}
// Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it.
renderer.send('browser:open-link-in-orca-tab', {
browserPageId: browserTabId,
url: normalizedUrl
})
return true
}
}
export const browserManager = new BrowserManager()
export const browserCertificateTrustController = new BrowserCertificateTrustController({
resolveManagedGuestContext: (webContentsId) =>
browserManager.getManagedBrowserGuestContext(webContentsId),
resolveWebContentsIdForPage: (browserPageId) =>
browserManager.getGuestWebContentsId(browserPageId),
resolveWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null,
onFailureChanged: (webContentsId, failure, navigationUrl) =>
browserManager.notifyCertificateFailureChanged(webContentsId, failure, navigationUrl)
})
browserManager.setCertificateTrustController(browserCertificateTrustController)