返回 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
6 export const SHARED_PARTITION = "persist:browser";
7 export const USER_TASK_ID = "user";
8 export const OPEN_WAIT_MS = 15_000;
9 // Input the shell dispatches for the agent reaches the page as trusted, so
10 // the guest preload reports it like a user; reports inside this window after
11 // a dispatch are the agent's own echo, not a take-over.
12 export const AGENT_INPUT_GRACE_MS = 750;
13 export const MAX_CRASH_RELOADS = 3;
14 export const MIN_ZOOM = 0.25;
15 export const MAX_ZOOM = 5;
16
17 export interface BrowserTab {
18 id: string;
19 taskId: string;
20 view: GuestView;
21 partition: string;
22 temporary: boolean;
23 epoch: number;
24 mode: BrowserTabMode;
25 loading: boolean;
26 error: { code: number; description: string } | null;
27 zoom: number;
28 createdAt: number;
29 lastURL: string;
30 crashes: number;
31 agentInputUntil: number;
32 }
33
34 export interface OpenOptions {
35 taskId: string;
36 temporary: boolean;
37 }
38
39 export interface SurfaceManagerDeps {
40 views: GuestViewFactory;
41 contentSize(): { width: number; height: number } | null;
42 onTakeover(tab: BrowserTab, reason: string): void;
43 onCrash(tab: BrowserTab, reason: string): void;
44 log: Logger;
45 now?(): number;
46 openWaitMs?: number;
47 }
48
49 export function normaliseBrowserURL(input: string): string {
50 const raw = input.trim();
51 if (raw === "") throw new Error("empty URL");
52 const candidate = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
53 let url: URL;
54 try {
55 url = new URL(candidate);
56 } catch {
57 throw new Error(`invalid URL: ${raw.slice(0, 120)}`);
58 }
59 if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`only http(s) URLs can be opened, not ${url.protocol}`);
60 return url.href;
61 }
62
63 export function validateLayout(rect: BrowserLayoutRect, content: { width: number; height: number } | null): Rectangle {
64 const values = [rect.x, rect.y, rect.width, rect.height];
65 if (values.some((value) => typeof value !== "number" || !Number.isFinite(value))) throw new Error("layout rect must be finite numbers");
66 const x = Math.max(0, Math.round(rect.x));
67 const y = Math.max(0, Math.round(rect.y));
68 let width = Math.max(0, Math.round(rect.width));
69 let height = Math.max(0, Math.round(rect.height));
70 if (content) {
71 width = Math.min(width, Math.max(0, content.width - x));
72 height = Math.min(height, Math.max(0, content.height - y));
73 }
74 return { x, y, width, height };
75 }
76
77 export class BrowserSurfaceManager {
78 private readonly tabs = new Map<string, BrowserTab>();
79 private readonly listeners = new Set<(tabs: BrowserTabView[]) => void>();
80 private layout: Rectangle | null = null;
81 private overlay = false;
82 private activeId: string | null = null;
83 private counter = 0;
84 private readonly now: () => number;
85
86 constructor(private readonly deps: SurfaceManagerDeps) {
87 this.now = deps.now ?? (() => Date.now());
88 }
89
90 get activeTabId(): string | null {
91 return this.activeId;
92 }
93
94 get(tabId: string): BrowserTab | undefined {
95 return this.tabs.get(tabId);
96 }
97
98 require(tabId: string): BrowserTab {
99 const tab = this.tabs.get(tabId);
100 if (!tab) throw new Error(`unknown browser tab ${tabId || "(empty)"}`);
101 return tab;
102 }
103
104 all(): BrowserTab[] {
105 return [...this.tabs.values()];
106 }
107
108 tabsForTask(taskId: string): BrowserTab[] {
109 return this.all().filter((tab) => tab.taskId === taskId);
110 }
111
112 list(): BrowserTabView[] {
113 return this.all().map((tab) => this.view(tab));
114 }
115
116 view(tab: BrowserTab): BrowserTabView {
117 const page = tab.view.page;
118 const gone = page.isDestroyed();
119 return {
120 id: tab.id,
121 taskId: tab.taskId,
122 url: gone ? tab.lastURL : page.getURL(),
123 title: gone ? "" : page.getTitle(),
124 loading: tab.loading,
125 canGoBack: !gone && page.navigationHistory.canGoBack(),
126 canGoForward: !gone && page.navigationHistory.canGoForward(),
127 temporary: tab.temporary,
128 mode: tab.mode,
129 epoch: tab.epoch,
130 zoom: tab.zoom,
131 active: tab.id === this.activeId,
132 error: tab.error,
133 };
134 }
135
136 subscribe(listener: (tabs: BrowserTabView[]) => void): () => void {
137 this.listeners.add(listener);
138 return () => {
139 this.listeners.delete(listener);
140 };
141 }
142
143 async open(url: string, options: OpenOptions): Promise<BrowserTab> {
144 const href = normaliseBrowserURL(url);
145 const id = this.nextId();
146 const partition = options.temporary ? `temp:${id}` : SHARED_PARTITION;
147 const view = this.deps.views.create(partition);
148 const tab = this.register(id, view, options.taskId, partition, options.temporary);
149 if (this.layout) view.setBounds(this.layout);
150 // The application renderer owns selection. Agent opens must not replace
151 // another task's visible page while its address bar still names that task.
152 this.broadcast();
153 const load = view.page.loadURL(href).catch((error: unknown) => {
154 this.deps.log.warn(`browser tab ${tab.id} load failed: ${String(error)}`);
155 });
156 await Promise.race([load, new Promise<void>((resolve) => setTimeout(resolve, this.deps.openWaitMs ?? OPEN_WAIT_MS).unref?.())]);
157 return tab;
158 }
159
160 close(tabId: string): void {
161 const tab = this.tabs.get(tabId);
162 if (!tab) return;
163 this.forget(tab);
164 tab.view.destroy();
165 this.broadcast();
166 }
167
168 activate(tabId: string | null): void {
169 if (tabId !== null) this.require(tabId);
170 this.activeId = tabId;
171 this.applyVisibility();
172 this.broadcast();
173 }
174
175 setLayout(rect: BrowserLayoutRect | null): void {
176 this.layout = rect === null ? null : validateLayout(rect, this.deps.contentSize());
177 this.applyVisibility();
178 }
179
180 setOverlay(active: boolean): void {
181 if (this.overlay === active) return;
182 this.overlay = active;
183 this.applyVisibility();
184 }
185
186 async navigate(tabId: string, target: BrowserNavigateTarget): Promise<BrowserTab> {
187 const tab = this.require(tabId);
188 const page = tab.view.page;
189 switch (target.action) {
190 case "back":
191 if (page.navigationHistory.canGoBack()) page.navigationHistory.goBack();
192 return tab;
193 case "forward":
194 if (page.navigationHistory.canGoForward()) page.navigationHistory.goForward();
195 return tab;
196 case "reload":
197 page.reload();
198 return tab;
199 case "stop":
200 page.stop();
201 return tab;
202 default:
203 break;
204 }
205 if (typeof target.url !== "string") throw new Error("navigate needs a url or an action");
206 await page.loadURL(normaliseBrowserURL(target.url)).catch((error: unknown) => {
207 this.deps.log.warn(`browser tab ${tab.id} navigation failed: ${String(error)}`);
208 });
209 return tab;
210 }
211
212 setZoom(tabId: string, factor: number): void {
213 const tab = this.require(tabId);
214 if (!Number.isFinite(factor)) throw new Error("zoom factor must be a finite number");
215 tab.zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, factor));
216 tab.view.page.setZoomFactor(tab.zoom);
217 this.broadcast();
218 }
219
220 toggleDevTools(tabId: string): void {
221 const page = this.require(tabId).view.page;
222 if (page.isDevToolsOpened()) page.closeDevTools();
223 else page.openDevTools({ mode: "detach" });
224 }
225
226 resume(tabId: string): void {
227 const tab = this.require(tabId);
228 if (tab.mode === "agent") return;
229 tab.mode = "agent";
230 tab.epoch += 1;
231 this.broadcast();
232 }
233
234 takeover(tabId: string, reason: string): void {
235 const tab = this.tabs.get(tabId);
236 if (!tab) return;
237 tab.epoch += 1;
238 tab.mode = "human";
239 this.broadcast();
240 this.deps.onTakeover(tab, reason);
241 }
242
243 // Called with the guest preload's report; the sender id identifies the tab.
244 takeoverFromSender(webContentsId: number, kind: BrowserTakeoverKind): boolean {
245 const tab = this.all().find((entry) => entry.view.page.id === webContentsId);
246 if (!tab) return false;
247 if (this.now() < tab.agentInputUntil) return false;
248 this.takeover(tab.id, `user ${kind}`);
249 return true;
250 }
251
252 markAgentInput(tab: BrowserTab): void {
253 tab.agentInputUntil = this.now() + AGENT_INPUT_GRACE_MS;
254 }
255
256 pauseForRendererLoss(reason: string): void {
257 this.layout = null;
258 this.activeId = null;
259 this.applyVisibility();
260 for (const tab of this.all()) this.takeover(tab.id, reason);
261 }
262
263 destroyAll(): void {
264 const tabs = this.all();
265 this.tabs.clear();
266 this.activeId = null;
267 for (const tab of tabs) {
268 tab.mode = "human";
269 tab.epoch += 1;
270 tab.view.destroy();
271 }
272 this.broadcast();
273 }
274
275 private nextId(): string {
276 this.counter += 1;
277 return `tab-${this.counter}`;
278 }
279
280 private register(id: string, view: GuestView, taskId: string, partition: string, temporary: boolean): BrowserTab {
281 const tab: BrowserTab = {
282 id,
283 taskId,
284 view,
285 partition,
286 temporary,
287 epoch: 0,
288 mode: "agent",
289 loading: false,
290 error: null,
291 zoom: 1,
292 createdAt: this.now(),
293 lastURL: "",
294 crashes: 0,
295 agentInputUntil: 0,
296 };
297 this.tabs.set(id, tab);
298 view.bind(this.events(tab));
299 return tab;
300 }
301
302 private events(tab: BrowserTab): GuestViewEvents {
303 return {
304 onStartLoading: () => {
305 tab.loading = true;
306 this.broadcast();
307 },
308 onStopLoading: () => {
309 tab.loading = false;
310 this.broadcast();
311 },
312 onNavigate: (url, inPage) => {
313 tab.epoch += 1;
314 tab.lastURL = url;
315 if (!inPage) tab.error = null;
316 this.broadcast();
317 },
318 onTitle: () => this.broadcast(),
319 onFailLoad: (code, description) => {
320 tab.error = { code, description };
321 this.broadcast();
322 },
323 onRenderProcessGone: (reason) => this.recover(tab, reason),
324 onDestroyed: () => {
325 if (!this.tabs.has(tab.id)) return;
326 this.forget(tab);
327 this.broadcast();
328 },
329 onPopup: () => {
330 if (!this.tabs.has(tab.id)) return null;
331 return (view) => {
332 this.register(this.nextId(), view, tab.taskId, tab.partition, tab.temporary);
333 this.broadcast();
334 };
335 },
336 };
337 }
338
339 // A crashed website view never replays anything: it reloads the last
340 // committed URL in human mode and the agent must look again.
341 private recover(tab: BrowserTab, reason: string): void {
342 tab.mode = "human";
343 tab.epoch += 1;
344 tab.loading = false;
345 tab.crashes += 1;
346 tab.error = { code: 0, description: `renderer ${reason}` };
347 this.broadcast();
348 this.deps.onCrash(tab, reason);
349 if (tab.crashes > MAX_CRASH_RELOADS || tab.lastURL === "" || tab.view.page.isDestroyed()) return;
350 tab.view.page.loadURL(tab.lastURL).catch((error: unknown) => {
351 this.deps.log.warn(`browser tab ${tab.id} recovery failed: ${String(error)}`);
352 });
353 }
354
355 private forget(tab: BrowserTab): void {
356 this.tabs.delete(tab.id);
357 tab.mode = "human";
358 tab.epoch += 1;
359 if (this.activeId === tab.id) {
360 this.activeId = null;
361 this.applyVisibility();
362 }
363 }
364
365 private applyVisibility(): void {
366 for (const tab of this.tabs.values()) {
367 const visible = !this.overlay && this.layout !== null && tab.id === this.activeId && this.layout.width > 0 && this.layout.height > 0;
368 if (this.layout) tab.view.setBounds(this.layout);
369 tab.view.setVisible(visible);
370 }
371 }
372
373 private broadcast(): void {
374 if (this.listeners.size === 0) return;
375 const tabs = this.list();
376 for (const listener of [...this.listeners]) {
377 try {
378 listener(tabs);
379 } catch (error) {
380 this.deps.log.warn(`browser tab listener failed: ${String(error)}`);
381 }
382 }
383 }
384 }
385
385 lines TYPESCRIPT