返回 DeepSeek-Reasonix
window.ts
根目录 / desktop / electron / src / main / window.ts
1 import { BrowserWindow, nativeTheme, screen, type WebContents, type WebFrameMain } from "electron";
2 import type { EventFrame, WindowBounds, WindowTheme } from "../shared/ipc.js";
3 import { IPC } from "../shared/ipc.js";
4 import { shellActionFromURL, type ShellAction } from "./failurePage.js";
5 import type { HelloWindow } from "./handshake.js";
6 import { errorText, type Logger } from "./log.js";
7 import { APP_ORIGIN } from "./protocol.js";
8 import { AppZoomStore } from "./zoomStore.js";
9 import { persistedWindowRect, restoreWindowRect, type WindowRect } from "./windowBounds.js";
10
11 export const DEFAULT_GEOMETRY: HelloWindow = { width: 1280, height: 820, minWidth: 760, minHeight: 480, frameless: false, zoomFactor: 1 };
12
13 export interface MainWindowDeps {
14 preloadPath: string;
15 appURL: string;
16 platform: NodeJS.Platform;
17 icon?: string;
18 log: Logger;
19 onAppDomReady(rendererGeneration: number): void;
20 onRendererLost?(reason: string): void;
21 onRendererFailure?(details: { reason: string; exitCode: number }, canReload: boolean): boolean;
22 onUnresponsive?(): void;
23 onResponsive?(): void;
24 isQuitting?(): boolean;
25 onCloseRequested(): Promise<void>;
26 onShellAction(action: ShellAction): void;
27 zoomStore: AppZoomStore;
28 }
29
30 type Content = "none" | "starting" | "app" | "failure";
31
32 function hex(value: number): string {
33 return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
34 }
35
36 export class MainWindow {
37 private win: BrowserWindow | null = null;
38 private content: Content = "none";
39 private frameless = false;
40 private rendererGeneration = 0;
41 private closeAllowed = false;
42 private lastMaximised = false;
43 private lastNormalBounds: WindowRect | undefined;
44 private readonly appOrigin: string;
45
46 constructor(private readonly deps: MainWindowDeps) {
47 let origin = APP_ORIGIN;
48 try {
49 origin = new URL(deps.appURL).origin;
50 } catch {
51 // A malformed dev URL fails at load time with a logged error.
52 }
53 this.appOrigin = origin === "null" ? APP_ORIGIN : origin;
54 }
55
56 get browserWindow(): BrowserWindow | null {
57 return this.win && !this.win.isDestroyed() ? this.win : null;
58 }
59
60 prepareApp(geometry: HelloWindow): void {
61 if (this.content === "app" && this.browserWindow) return;
62 // Native window frames cannot be changed after construction. Reuse the
63 // loading window when compatible, applying the service's saved geometry.
64 if (this.content === "starting" && this.browserWindow && this.frameless === geometry.frameless) {
65 const display = geometry.position
66 ? screen.getDisplayMatching({ ...geometry.position, width: geometry.width, height: geometry.height })
67 : screen.getPrimaryDisplay();
68 const rect = restoreWindowRect(geometry, geometry.position, display.workArea);
69 this.browserWindow.setMinimumSize(Math.min(Math.round(geometry.minWidth), display.workArea.width), Math.min(Math.round(geometry.minHeight), display.workArea.height));
70 this.browserWindow.setBounds(rect);
71 this.browserWindow.webContents.setZoomFactor(this.deps.zoomStore.current.appZoomFactor);
72 this.content = "none";
73 return;
74 }
75 const previous = this.browserWindow;
76 this.win = null;
77 this.create(geometry);
78 previous?.destroy();
79 }
80
81 create(geometry: HelloWindow): void {
82 if (this.browserWindow) return;
83 const { deps } = this;
84 const display = geometry.position
85 ? screen.getDisplayMatching({ ...geometry.position, width: geometry.width, height: geometry.height })
86 : screen.getPrimaryDisplay();
87 const rect = restoreWindowRect(geometry, geometry.position, display.workArea);
88 const win = new BrowserWindow({
89 ...rect,
90 minWidth: Math.min(Math.round(geometry.minWidth), display.workArea.width),
91 minHeight: Math.min(Math.round(geometry.minHeight), display.workArea.height),
92 show: false,
93 title: "Reasonix",
94 backgroundColor: "#1a1a2e",
95 titleBarStyle: deps.platform === "darwin" ? "hiddenInset" : "default",
96 frame: !geometry.frameless,
97 autoHideMenuBar: deps.platform !== "darwin",
98 icon: deps.icon,
99 webPreferences: {
100 preload: deps.preloadPath,
101 sandbox: true,
102 contextIsolation: true,
103 nodeIntegration: false,
104 spellcheck: false,
105 zoomFactor: this.deps.zoomStore.current.appZoomFactor,
106 },
107 });
108 this.win = win;
109 this.frameless = geometry.frameless;
110 this.lastMaximised = false;
111 this.lastNormalBounds = win.getNormalBounds();
112 // Some platforms report isMaximized=false while iconic. Keep the last
113 // non-minimized state so minimising a maximized window does not erase it.
114 const captureBounds = () => { this.bounds(); };
115 win.on("resize", captureBounds);
116 win.on("move", captureBounds);
117 win.on("maximize", captureBounds);
118 win.on("unmaximize", captureBounds);
119 this.content = "none";
120 if (deps.platform !== "darwin") win.setMenuBarVisibility(false);
121 win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
122 win.webContents.on("will-attach-webview", (event) => event.preventDefault());
123 win.webContents.on("will-navigate", (event, url) => {
124 const action = shellActionFromURL(url);
125 if (action) {
126 event.preventDefault();
127 deps.onShellAction(action);
128 return;
129 }
130 if (url.startsWith(this.appOrigin + "/")) return;
131 event.preventDefault();
132 deps.log.warn(`blocked main window navigation to ${url}`);
133 });
134 win.webContents.on("dom-ready", () => {
135 if (this.content !== "app") return;
136 this.rendererGeneration += 1;
137 deps.onAppDomReady(this.rendererGeneration);
138 });
139 win.webContents.on("render-process-gone", (_event, details) => {
140 if (this.win !== win || this.deps.isQuitting?.()) return;
141 deps.log.error(`renderer process gone: ${details.reason} (exit code ${details.exitCode})`);
142 deps.onRendererLost?.(`app renderer ${details.reason}`);
143 deps.onResponsive?.();
144 const retry = deps.onRendererFailure?.(details, this.content === "app") ?? true;
145 if (retry && !this.deps.isQuitting?.() && this.content === "app" && this.browserWindow) win.webContents.reload();
146 });
147 win.on("unresponsive", () => {
148 if (this.win !== win || deps.isQuitting?.()) return;
149 deps.log.warn("main renderer unresponsive (cause unknown)");
150 deps.onUnresponsive?.();
151 });
152 win.on("responsive", () => { if (this.win === win) deps.onResponsive?.(); });
153 win.on("close", (event) => {
154 if (this.closeAllowed) return;
155 event.preventDefault();
156 void deps.onCloseRequested().catch((error) => {
157 deps.log.warn(`window close coordination failed: ${errorText(error)}`);
158 });
159 });
160 win.on("closed", () => {
161 if (this.win === win) this.win = null;
162 });
163 }
164
165 async getAppZoom(): Promise<number> { return (await this.deps.zoomStore.load()).appZoomFactor; }
166 async setAppZoom(factor: number): Promise<number> {
167 const state = await this.deps.zoomStore.set(factor);
168 this.browserWindow?.webContents.setZoomFactor(state.appZoomFactor);
169 return state.appZoomFactor;
170 }
171 async resetAppZoom(): Promise<number> { return this.setAppZoom(1); }
172 async stepAppZoom(direction: 1 | -1): Promise<number> {
173 const current = await this.getAppZoom();
174 return this.setAppZoom(current + direction * 0.05);
175 }
176
177 async loadApp(): Promise<void> {
178 const win = this.browserWindow;
179 if (!win) return;
180 this.content = "app";
181 try {
182 await win.loadURL(this.deps.appURL);
183 } catch (error) {
184 this.deps.log.error(`failed to load ${this.deps.appURL}: ${errorText(error)}`);
185 }
186 }
187
188 reattachApp(): boolean {
189 if (!this.browserWindow || this.content !== "app") return false;
190 this.rendererGeneration += 1;
191 this.deps.onAppDomReady(this.rendererGeneration);
192 return true;
193 }
194
195 async showStartup(html: string): Promise<void> {
196 return this.showShellPage(html, "starting");
197 }
198
199 async showFailure(html: string): Promise<void> {
200 return this.showShellPage(html, "failure");
201 }
202
203 private async showShellPage(html: string, content: "starting" | "failure"): Promise<void> {
204 const win = this.browserWindow;
205 if (!win) return;
206 this.deps.onRendererLost?.("app failure page");
207 this.content = content;
208 try {
209 await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
210 } catch (error) {
211 // Successful startup replaces the provisional window while its data URL
212 // may still be loading. That cancellation is not a startup failure.
213 if (!win.isDestroyed() && this.browserWindow === win) this.deps.log.error(`failed to load the recovery page: ${errorText(error).slice(0, 300)}`);
214 }
215 if (!win.isDestroyed() && this.browserWindow === win && !this.deps.isQuitting?.() && this.content === content) win.show();
216 }
217
218 allowClose(): void {
219 this.closeAllowed = true;
220 }
221
222 isTrustedSender(sender: WebContents, frame: WebFrameMain | null | undefined): boolean {
223 const win = this.browserWindow;
224 return Boolean(win) && sender === win?.webContents && frame != null && frame === win.webContents.mainFrame;
225 }
226
227 send(channel: string, payload: unknown): void {
228 const win = this.browserWindow;
229 if (!win || win.webContents.isDestroyed()) return;
230 win.webContents.send(channel, payload);
231 }
232
233 sendShellEvent(name: string, generation: string, args: unknown[] = []): void {
234 const frame: EventFrame = { seq: 0, generation, name, args };
235 this.send(IPC.event, frame);
236 }
237
238 show(reason: string): void {
239 const win = this.browserWindow;
240 if (!win) return;
241 void reason;
242 if (win.isMinimized()) win.restore();
243 win.show();
244 }
245
246 focusForSecondInstance(): void {
247 this.show("second-instance");
248 this.browserWindow?.focus();
249 }
250
251 hide(): void {
252 this.browserWindow?.hide();
253 }
254
255 maximise(): void {
256 this.browserWindow?.maximize();
257 }
258
259 unmaximise(): void {
260 this.browserWindow?.unmaximize();
261 }
262
263 minimise(): void {
264 this.browserWindow?.minimize();
265 }
266
267 unminimise(): void {
268 this.browserWindow?.restore();
269 }
270
271 toggleMaximise(): void {
272 const win = this.browserWindow;
273 if (!win) return;
274 if (win.isMaximized()) win.unmaximize();
275 else win.maximize();
276 }
277
278 center(): void {
279 this.browserWindow?.center();
280 }
281
282 isMaximised(): boolean {
283 return this.browserWindow?.isMaximized() ?? false;
284 }
285
286 isMinimised(): boolean {
287 return this.browserWindow?.isMinimized() ?? false;
288 }
289
290 setPosition(x: number, y: number): void {
291 this.browserWindow?.setPosition(Math.round(x), Math.round(y));
292 }
293
294 setTitle(title: string): void {
295 this.browserWindow?.setTitle(title);
296 }
297
298 toggleDevTools(): void {
299 this.browserWindow?.webContents.toggleDevTools();
300 }
301
302 close(): void {
303 this.browserWindow?.close();
304 }
305
306 async flushSessionDraft(): Promise<void> {
307 const win = this.browserWindow;
308 if (!win || this.content !== "app" || win.webContents.isDestroyed()) return;
309 await win.webContents.executeJavaScript(
310 "Promise.resolve(globalThis.__reasonixFlushSessionDraft?.())",
311 true,
312 );
313 }
314
315 async resumeSessionDraftEditing(): Promise<void> {
316 const win = this.browserWindow;
317 if (!win || this.content !== "app" || win.webContents.isDestroyed()) return;
318 await win.webContents.executeJavaScript(
319 "Promise.resolve(globalThis.__reasonixResumeSessionDraftEditing?.())",
320 true,
321 );
322 }
323
324 contentSize(): { width: number; height: number } | null {
325 const win = this.browserWindow;
326 if (!win) return null;
327 const [width, height] = win.getContentSize();
328 return { width, height };
329 }
330
331 bounds(): WindowBounds {
332 const win = this.browserWindow;
333 if (!win) return { x: 0, y: 0, width: 0, height: 0, maximised: false };
334 // getNormalBounds can return the maximized frame after minimising on macOS.
335 // Freeze both geometry and intent while iconic; capture native moves even
336 // when they occur between the renderer's periodic persistence requests.
337 if (!win.isMinimized()) {
338 this.lastNormalBounds = persistedWindowRect(win);
339 this.lastMaximised = win.isMaximized();
340 }
341 const rect = this.lastNormalBounds ?? persistedWindowRect(win);
342 return { ...rect, maximised: this.lastMaximised };
343 }
344
345 setTheme(theme: WindowTheme): void {
346 nativeTheme.themeSource = theme;
347 }
348
349 setBackgroundColour(r: number, g: number, b: number, a: number): void {
350 void a;
351 this.browserWindow?.setBackgroundColor(`#${hex(r)}${hex(g)}${hex(b)}`);
352 }
353
354 }
355
355 lines TYPESCRIPT