| 1 | import { WebContentsView, session as electronSession, BrowserWindow, screen, type Session, type WebContents, type WebPreferences, type MouseInputEvent } from "electron"; |
| 2 | import { disposeDebugger } from "./debuggerLease.js"; |
| 3 | import { browserFailure } from "./errors.js"; |
| 4 | import { abortable } from "./captureQueue.js"; |
| 5 | import { DiagnosticBuffer } from "./diagnostics.js"; |
| 6 | import { viewportScale, type BrowserViewport } from "./viewport.js"; |
| 7 | import { dispatchMouseInput } from "./mouseInput.js"; |
| 8 | import type { Logger } from "../log.js"; |
| 9 | import { isBlockedNavigation, isPopupURL, type GuestView, type GuestViewEvents, type GuestViewFactory } from "./guestView.js"; |
| 10 | |
| 11 | const GUEST_PERMISSIONS = new Set(["clipboard-sanitized-write", "fullscreen"]); |
| 12 | |
| 13 | export interface ElectronGuestViewDeps { |
| 14 | window(): BrowserWindow | null; |
| 15 | preloadPath: string; |
| 16 | log: Logger; |
| 17 | onSession?(partition: string, session: Session): void; |
| 18 | } |
| 19 | |
| 20 | // Website views are untrusted: sandboxed, isolated, no Node, only the guest |
| 21 | // preload that reports user input. Every partition session gets the same |
| 22 | // permission policy the first time it is seen. |
| 23 | export class ElectronGuestViewFactory implements GuestViewFactory { |
| 24 | private readonly sessions = new Set<string>(); |
| 25 | private readonly diagnosticBuffers = new Map<number, DiagnosticBuffer>(); |
| 26 | |
| 27 | constructor(private readonly deps: ElectronGuestViewDeps) {} |
| 28 | |
| 29 | create(partition: string, inherited?: WebPreferences): GuestView { |
| 30 | const win = this.deps.window(); |
| 31 | if (!win) throw new Error("browser tabs need the main window"); |
| 32 | this.prepareSession(partition); |
| 33 | const view = new WebContentsView({ |
| 34 | webPreferences: { |
| 35 | ...inherited, |
| 36 | partition, |
| 37 | preload: this.deps.preloadPath, |
| 38 | sandbox: true, |
| 39 | contextIsolation: true, |
| 40 | nodeIntegration: false, |
| 41 | nodeIntegrationInSubFrames: false, |
| 42 | nodeIntegrationInWorker: false, |
| 43 | webviewTag: false, |
| 44 | spellcheck: true, |
| 45 | backgroundThrottling: true, |
| 46 | }, |
| 47 | }); |
| 48 | win.contentView.addChildView(view); |
| 49 | view.setVisible(false); |
| 50 | const guest = new ElectronGuestView(view, win, this, this.deps.log); |
| 51 | this.diagnosticBuffers.set(view.webContents.id, guest.diagnostics); |
| 52 | const id = view.webContents.id; |
| 53 | view.webContents.once("destroyed", () => this.diagnosticBuffers.delete(id)); |
| 54 | return guest; |
| 55 | } |
| 56 | |
| 57 | private prepareSession(partition: string): void { |
| 58 | if (this.sessions.has(partition)) return; |
| 59 | this.sessions.add(partition); |
| 60 | const session = electronSession.fromPartition(partition); |
| 61 | session.setPermissionRequestHandler((_contents, permission, callback) => callback(GUEST_PERMISSIONS.has(permission))); |
| 62 | session.setPermissionCheckHandler((_contents, permission) => GUEST_PERMISSIONS.has(permission)); |
| 63 | session.webRequest.onCompleted(details => { |
| 64 | if (details.statusCode >= 400) this.diagnosticBuffers.get(details.webContentsId ?? -1)?.add({ kind: "network", message: `HTTP ${details.statusCode}`, status: details.statusCode, url: details.url }); |
| 65 | }); |
| 66 | session.webRequest.onErrorOccurred(details => { |
| 67 | this.diagnosticBuffers.get(details.webContentsId ?? -1)?.add({ kind: "network", message: details.error, url: details.url }); |
| 68 | }); |
| 69 | this.deps.onSession?.(partition, session); |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | class ElectronGuestView implements GuestView { |
| 74 | readonly diagnostics = new DiagnosticBuffer(); |
| 75 | private destroyed = false; |
| 76 | private desiredBounds: Electron.Rectangle = { x: 0, y: 0, width: 1280, height: 720 }; |
| 77 | private desiredVisible = false; |
| 78 | private releaseCapture: (() => void) | null = null; |
| 79 | private observations = 0; |
| 80 | private viewport: BrowserViewport | null = null; |
| 81 | private emulated = false; |
| 82 | private captureHost: BrowserWindow | null = null; |
| 83 | |
| 84 | constructor( |
| 85 | private readonly view: WebContentsView, |
| 86 | private readonly win: BrowserWindow, |
| 87 | private readonly factory: GuestViewFactory, |
| 88 | private readonly log: Logger, |
| 89 | ) { |
| 90 | const wc = view.webContents; |
| 91 | const debuggerAPI = wc.debugger; |
| 92 | wc.on("render-process-gone", () => disposeDebugger(debuggerAPI)); |
| 93 | wc.once("destroyed", () => disposeDebugger(debuggerAPI, false)); |
| 94 | debuggerAPI.on("detach", () => disposeDebugger(debuggerAPI, false)); |
| 95 | } |
| 96 | |
| 97 | get page(): WebContents { |
| 98 | return this.view.webContents; |
| 99 | } |
| 100 | |
| 101 | bind(events: GuestViewEvents): void { |
| 102 | const wc = this.view.webContents; |
| 103 | wc.on("dom-ready", () => this.applyViewport()); |
| 104 | wc.on("console-message", details => { |
| 105 | if (details.level === "warning" || details.level === "error") this.diagnostics.add({ kind: /^Uncaught\b/.test(details.message) ? "exception" : "console", message: details.message, url: details.sourceId }); |
| 106 | }); |
| 107 | wc.on("did-start-loading", () => events.onStartLoading()); |
| 108 | wc.on("did-stop-loading", () => events.onStopLoading()); |
| 109 | wc.on("did-navigate", (_event, url) => events.onNavigate(url, false)); |
| 110 | wc.on("did-navigate-in-page", (_event, url, isMainFrame) => { |
| 111 | if (isMainFrame) events.onNavigate(url, true); |
| 112 | }); |
| 113 | wc.on("page-title-updated", (_event, title) => events.onTitle(title)); |
| 114 | wc.on("did-fail-load", (_event, code, description, url, isMainFrame) => { |
| 115 | if (code !== -3) this.diagnostics.add({ kind: "navigation", message: description, url }); |
| 116 | if (isMainFrame && code !== -3) events.onFailLoad(code, description, url); |
| 117 | }); |
| 118 | wc.on("render-process-gone", (_event, details) => events.onRenderProcessGone(details.reason)); |
| 119 | wc.on("destroyed", () => events.onDestroyed()); |
| 120 | wc.on("will-navigate", (details) => this.guardNavigation(details, details.url)); |
| 121 | wc.on("will-frame-navigate", (details) => this.guardNavigation(details, details.url)); |
| 122 | wc.on("will-redirect", (details) => this.guardNavigation(details, details.url)); |
| 123 | wc.on("will-attach-webview", (event) => event.preventDefault()); |
| 124 | wc.setWindowOpenHandler(({ url, disposition }) => { |
| 125 | if (!isPopupURL(url)) return { action: "deny" }; |
| 126 | const adopt = events.onPopup(url, disposition); |
| 127 | if (!adopt) return { action: "deny" }; |
| 128 | return { |
| 129 | action: "allow", |
| 130 | createWindow: (options) => { |
| 131 | const child = this.factory.create(String(options.webPreferences?.partition ?? ""), options.webPreferences); |
| 132 | adopt(child); |
| 133 | return child.page as WebContents; |
| 134 | }, |
| 135 | }; |
| 136 | }); |
| 137 | } |
| 138 | |
| 139 | private guardNavigation(details: { preventDefault(): void }, url: string): void { |
| 140 | if (!isBlockedNavigation(url)) return; |
| 141 | details.preventDefault(); |
| 142 | this.log.warn(`blocked browser navigation to ${url.slice(0, 120)}`); |
| 143 | } |
| 144 | |
| 145 | setBounds(bounds: Electron.Rectangle): void { |
| 146 | this.desiredBounds = bounds; |
| 147 | if (!this.destroyed && !this.releaseCapture) this.view.setBounds(bounds); |
| 148 | if (!this.releaseCapture) this.applyViewport(); |
| 149 | } |
| 150 | |
| 151 | inputScale(): number { return (this.page.getZoomFactor() || 1) * (this.captureHost && this.viewport ? 1 : viewportScale(this.viewport, this.view.getBounds())); } |
| 152 | capturePixelRatio(): number { return screen.getDisplayMatching((this.captureHost ?? this.win).getBounds()).scaleFactor; } |
| 153 | |
| 154 | setViewport(viewport: BrowserViewport | null): void { |
| 155 | this.viewport = viewport; |
| 156 | this.applyViewport(); |
| 157 | } |
| 158 | |
| 159 | private applyViewport(): void { |
| 160 | if (this.destroyed || this.page.isDestroyed()) return; |
| 161 | if (!this.page.getURL()) return; |
| 162 | if (!this.viewport) { |
| 163 | if (this.emulated) this.page.disableDeviceEmulation(); |
| 164 | this.emulated = false; |
| 165 | return; |
| 166 | } |
| 167 | const { width, height } = this.viewport; |
| 168 | this.page.setZoomFactor(1); |
| 169 | this.page.enableDeviceEmulation({ screenPosition: "desktop", screenSize: { width, height }, deviceScaleFactor: 1, viewSize: { width, height }, viewPosition: { x: 0, y: 0 }, scale: this.captureHost ? 1 : viewportScale(this.viewport, this.view.getBounds()) }); |
| 170 | this.emulated = true; |
| 171 | } |
| 172 | |
| 173 | setVisible(visible: boolean): void { |
| 174 | this.desiredVisible = visible; |
| 175 | if (!this.destroyed && !this.releaseCapture) this.view.setVisible(visible); |
| 176 | } |
| 177 | |
| 178 | presentForUser(): void { this.releaseCapture?.(); } |
| 179 | captureSurfaceSize(): { width: number; height: number } { return this.view.getBounds(); } |
| 180 | async sendMouseInput(event: MouseInputEvent, verify?: () => void): Promise<void> { |
| 181 | await dispatchMouseInput(this.page, event, () => this.inputScale(), verify); |
| 182 | } |
| 183 | prepareObservation(): () => void { |
| 184 | this.observations++; |
| 185 | this.page.setBackgroundThrottling(false); |
| 186 | let released = false; |
| 187 | return () => { if (released) return; released = true; this.observations--; if (!this.page.isDestroyed() && !this.releaseCapture && this.observations === 0) this.page.setBackgroundThrottling(true); }; |
| 188 | } |
| 189 | |
| 190 | async prepareCapture(signal: AbortSignal, recording = false): Promise<() => void> { |
| 191 | signal.throwIfAborted(); |
| 192 | if (this.destroyed || this.page.isDestroyed()) throw browserFailure("surface_unavailable", "page was closed"); |
| 193 | if ((!recording || process.platform !== "darwin") && this.desiredVisible && this.win.isVisible() && !this.win.isMinimized() && this.desiredBounds.width > 0 && this.desiredBounds.height > 0) return () => {}; |
| 194 | // Background capture is qualified independently for each native platform. |
| 195 | if (process.platform !== "darwin") throw browserFailure("capability_unsupported", "background capture is not qualified on this platform; show the page and retry"); |
| 196 | if (this.releaseCapture) return () => {}; // The recording owns the existing host. |
| 197 | const width = this.viewport?.width ?? (this.desiredBounds.width > 0 ? this.desiredBounds.width : 1280); |
| 198 | const height = this.viewport?.height ?? (this.desiredBounds.height > 0 ? this.desiredBounds.height : 720); |
| 199 | const host = new BrowserWindow({ width, height, useContentSize: true, show: false, frame: false, focusable: false, skipTaskbar: true, opacity: 0, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } }); |
| 200 | this.captureHost = host; |
| 201 | let released = false; |
| 202 | const release = () => { |
| 203 | if (released) return; |
| 204 | released = true; |
| 205 | signal.removeEventListener("abort", release); |
| 206 | if (!host.isDestroyed()) host.contentView.removeChildView(this.view); |
| 207 | this.releaseCapture = null; |
| 208 | this.captureHost = null; |
| 209 | if (!this.destroyed && !this.page.isDestroyed()) { |
| 210 | this.page.setBackgroundThrottling(this.observations === 0); |
| 211 | if (!this.win.isDestroyed()) { |
| 212 | this.win.contentView.addChildView(this.view); |
| 213 | this.view.setBounds(this.desiredBounds); |
| 214 | this.view.setVisible(this.desiredVisible); |
| 215 | this.applyViewport(); |
| 216 | } |
| 217 | } |
| 218 | if (!host.isDestroyed()) host.destroy(); |
| 219 | }; |
| 220 | this.releaseCapture = release; |
| 221 | signal.addEventListener("abort", release, { once: true }); |
| 222 | const preparation = new AbortController(); |
| 223 | const preparationSignal = AbortSignal.any([signal, preparation.signal]); |
| 224 | const preparationTimer = setTimeout(() => preparation.abort(browserFailure("surface_unavailable", "capture surface preparation exceeded 5000ms")), 5000); |
| 225 | try { |
| 226 | this.win.contentView.removeChildView(this.view); |
| 227 | host.contentView.addChildView(this.view); |
| 228 | this.view.setBounds({ x: 0, y: 0, width, height }); |
| 229 | this.applyViewport(); |
| 230 | this.view.setVisible(true); |
| 231 | this.page.setBackgroundThrottling(false); |
| 232 | host.setIgnoreMouseEvents(true); |
| 233 | host.showInactive(); |
| 234 | const deadline = Date.now() + 5000; |
| 235 | // Probe the compositor, rather than treating showInactive or elapsed time |
| 236 | // as readiness. A bounded cadence only schedules the next actual probe. |
| 237 | while (true) { |
| 238 | signal.throwIfAborted(); |
| 239 | try { |
| 240 | const probe = await abortable(this.page.capturePage({ x: 0, y: 0, width: 1, height: 1 }, { stayHidden: true, stayAwake: true }), preparationSignal); |
| 241 | if (!probe.isEmpty()) return release; |
| 242 | } catch (error) { if (preparationSignal.aborted) throw error; } |
| 243 | if (Date.now() >= deadline) throw browserFailure("surface_unavailable", "capture surface preparation exceeded 5000ms"); |
| 244 | await abortable(new Promise<void>((resolve) => setTimeout(resolve, 16)), signal); |
| 245 | } |
| 246 | } catch (error) { release(); throw error; } |
| 247 | finally { clearTimeout(preparationTimer); } |
| 248 | } |
| 249 | |
| 250 | // Detach first so the window never paints a closing view, then close the |
| 251 | // WebContents; the prototype's quit order that never left orphans. |
| 252 | destroy(): void { |
| 253 | if (this.destroyed) return; |
| 254 | this.destroyed = true; |
| 255 | this.releaseCapture?.(); |
| 256 | if (!this.win.isDestroyed()) { |
| 257 | try { |
| 258 | this.win.contentView.removeChildView(this.view); |
| 259 | } catch (error) { |
| 260 | this.log.warn(`removeChildView failed: ${String(error)}`); |
| 261 | } |
| 262 | } |
| 263 | const wc = this.view.webContents; |
| 264 | if (!wc.isDestroyed()) { disposeDebugger(wc.debugger); wc.close(); } |
| 265 | } |
| 266 | } |
| 267 |