返回 DeepSeek-Reasonix
surfaceManager.ts
根目录 / desktop / electron / src / main / browser / surfaceManager.ts
1 import type { Rectangle } from "electron";
2 import type { BrowserLayoutRect, BrowserNavigateTarget, BrowserTabMode, BrowserTabView, BrowserTakeoverKind } from "../../shared/ipc.js";
3 import type { Logger } from "../log.js";
4 import type { GuestView, GuestViewEvents, GuestViewFactory } from "./guestView.js";
5 import { validateViewport, type BrowserViewport } from "./viewport.js";
6 import { browserFailure } from "./errors.js";
7 import { LazyGuestView } from "./lazyGuestView.js";
8 import { abortable } from "./captureQueue.js";
9 import type { RecoveredTab } from "./recoveryStore.js";
10
11 export const SHARED_PARTITION = "persist:browser";
12 export const USER_TASK_ID = "user";
13 export const OPEN_WAIT_MS = 15_000;
14 // Input the shell dispatches for the agent reaches the page as trusted, so
15 // the guest preload reports it like a user; reports inside this window after
16 // a dispatch are the agent's own echo, not a take-over.
17 export const AGENT_INPUT_GRACE_MS = 750;
18 export const MAX_CRASH_RELOADS = 3;
19 export const MIN_ZOOM = 0.25;
20 export const MAX_ZOOM = 5;
21
22 export interface BrowserTab {
23 id: string;
24 taskId: string;
25 sessionId: string;
26 view: GuestView;
27 partition: string;
28 temporary: boolean;
29 epoch: number;
30 // Page/service invalidation, independent of ordinary human control changes.
31 lifecycleEpoch: number;
32 viewportRevision: number;
33 surfaceRevision: number;
34 viewport: BrowserViewport | null;
35 operation?: { id: string; phase: string; message?: string };
36 fileReference?: import("./recoveryStore.js").FileReference;
37 fileReferenceURL?: string;
38 mode: BrowserTabMode;
39 loading: boolean;
40 error: { code: number; description: string } | null;
41 zoom: number;
42 createdAt: number;
43 lastURL: string;
44 crashes: number;
45 agentInputUntil: number;
46 }
47
48 export interface OpenOptions {
49 taskId: string;
50 sessionId?: string;
51 temporary: boolean;
52 }
53
54 export interface SurfaceManagerDeps {
55 views: GuestViewFactory;
56 contentSize(): { width: number; height: number } | null;
57 onTakeover(tab: BrowserTab, reason: string): void;
58 onCrash(tab: BrowserTab, reason: string): void;
59 log: Logger;
60 now?(): number;
61 openWaitMs?: number;
62 }
63
64 export function normaliseBrowserURL(input: string): string {
65 const raw = input.trim();
66 if (raw === "") throw new Error("empty URL");
67 const candidate = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
68 let url: URL;
69 try {
70 url = new URL(candidate);
71 } catch {
72 throw new Error(`invalid URL: ${raw.slice(0, 120)}`);
73 }
74 if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`only http(s) URLs can be opened, not ${url.protocol}`);
75 return url.href;
76 }
77
78 export function validateLayout(rect: BrowserLayoutRect, content: { width: number; height: number } | null): Rectangle {
79 const values = [rect.x, rect.y, rect.width, rect.height];
80 if (values.some((value) => typeof value !== "number" || !Number.isFinite(value))) throw new Error("layout rect must be finite numbers");
81 const x = Math.max(0, Math.round(rect.x));
82 const y = Math.max(0, Math.round(rect.y));
83 let width = Math.max(0, Math.round(rect.width));
84 let height = Math.max(0, Math.round(rect.height));
85 if (content) {
86 width = Math.min(width, Math.max(0, content.width - x));
87 height = Math.min(height, Math.max(0, content.height - y));
88 }
89 return { x, y, width, height };
90 }
91
92 export class BrowserSurfaceManager {
93 private readonly tabs = new Map<string, BrowserTab>();
94 private readonly listeners = new Set<(tabs: BrowserTabView[]) => void>();
95 private layout: Rectangle | null = null;
96 private overlay = false;
97 private activeId: string | null = null;
98 private counter = 0;
99 private readonly now: () => number;
100
101 constructor(private readonly deps: SurfaceManagerDeps) {
102 this.now = deps.now ?? (() => Date.now());
103 }
104
105 get activeTabId(): string | null {
106 return this.activeId;
107 }
108
109 get(tabId: string): BrowserTab | undefined {
110 return this.tabs.get(tabId);
111 }
112
113 require(tabId: string): BrowserTab {
114 const tab = this.tabs.get(tabId);
115 if (!tab) throw new Error(`unknown browser tab ${tabId || "(empty)"}`);
116 return tab;
117 }
118
119 all(): BrowserTab[] {
120 return [...this.tabs.values()];
121 }
122
123 tabsForTask(taskId: string): BrowserTab[] {
124 return this.all().filter((tab) => tab.taskId === taskId);
125 }
126
127 tabsForSession(taskId: string, sessionId: string): BrowserTab[] {
128 return this.all().filter((tab) => tab.taskId === taskId && tab.sessionId === sessionId);
129 }
130
131 list(): BrowserTabView[] {
132 return this.all().map((tab) => this.view(tab));
133 }
134
135 restore(tabs: RecoveredTab[]): void {
136 for (const row of tabs.slice(0, 32)) {
137 if (this.tabs.has(row.id) || this.tabs.size >= 32) continue;
138 this.counter = Math.max(this.counter, Number(row.id.slice(4)));
139 const view = new LazyGuestView(this.deps.views, SHARED_PARTITION, row);
140 const tab = this.register(row.id, view, row.taskId, row.sessionId, SHARED_PARTITION, false);
141 tab.viewport = row.viewport;
142 view.setViewport(row.viewport);
143 tab.lastURL = row.url;
144 tab.fileReference = row.fileReference;
145 }
146 }
147
148 recoveryTabs(): RecoveredTab[] {
149 return this.all().filter(tab => !tab.temporary).map(tab => ({ id: tab.id, taskId: tab.taskId, sessionId: tab.sessionId, url: tab.view.page.getURL() || tab.lastURL, title: tab.view.page.getTitle(), viewport: tab.viewport, fileReference: tab.fileReference }));
150 }
151
152 view(tab: BrowserTab): BrowserTabView {
153 const page = tab.view.page;
154 const gone = page.isDestroyed();
155 return {
156 id: tab.id,
157 taskId: tab.taskId,
158 sessionId: tab.sessionId,
159 restorePreview: Boolean(tab.fileReference && tab.view.isPlaceholder?.()),
160 url: gone ? tab.lastURL : page.getURL(),
161 title: gone ? "" : page.getTitle(),
162 loading: tab.loading,
163 canGoBack: !gone && page.navigationHistory.canGoBack(),
164 canGoForward: !gone && page.navigationHistory.canGoForward(),
165 temporary: tab.temporary,
166 mode: tab.mode,
167 epoch: tab.epoch,
168 zoom: tab.zoom,
169 viewport: tab.viewport,
170 operation: tab.operation,
171 active: tab.id === this.activeId,
172 error: tab.error,
173 };
174 }
175
176 subscribe(listener: (tabs: BrowserTabView[]) => void): () => void {
177 this.listeners.add(listener);
178 return () => {
179 this.listeners.delete(listener);
180 };
181 }
182
183 async open(url: string, options: OpenOptions, signal?: AbortSignal): Promise<BrowserTab> {
184 signal?.throwIfAborted();
185 if (this.tabs.size >= 32) throw new Error("browser tab limit reached (32); close an unused tab first");
186 const href = normaliseBrowserURL(url);
187 const id = this.nextId();
188 const partition = options.temporary ? `temp:${id}` : SHARED_PARTITION;
189 const view = this.deps.views.create(partition);
190 const tab = this.register(id, view, options.taskId, options.sessionId ?? "", partition, options.temporary);
191 if (this.layout) view.setBounds(this.layout);
192 // The application renderer owns selection. Agent opens must not replace
193 // another task's visible page while its address bar still names that task.
194 this.broadcast();
195 const load = view.page.loadURL(href).catch((error: unknown) => {
196 this.deps.log.warn(`browser tab ${tab.id} load failed: ${String(error)}`);
197 tab.loading = false;
198 tab.error = { code: 0, description: String(error) };
199 this.broadcast();
200 });
201 let timer: ReturnType<typeof setTimeout> | undefined;
202 const ready = Promise.race([load, new Promise<void>((resolve) => { timer = setTimeout(resolve, this.deps.openWaitMs ?? OPEN_WAIT_MS); timer.unref?.(); })]);
203 try { await (signal ? abortable(ready, signal) : ready); }
204 catch (error) {
205 // Open already registered the view. A cancelled waiter has no tab ID
206 // to return to the caller, so reclaim only pages still owned by the
207 // agent. A user-taken-over page remains theirs.
208 if (signal?.aborted && this.tabs.get(tab.id) === tab && tab.mode === "agent") this.close(tab.id);
209 throw error;
210 }
211 finally { clearTimeout(timer); }
212 return tab;
213 }
214
215 close(tabId: string): void {
216 const tab = this.tabs.get(tabId);
217 if (!tab) return;
218 this.forget(tab);
219 tab.view.destroy();
220 this.broadcast();
221 }
222
223 activate(tabId: string | null): void {
224 if (tabId !== null) this.require(tabId);
225 if (tabId) this.require(tabId).view.presentForUser?.();
226 if (tabId) void this.require(tabId).view.ensureLoaded?.().catch(error => this.deps.log.warn(`Restored browser tab failed to load: ${String(error)}`));
227 this.activeId = tabId;
228 this.applyVisibility();
229 this.broadcast();
230 }
231
232 setLayout(rect: BrowserLayoutRect | null): void {
233 const next = rect === null ? null : validateLayout(rect, this.deps.contentSize());
234 if (next && (next.width !== this.layout?.width || next.height !== this.layout?.height)) {
235 for (const tab of this.tabs.values()) {
236 tab.surfaceRevision++;
237 // Fit changes presentation scale, not the emulated CSS viewport.
238 if (!tab.viewport) tab.viewportRevision++;
239 }
240 }
241 this.layout = next;
242 this.applyVisibility();
243 this.broadcast();
244 }
245
246 setOverlay(active: boolean): void {
247 if (this.overlay === active) return;
248 this.overlay = active;
249 this.applyVisibility();
250 }
251
252 setViewport(tabId: string, viewport: BrowserViewport | null): void {
253 const tab = this.require(tabId);
254 if (!tab.view.setViewport) throw browserFailure("capability_unsupported", "viewport emulation is unavailable");
255 const next = viewport ? validateViewport(viewport) : null;
256 tab.view.setViewport(next);
257 tab.viewport = next;
258 tab.viewportRevision++;
259 tab.epoch++;
260 tab.zoom = tab.view.page.getZoomFactor();
261 this.broadcast();
262 }
263
264 setOperation(tab: BrowserTab, operation: NonNullable<BrowserTab["operation"]>): void {
265 if (this.tabs.get(tab.id) !== tab) return;
266 tab.operation = operation;
267 this.broadcast();
268 }
269
270 async navigate(tabId: string, target: BrowserNavigateTarget, signal?: AbortSignal): Promise<BrowserTab> {
271 signal?.throwIfAborted();
272 const tab = this.require(tabId);
273 const page = tab.view.page;
274 switch (target.action) {
275 case "back":
276 if (page.navigationHistory.canGoBack()) page.navigationHistory.goBack();
277 return tab;
278 case "forward":
279 if (page.navigationHistory.canGoForward()) page.navigationHistory.goForward();
280 return tab;
281 case "reload":
282 page.reload();
283 return tab;
284 case "stop":
285 page.stop();
286 return tab;
287 default:
288 break;
289 }
290 if (typeof target.url !== "string") throw new Error("navigate needs a url or an action");
291 const load = page.loadURL(normaliseBrowserURL(target.url)).catch((error: unknown) => {
292 this.deps.log.warn(`browser tab ${tab.id} navigation failed: ${String(error)}`);
293 tab.loading = false;
294 tab.error = { code: 0, description: String(error) };
295 this.broadcast();
296 });
297 await (signal ? abortable(load, signal) : load);
298 return tab;
299 }
300
301 setZoom(tabId: string, factor: number): void {
302 const tab = this.require(tabId);
303 if (tab.viewport && tab.view.setViewport) throw new Error("use responsive display scale or exit responsive mode before changing page zoom");
304 if (!Number.isFinite(factor)) throw new Error("zoom factor must be a finite number");
305 tab.zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, factor));
306 tab.view.page.setZoomFactor(tab.zoom);
307 this.broadcast();
308 }
309
310 toggleDevTools(tabId: string): void {
311 const page = this.require(tabId).view.page;
312 if (page.isDevToolsOpened()) page.closeDevTools();
313 else page.openDevTools({ mode: "detach" });
314 }
315
316 resume(tabId: string): void {
317 const tab = this.require(tabId);
318 if (tab.operation?.phase === "picking") throw new Error("finish element selection or press Esc before returning control");
319 if (tab.mode === "agent") return;
320 tab.mode = "agent";
321 tab.epoch += 1;
322 this.broadcast();
323 }
324
325 takeover(tabId: string, reason: string): void {
326 const tab = this.tabs.get(tabId);
327 if (!tab) return;
328 tab.epoch += 1;
329 tab.mode = "human";
330 this.broadcast();
331 this.deps.onTakeover(tab, reason);
332 }
333
334 // Called with the guest preload's report; the sender id identifies the tab.
335 takeoverFromSender(webContentsId: number, kind: BrowserTakeoverKind): boolean {
336 const tab = this.all().find((entry) => entry.view.page.id === webContentsId);
337 if (!tab) return false;
338 if (this.now() < tab.agentInputUntil) return false;
339 this.takeover(tab.id, `user ${kind}`);
340 return true;
341 }
342
343 markAgentInput(tab: BrowserTab): void {
344 tab.agentInputUntil = this.now() + AGENT_INPUT_GRACE_MS;
345 }
346
347 pauseForRendererLoss(reason: string): void {
348 this.layout = null;
349 this.activeId = null;
350 this.applyVisibility();
351 for (const tab of this.all()) {
352 tab.lifecycleEpoch += 1;
353 this.takeover(tab.id, reason);
354 }
355 }
356
357 destroyAll(): void {
358 const tabs = this.all();
359 this.tabs.clear();
360 this.activeId = null;
361 for (const tab of tabs) {
362 tab.mode = "human";
363 tab.epoch += 1;
364 tab.view.destroy();
365 }
366 this.broadcast();
367 }
368
369 private nextId(): string {
370 this.counter += 1;
371 return `tab-${this.counter}`;
372 }
373
374 private register(id: string, view: GuestView, taskId: string, sessionId: string, partition: string, temporary: boolean): BrowserTab {
375 const tab: BrowserTab = {
376 id,
377 taskId,
378 sessionId,
379 view,
380 partition,
381 temporary,
382 epoch: 0,
383 lifecycleEpoch: 0,
384 viewportRevision: 0,
385 surfaceRevision: 0,
386 viewport: taskId === USER_TASK_ID ? null : { width: 1280, height: 720, scale: "fit" },
387 mode: "agent",
388 loading: false,
389 error: null,
390 zoom: 1,
391 createdAt: this.now(),
392 lastURL: "",
393 crashes: 0,
394 agentInputUntil: 0,
395 };
396 this.tabs.set(id, tab);
397 view.bind(this.events(tab));
398 if (tab.viewport && view.setViewport) view.setViewport(tab.viewport);
399 return tab;
400 }
401
402 private events(tab: BrowserTab): GuestViewEvents {
403 return {
404 onStartLoading: () => {
405 tab.loading = true;
406 this.broadcast();
407 },
408 onStopLoading: () => {
409 tab.loading = false;
410 this.broadcast();
411 },
412 onNavigate: (url, inPage) => {
413 if (tab.fileReference && tab.fileReferenceURL !== url) { tab.fileReference = undefined; tab.fileReferenceURL = undefined; }
414 tab.epoch += 1;
415 tab.lifecycleEpoch += 1;
416 tab.lastURL = url;
417 if (!inPage) tab.error = null;
418 this.broadcast();
419 },
420 onTitle: () => this.broadcast(),
421 onFailLoad: (code, description) => {
422 tab.error = { code, description };
423 this.broadcast();
424 },
425 onRenderProcessGone: (reason) => this.recover(tab, reason),
426 onDestroyed: () => {
427 if (!this.tabs.has(tab.id)) return;
428 this.forget(tab);
429 this.broadcast();
430 },
431 onPopup: () => {
432 if (!this.tabs.has(tab.id) || this.tabs.size >= 32) return null;
433 return (view) => {
434 if (!this.tabs.has(tab.id) || this.tabs.size >= 32) { view.destroy(); return; }
435 this.register(this.nextId(), view, tab.taskId, tab.sessionId, tab.partition, tab.temporary);
436 this.broadcast();
437 };
438 },
439 };
440 }
441
442 // A crashed website view never replays anything: it reloads the last
443 // committed URL in human mode and the agent must look again.
444 private recover(tab: BrowserTab, reason: string): void {
445 tab.mode = "human";
446 tab.epoch += 1;
447 tab.lifecycleEpoch += 1;
448 tab.loading = false;
449 tab.crashes += 1;
450 tab.error = { code: 0, description: `renderer ${reason}` };
451 this.broadcast();
452 this.deps.onCrash(tab, reason);
453 if (tab.crashes > MAX_CRASH_RELOADS || tab.lastURL === "" || tab.view.page.isDestroyed()) return;
454 tab.view.page.loadURL(tab.lastURL).catch((error: unknown) => {
455 this.deps.log.warn(`browser tab ${tab.id} recovery failed: ${String(error)}`);
456 });
457 }
458
459 private forget(tab: BrowserTab): void {
460 this.tabs.delete(tab.id);
461 tab.mode = "human";
462 tab.epoch += 1;
463 if (this.activeId === tab.id) {
464 this.activeId = null;
465 this.applyVisibility();
466 }
467 }
468
469 private applyVisibility(): void {
470 for (const tab of this.tabs.values()) {
471 const visible = !this.overlay && this.layout !== null && tab.id === this.activeId && this.layout.width > 0 && this.layout.height > 0;
472 if (this.layout) tab.view.setBounds(this.layout);
473 tab.view.setVisible(visible);
474 }
475 }
476
477 private broadcast(): void {
478 if (this.listeners.size === 0) return;
479 const tabs = this.list();
480 for (const listener of [...this.listeners]) {
481 try {
482 listener(tabs);
483 } catch (error) {
484 this.deps.log.warn(`browser tab listener failed: ${String(error)}`);
485 }
486 }
487 }
488 }
489
489 lines TYPESCRIPT