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