1887 lines
70 KiB
TypeScript
1887 lines
70 KiB
TypeScript
/* eslint-disable max-lines -- Why: BrowserManager intentionally remains the
|
|
single privileged facade for guest registration, authorization, and lifecycle
|
|
cleanup even after extracting the grab/session helpers. Keeping that ownership
|
|
in one file avoids scattering the browser security boundary across modules. */
|
|
import { randomUUID } from 'node:crypto'
|
|
|
|
import { shell, webContents } from 'electron'
|
|
import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants'
|
|
import {
|
|
normalizeBrowserNavigationUrl,
|
|
normalizeExternalBrowserUrl,
|
|
redactKagiSessionToken
|
|
} 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 { cleanElectronUserAgent } from './browser-session-ua'
|
|
import type { BrowserViewportOverride } 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'
|
|
|
|
const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000
|
|
|
|
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 renderer creates the lease before waiting for paint; if main's
|
|
// acquire timeout wins, release the eventual token so hidden webviews do
|
|
// not stay paintable indefinitely.
|
|
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
|
|
}
|
|
|
|
// Why: mobile presets need a touch-capable UA or responsive sites serve the
|
|
// desktop variant based on UA sniffing. This is the Chrome DevTools default
|
|
// iPhone UA template; we splice in the guest session's real Chrome major so
|
|
// sec-ch-ua headers (see setupClientHintsOverride) stay consistent.
|
|
function buildMobileUserAgent(chromeMajor: string): string {
|
|
return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1`
|
|
}
|
|
|
|
function extractChromeMajor(ua: string): string {
|
|
const match = ua.match(/Chrome\/(\d+)/)
|
|
return match ? match[1] : '134'
|
|
}
|
|
|
|
export type BrowserGuestRegistration = {
|
|
browserPageId?: string
|
|
browserTabId?: string
|
|
workspaceId?: string
|
|
worktreeId?: string
|
|
sessionProfileId?: string | null
|
|
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
|
|
}
|
|
|
|
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: applied by Electron when it creates the popup's WebContents, before
|
|
// createWindow runs. Feature strings and opener inheritance must not be able
|
|
// to relax the child's process 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 enables O(1) guest→tab lookups instead of O(N) linear
|
|
// scans on every mouse event, load failure, permission, and popup event.
|
|
private readonly tabIdByWebContentsId = new Map<number, string>()
|
|
private readonly popupOwnerContextByGuestId = new Map<number, PopupOwnerContext>()
|
|
// Why: guest registration is keyed by browser page id, but renderer
|
|
// visibility/focus state is keyed by browser workspace id. Screenshot prep
|
|
// has to bridge that 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 rendererWebContentsIdByTabId = new Map<string, number>()
|
|
// Why: chain setViewportOverride calls per tab so rapid toggles don't
|
|
// interleave CDP commands. Without serialization, two concurrent calls can
|
|
// race (e.g. clearDeviceMetricsOverride landing after a later mobile
|
|
// setDeviceMetricsOverride), leaving emulation in an unexpected state.
|
|
private readonly viewportOpsByTabId = new Map<string, Promise<unknown>>()
|
|
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 policyCleanupByGuestId = new Map<number, () => void>()
|
|
private shouldForwardDictationShortcut: (() => boolean) | null = null
|
|
private readonly pendingLoadFailuresByGuestId = new Map<
|
|
number,
|
|
{ code: number; description: string; validatedUrl: string }
|
|
>()
|
|
|
|
setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void {
|
|
this.shouldForwardDictationShortcut = predicate
|
|
}
|
|
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()
|
|
|
|
setSettingsResolver(
|
|
resolver: () => {
|
|
keybindings?: KeybindingOverrides
|
|
mobileEmulatorEnabled?: boolean
|
|
}
|
|
): void {
|
|
this.settingsResolver = resolver
|
|
}
|
|
|
|
// Why: Page.addScriptToEvaluateOnNewDocument (via the CDP debugger) is the
|
|
// only reliable way to run JS before page scripts on every navigation.
|
|
// The previous approach — executeJavaScript on did-start-navigation — ran
|
|
// on the OLD page context during navigation, so overrides were never
|
|
// present when the new page's Turnstile script executed.
|
|
//
|
|
// Returns a cleanup function that removes the detach listener and prevents
|
|
// further re-attach attempts.
|
|
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: the CDP proxy and bridge detach the debugger when they stop,
|
|
// which removes addScriptToEvaluateOnNewDocument injections. Re-attach
|
|
// so manual browsing retains anti-detection overrides after agent
|
|
// sessions end. The 500ms delay avoids racing with the proxy/bridge if
|
|
// it is mid-restart (detach → re-attach).
|
|
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: screenshot sessions target guest page ids, but Orca's visible browser
|
|
// chrome is keyed by workspace ids. If we activate the page id directly, the
|
|
// webview stays hidden under the terminal pane and Page.captureScreenshot
|
|
// times out even though the guest still exists.
|
|
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 browser commands need a paintable webview for lazy-loading
|
|
// sites, but must not steal the user's visible Orca tab/worktree.
|
|
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: Cloudflare Turnstile and similar bot detectors probe browser APIs
|
|
// (navigator.webdriver, plugins, window.chrome) that differ in Electron
|
|
// webviews vs real Chrome. Inject overrides on every page load so manual
|
|
// browsing passes challenges even without the CDP debugger attached.
|
|
const disposeAntiDetection = this.injectAntiDetection(guest)
|
|
|
|
// Why: background throttling must be disabled so agent-driven screenshots
|
|
// (Page.captureScreenshot via CDP proxy) can capture frames even when the
|
|
// Orca window is not the focused foreground app. With throttling enabled,
|
|
// the compositor stops producing frames and capturePage() returns empty.
|
|
guest.setBackgroundThrottling(false)
|
|
const handleDidCreateWindow = (window: Electron.BrowserWindow): void => {
|
|
// Why: popup descendants inherit the opener's owner context for routing,
|
|
// but must not replace its primary guest registration.
|
|
this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id))
|
|
}
|
|
guest.on('did-create-window', handleDidCreateWindow)
|
|
guest.setWindowOpenHandler(({ url }) => {
|
|
const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
|
|
const browserUrl = normalizeBrowserNavigationUrl(url)
|
|
const externalUrl = normalizeExternalBrowserUrl(url)
|
|
|
|
// Why: file URLs are valid for user-opened in-pane previews, but remote
|
|
// content must not create native child windows targeting local paths.
|
|
const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL)
|
|
if (browserTabId && canOpenAsChild) {
|
|
// Why: OAuth may request ordinary size/position features, but browser
|
|
// content must not create deceptive or inescapable native chrome.
|
|
return {
|
|
action: 'allow',
|
|
overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS,
|
|
// Why: a default child window has no address bar, so users cannot
|
|
// verify a popup's destination. Host it in an Orca window with an
|
|
// origin bar while keeping the shared session + window.opener.
|
|
createWindow: (options: PopupChildWindowOptions) =>
|
|
this.createPopupChildWindowWithOriginBar(guest, url, options)
|
|
}
|
|
} else if (externalUrl) {
|
|
// Why: a target=_blank click on a Kagi search result page produces a
|
|
// popup URL that still contains the bearer token; redact before
|
|
// handing the URL to the OS default browser.
|
|
void shell.openExternal(redactKagiSessionToken(externalUrl))
|
|
this.forwardOrQueuePopupEvent(guest.id, {
|
|
origin: safeOrigin(externalUrl),
|
|
action: 'opened-external'
|
|
})
|
|
} else {
|
|
// Why: popup attempts can carry auth redirects and one-time tokens.
|
|
// Surface only sanitized origin metadata so the renderer can explain
|
|
// the blocked action without persisting sensitive URL details.
|
|
this.forwardOrQueuePopupEvent(guest.id, {
|
|
origin: safeOrigin(url),
|
|
action: 'blocked'
|
|
})
|
|
}
|
|
return { action: 'deny' }
|
|
})
|
|
|
|
const navigationGuard = (event: Electron.Event, url: string): void => {
|
|
// Why: blob: URLs are same-origin (inherit the creator's origin) and are
|
|
// used by Cloudflare Turnstile to load challenge resources inside iframes.
|
|
// Blocking them triggers error 600010 ("bot behavior detected"). Only
|
|
// allow blobs whose embedded origin is http(s) to maintain defense-in-depth
|
|
// against blob:null or other opaque-origin blobs.
|
|
if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) {
|
|
return
|
|
}
|
|
// Why: file:// is permitted at `will-attach-webview` so the preview pane
|
|
// can render local HTML the user explicitly opened. After that initial
|
|
// load, a page must not be able to redirect the guest to file:// — that
|
|
// would let a remote page probe the local filesystem. Keep the in-guest
|
|
// navigation guard strict even though initial attach is permissive.
|
|
if (url.startsWith('file:')) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
if (!normalizeBrowserNavigationUrl(url)) {
|
|
// Why: `will-attach-webview` only validates the initial src. Main must
|
|
// keep enforcing the same allowlist for later guest navigations too.
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
const didFailLoadHandler = (
|
|
_event: Electron.Event,
|
|
errorCode: number,
|
|
errorDescription: string,
|
|
validatedURL: string,
|
|
isMainFrame: boolean
|
|
): void => {
|
|
if (!isMainFrame || errorCode === -3) {
|
|
return
|
|
}
|
|
this.forwardOrQueueGuestLoadFailure(guest.id, {
|
|
code: errorCode,
|
|
description: errorDescription || 'This site could not be reached.',
|
|
validatedUrl: validatedURL || guest.getURL() || 'about:blank'
|
|
})
|
|
}
|
|
|
|
guest.on('will-navigate', navigationGuard)
|
|
guest.on('will-redirect', navigationGuard)
|
|
guest.on('did-fail-load', didFailLoadHandler)
|
|
const handleDestroyed = (): void => {
|
|
// Why: guests can be destroyed before renderer registration. Without
|
|
// this, attach-time policy closures remain retained until app shutdown.
|
|
this.cleanupGuestPolicyAttachment(guest.id)
|
|
}
|
|
guest.on('destroyed', handleDestroyed)
|
|
|
|
// Why: store cleanup so unregisterGuest can remove these listeners when the
|
|
// guest surface is torn down, preventing the callbacks from preventing GC of
|
|
// the underlying WebContents wrapper.
|
|
this.policyCleanupByGuestId.set(guest.id, () => {
|
|
disposeAntiDetection()
|
|
try {
|
|
guest.off('destroyed', handleDestroyed)
|
|
guest.off('did-create-window', handleDidCreateWindow)
|
|
} catch {
|
|
// guest may already be destroyed
|
|
}
|
|
if (!guest.isDestroyed()) {
|
|
guest.off('will-navigate', navigationGuard)
|
|
guest.off('will-redirect', navigationGuard)
|
|
guest.off('did-fail-load', didFailLoadHandler)
|
|
}
|
|
})
|
|
}
|
|
|
|
private createPopupChildWindowWithOriginBar(
|
|
openerGuest: Electron.WebContents,
|
|
targetUrl: string,
|
|
options: PopupChildWindowOptions
|
|
): Electron.WebContents {
|
|
const popup = openPopupWithOriginBar(options, targetUrl)
|
|
// Why: Electron does not emit did-create-window for createWindow-created
|
|
// children, so the opener's policies and routing context attach here.
|
|
this.attachGuestPolicies(
|
|
popup.contentWebContents,
|
|
this.resolvePopupOwnerContext(openerGuest.id)
|
|
)
|
|
this.forwardOrQueuePopupEvent(openerGuest.id, {
|
|
origin: safeOrigin(targetUrl),
|
|
action: 'opened-in-orca'
|
|
})
|
|
// Why: parity with Electron's default child-window lifecycle — closing the
|
|
// owning browser tab must not leave orphaned 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: a browser page can re-register with a new guest id after Chromium
|
|
// swaps renderer processes. Late events from the dead guest must stop
|
|
// resolving to the live page, or stale download/popup/permission callbacks
|
|
// can be delivered to the wrong session after the swap.
|
|
this.cleanupGuestPolicyAttachment(previousWebContentsId)
|
|
this.tabIdByWebContentsId.delete(previousWebContentsId)
|
|
}
|
|
|
|
private cleanupGuestPolicyAttachment(guestWebContentsId: number): void {
|
|
const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId)
|
|
const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId)
|
|
if (policyCleanup) {
|
|
policyCleanup()
|
|
this.policyCleanupByGuestId.delete(guestWebContentsId)
|
|
}
|
|
this.policyAttachedGuestIds.delete(guestWebContentsId)
|
|
this.popupOwnerContextByGuestId.delete(guestWebContentsId)
|
|
// Why: a popup must stop inheriting authorization as soon as its primary
|
|
// owner is retired, even if Chromium has not destroyed the child yet.
|
|
if (isPrimaryGuest) {
|
|
for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) {
|
|
if (owner.rootGuestWebContentsId === guestWebContentsId) {
|
|
this.popupOwnerContextByGuestId.delete(popupGuestId)
|
|
}
|
|
}
|
|
}
|
|
this.pendingLoadFailuresByGuestId.delete(guestWebContentsId)
|
|
this.pendingPermissionEventsByGuestId.delete(guestWebContentsId)
|
|
this.pendingPopupEventsByGuestId.delete(guestWebContentsId)
|
|
this.cancelPendingDownloadsForGuest(guestWebContentsId)
|
|
}
|
|
|
|
registerGuest({
|
|
browserPageId,
|
|
browserTabId: legacyBrowserTabId,
|
|
workspaceId,
|
|
worktreeId,
|
|
sessionProfileId,
|
|
webContentsId,
|
|
rendererWebContentsId
|
|
}: BrowserGuestRegistration): void {
|
|
const browserTabId = browserPageId ?? legacyBrowserTabId
|
|
if (!browserTabId) {
|
|
return
|
|
}
|
|
// Why: re-registering the same browser tab can happen when Chromium swaps
|
|
// or recreates the underlying guest surface. Any active grab is bound to
|
|
// the old guest's listeners and teardown path, so keeping it alive would
|
|
// leave the session attached to a stale webContents until timeout.
|
|
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
|
|
}
|
|
|
|
// Why: the renderer sends webContentsId, which we must not blindly trust.
|
|
// A compromised renderer could send the main window's own webContentsId,
|
|
// causing us to overwrite its setWindowOpenHandler or attach unintended
|
|
// context menus. Only accept genuine webview guest surfaces.
|
|
if (guest.getType() !== 'webview') {
|
|
return
|
|
}
|
|
if (!this.policyAttachedGuestIds.has(webContentsId)) {
|
|
// Why: renderer registration is only the second half of the guest setup.
|
|
// Main must only trust guests that already passed attach-time policy
|
|
// installation; otherwise a trusted renderer could point us at some other
|
|
// arbitrary webview and bypass the intended host-window attach boundary.
|
|
return
|
|
}
|
|
|
|
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)
|
|
this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId)
|
|
if (worktreeId) {
|
|
this.worktreeIdByTabId.set(browserTabId, worktreeId)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
unregisterGuest(browserTabId: string): void {
|
|
// Why: unregistering a guest while a grab is active means the guest is
|
|
// being torn down. Cancel the grab so the renderer gets a clean signal
|
|
// instead of a dangling Promise.
|
|
this.cancelGrabOp(browserTabId, 'evicted')
|
|
|
|
// Why: remove the policy listeners attached in attachGuestPolicies so the
|
|
// callbacks (which close over the guest WebContents) do not prevent GC of
|
|
// the underlying Chromium surface after the guest is destroyed.
|
|
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: browser downloads are transient per-tab chrome. Closing the owning
|
|
// tab must cancel active writes instead of hiding them behind no UI.
|
|
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.worktreeIdByTabId.delete(browserTabId)
|
|
// Why: drop any pending viewport-op chain for this tab so the Map doesn't
|
|
// retain a resolved promise keyed to a destroyed guest.
|
|
this.viewportOpsByTabId.delete(browserTabId)
|
|
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
|
|
}
|
|
|
|
// Why: headless orca serve has no renderer window to mount a <webview>, so its
|
|
// browser pages are backed by main-process offscreen WebContents instead. This
|
|
// registers such a page into the same resolution maps the bridge/screencast/
|
|
// input handlers read, but skips the webview-only guards and the renderer setup
|
|
// (context menu, grab shortcut, etc.) that assume a renderer-hosted guest.
|
|
registerOffscreenGuest({
|
|
browserPageId,
|
|
worktreeId,
|
|
sessionProfileId,
|
|
webContentsId
|
|
}: {
|
|
browserPageId: string
|
|
worktreeId?: string
|
|
sessionProfileId?: string | null
|
|
webContentsId: number
|
|
}): void {
|
|
const guest = webContents.fromId(webContentsId)
|
|
if (!guest || guest.isDestroyed()) {
|
|
return
|
|
}
|
|
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 (worktreeId) {
|
|
this.worktreeIdByTabId.set(browserPageId, worktreeId)
|
|
}
|
|
}
|
|
|
|
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()
|
|
// Why: unregisterGuest only cleans up guests that were registered (have an
|
|
// entry in webContentsIdByTabId). Guests that went through
|
|
// attachGuestPolicies but were never registered still have cleanup closures
|
|
// here — invoke them so their event listeners are removed before clearing.
|
|
for (const cleanup of this.policyCleanupByGuestId.values()) {
|
|
cleanup()
|
|
}
|
|
this.policyCleanupByGuestId.clear()
|
|
this.tabIdByWebContentsId.clear()
|
|
this.popupOwnerContextByGuestId.clear()
|
|
this.worktreeIdByTabId.clear()
|
|
this.sessionProfileIdByPageId.clear()
|
|
this.pendingLoadFailuresByGuestId.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
|
|
}
|
|
|
|
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: without a destination, Chromium must not keep writing invisibly;
|
|
// cancellation remains 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: failing setSavePath can leave Electron in a partially finalized
|
|
// state; cancellation is best-effort after Orca has made the UI 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: completed DownloadItems can already be finalized when cleanup
|
|
// runs. Cleanup must stay best-effort so UI 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: guest browser surfaces are intentionally isolated from Orca's preload
|
|
// bridge, so renderer code cannot directly call Electron WebContents APIs on
|
|
// them. Main owns the devtools escape hatch and only after 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: stale guest discovery must clear every per-tab registry entry,
|
|
// not just the forward/reverse WebContents maps.
|
|
this.unregisterGuest(browserTabId)
|
|
return false
|
|
}
|
|
guest.openDevTools({ mode: 'detach' })
|
|
return true
|
|
}
|
|
|
|
// Why: Electron <webview> guests do not expose Chrome DevTools' device
|
|
// toolbar (Cmd+Shift+M) to the embedding app, so viewport emulation must be
|
|
// driven through CDP directly. We reuse the debugger attachment that
|
|
// injectAntiDetection already established and never detach it here — doing
|
|
// so would clear Page.addScriptToEvaluateOnNewDocument and other per-guest
|
|
// overrides. Passing override=null clears emulation.
|
|
async setViewportOverride(
|
|
browserTabId: string,
|
|
override: BrowserViewportOverride | null
|
|
): Promise<boolean> {
|
|
// Why: chain per-tab so rapid toggles (e.g. user clicking presets quickly)
|
|
// don't interleave CDP commands. Each call waits for the previous one to
|
|
// settle, guaranteeing the last-requested override wins rather than whichever
|
|
// sendCommand sequence happens to finish last.
|
|
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 this call's promise is still the tail. A concurrent
|
|
// later call may have already replaced the entry; deleting would drop the
|
|
// chain and break serialization for the next invocation.
|
|
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: stale guest discovery must clear every per-tab registry entry,
|
|
// not just the forward/reverse WebContents maps.
|
|
this.unregisterGuest(browserTabId)
|
|
return false
|
|
}
|
|
|
|
try {
|
|
// Why: the scroll bridge runs outside the page world so page monkey
|
|
// patches cannot read the per-tab token or tamper with bridge state.
|
|
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: stale guest discovery must clear every per-tab registry entry,
|
|
// not just the forward/reverse WebContents maps.
|
|
this.unregisterGuest(browserTabId)
|
|
return false
|
|
}
|
|
|
|
try {
|
|
if (!guest.debugger.isAttached()) {
|
|
guest.debugger.attach('1.3')
|
|
}
|
|
} catch (err) {
|
|
// Why: DevTools being open on the guest causes attach to throw with
|
|
// "Another debugger is already attached". Silently returning false made
|
|
// this failure mode undiagnosable — surface it via the logger with enough
|
|
// context (tab + webContents ids) to correlate with user reports.
|
|
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
|
|
})
|
|
if (override.mobile) {
|
|
const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent()))
|
|
// Why: pass userAgentMetadata alongside the mobile UA string so
|
|
// sec-ch-ua-mobile / sec-ch-ua-platform client hints match. Without
|
|
// it, session-level desktop client-hints leak through and create a
|
|
// UA/CH mismatch that bot-detection (Cloudflare, Turnstile) flags.
|
|
await dbg.sendCommand('Emulation.setUserAgentOverride', {
|
|
userAgent: buildMobileUserAgent(chromeMajor),
|
|
userAgentMetadata: {
|
|
brands: [
|
|
{ brand: 'Google Chrome', version: chromeMajor },
|
|
{ brand: 'Chromium', version: chromeMajor },
|
|
{ brand: 'Not/A)Brand', version: '24' }
|
|
],
|
|
fullVersionList: [
|
|
{ brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` },
|
|
{ brand: 'Chromium', version: `${chromeMajor}.0.0.0` },
|
|
{ brand: 'Not/A)Brand', version: '24.0.0.0' }
|
|
],
|
|
fullVersion: `${chromeMajor}.0.0.0`,
|
|
platform: 'iOS',
|
|
platformVersion: '17.0',
|
|
architecture: '',
|
|
model: 'iPhone',
|
|
mobile: true
|
|
}
|
|
})
|
|
} else {
|
|
// Why: desktop presets still need the clean (non-Electron) UA so
|
|
// Cloudflare/Turnstile don't flag the session. Passing the cleaned
|
|
// real UA keeps sec-ch-ua consistent with the override.
|
|
await dbg.sendCommand('Emulation.setUserAgentOverride', {
|
|
userAgent: cleanElectronUserAgent(guest.getUserAgent())
|
|
})
|
|
}
|
|
} else {
|
|
await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {})
|
|
await dbg.sendCommand('Emulation.setTouchEmulationEnabled', {
|
|
enabled: false,
|
|
maxTouchPoints: 0
|
|
})
|
|
// Why: passing an empty string restores the session default UA.
|
|
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
|
|
}
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Browser Context Grab — main-owned operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Validates that a caller (identified by sender webContentsId) owns the
|
|
* given 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: stale guest discovery must clear every per-tab registry entry,
|
|
// not just the forward/reverse 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 or disable grab mode for a browser tab. When enabled, injects the
|
|
* overlay runtime into the guest. When disabled, cancels any active grab op.
|
|
*/
|
|
async setGrabMode(
|
|
browserTabId: string,
|
|
enabled: boolean,
|
|
guest: Electron.WebContents
|
|
): Promise<boolean> {
|
|
if (!enabled) {
|
|
this.cancelGrabOp(browserTabId, 'user')
|
|
return true
|
|
}
|
|
// Why: injecting the overlay runtime eagerly on arm lets the hover UI
|
|
// appear instantly when the user starts moving the pointer, rather than
|
|
// adding a visible delay between "click Grab" and "overlay appears".
|
|
// The runtime is idempotent — re-injection on the same page is safe.
|
|
try {
|
|
await guest.executeJavaScript(buildGuestOverlayScript('arm'))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Await a single grab selection on the given tab. Returns a Promise that
|
|
* resolves exactly once when the user clicks, cancels, or an error occurs.
|
|
*
|
|
* Why the click is handled in-guest rather than via main-side interception:
|
|
* Electron's `before-input-event` only fires for keyboard events, not mouse
|
|
* events on guest webContents. The design doc anticipated a main-owned
|
|
* interceptor, but the spike showed this API gap. The fallback (documented
|
|
* in the design doc) is to let the guest overlay's full-viewport hit-catcher
|
|
* consume the click. The overlay calls `stopPropagation()` and
|
|
* `preventDefault()` so the page underneath does not receive the event.
|
|
* This is not a perfect guarantee (capture-phase listeners on window may
|
|
* still fire), but it covers the vast majority of sites.
|
|
*/
|
|
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 and optionally crop it to
|
|
* the given CSS-pixel rect.
|
|
*/
|
|
async captureSelectionScreenshot(
|
|
_browserTabId: string,
|
|
rect: BrowserGrabRect,
|
|
guest: Electron.WebContents
|
|
): Promise<BrowserGrabScreenshot | null> {
|
|
return captureGrabSelectionScreenshot(rect, guest)
|
|
}
|
|
|
|
/**
|
|
* Extract the payload for the currently hovered element without disrupting
|
|
* the active grab overlay or awaitClick listener. Used by keyboard shortcuts
|
|
* that let the user copy content while hovering, before clicking.
|
|
*/
|
|
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: browser grab mode intentionally uses Cmd/Ctrl+C as its entry
|
|
// gesture, but a focused webview guest is a separate Chromium process so
|
|
// the renderer's window-level keydown handler never sees that shortcut.
|
|
// Only forward the chord when Chromium would not perform a normal copy:
|
|
// no editable element is focused and there is no selected text. That keeps
|
|
// native page copy working while still making the grab shortcut reachable
|
|
// from focused web content.
|
|
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 Chromium process — keyboard
|
|
// events go to the guest's own webContents and never fire the renderer's
|
|
// window-level keydown handler or the main window's before-input-event.
|
|
// Intercept common app shortcuts on the guest and forward them to the
|
|
// renderer so they work consistently regardless of which surface has focus.
|
|
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
|
|
})
|
|
)
|
|
}
|
|
|
|
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: some localhost failures happen before the renderer finishes
|
|
// registering which tab owns this guest. Queue the failure by guest ID so
|
|
// registerGuest can replay it instead of silently losing the error state.
|
|
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: DownloadItem.cancel can throw after the item has already
|
|
// finalized. Cleanup here is best-effort because the UI state is the
|
|
// source of truth for whether Orca still considers the request active.
|
|
}
|
|
|
|
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 validated URL is persisted
|
|
// by the renderer into BrowserPage.loadError (saved to disk via the
|
|
// workspace session writer).
|
|
renderer.send('browser:guest-load-failed', {
|
|
browserPageId: browserTabId,
|
|
loadError: {
|
|
...loadError,
|
|
validatedUrl: redactKagiSessionToken(loadError.validatedUrl)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
export const browserManager = new BrowserManager()
|