返回 DeepSeek-Reasonix
electronGuestViews.ts
根目录 / desktop / electron / src / main / browser / electronGuestViews.ts
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
267 lines TYPESCRIPT