返回 DeepSeek-Reasonix
index.ts
根目录 / desktop / electron / src / main / index.ts
1 import { app, clipboard, dialog, ipcMain, net, protocol, screen, session, shell } from "electron";
2 import { readdirSync } from "node:fs";
3 import { homedir } from "node:os";
4 import { join } from "node:path";
5 import { IPC, type BrowserTakeoverKind } from "../shared/ipc.js";
6 import { ActionExecutor } from "./browser/actions.js";
7 import { DocumentRegistry } from "./browser/documents.js";
8 import { DownloadTracker } from "./browser/downloads.js";
9 import { ElectronGuestViewFactory } from "./browser/electronGuestViews.js";
10 import { GrantRegistry } from "./browser/grants.js";
11 import { buildBrowserHostCalls } from "./browser/hostCalls.js";
12 import { browserLayoutInDIP } from "./browser/layout.js";
13 import { BrowserSurfaceManager, SHARED_PARTITION, type BrowserTab } from "./browser/surfaceManager.js";
14 import { BrowserControlStore, loadBrowserControlBootstrap, type BrowserSession } from "./browserControl.js";
15 import { BrowserControlHost } from "./browserControlHost.js";
16 import { applyAppUserModelId, registerTaskbarRelaunch } from "./appIdentity.js";
17 import { loadBuildIdentity } from "./buildIdentity.js";
18 import type { CookieSink } from "./chromeImport.js";
19 import { emptyContract, loadContract, type LoadedContract } from "./contract.js";
20 import { DialogHost } from "./dialogs.js";
21 import { renderFailurePage, type ShellAction } from "./failurePage.js";
22 import { buildHelloParams, describeHandshakeFailure, validateHelloResult, type HelloResult, type HandshakeFailure } from "./handshake.js";
23 import { reasonixHome } from "./home.js";
24 import { buildHostCallTable, dispatchHostCall, type ScreenInfo } from "./hostCalls.js";
25 import { firstExisting, iconCandidates } from "./icons.js";
26 import { registerRendererIpc } from "./ipc.js";
27 import { ProcessDiagnostics } from "./processDiagnostics.js";
28 import { createPerformanceHost } from "./performanceHost.js";
29 import { QuitSequencer } from "./lifecycle.js";
30 import { createLogger, errorText, RotatingFile } from "./log.js";
31 import { installApplicationMenu } from "./menu.js";
32 import { record } from "./params.js";
33 import { APP_INDEX_URL, APP_SCHEME, registerAppProtocol, resolveDistRoot } from "./protocol.js";
34 import { RemoteWindowHost } from "./remoteWindows.js";
35 import { ServiceSupervisor } from "./service.js";
36 import { ShellLifecycle } from "./shellLifecycle.js";
37 import { resolveServiceBinary } from "./serviceBinary.js";
38 import { claimShellInstance } from "./singleInstance.js";
39 import { TrayHost } from "./tray.js";
40 import { DEFAULT_GEOMETRY, MainWindow } from "./window.js";
41 import { AppZoomStore } from "./zoomStore.js";
42 import { GraphicsSettingsStore, loadGraphicsBootstrap } from "./graphics.js";
43 import { initialShellStatus, listenShellStatus, QUIT_REQUEST } from "./shellStatus.js";
44 import { supersededLauncher } from "./recovery.js";
45 import { startupLifecycle, startupPresentation, type StartupPresentReason } from "./startupPresentation.js";
46 import { StartupDelay, renderStartupPage } from "./startupDelay.js";
47
48 const MAIN_WINDOW_PERMISSIONS = new Set(["clipboard-read", "clipboard-sanitized-write", "fullscreen", "notifications"]);
49 const TAKEOVER_KINDS = new Set<string>(["mousedown", "keydown", "wheel", "touchstart", "pointerdown"]);
50
51 function safeDirName(value: string): string {
52 const name = value.replace(/[^A-Za-z0-9._-]+/g, "_");
53 return name === "" ? "user" : name;
54 }
55
56 app.setName("Reasonix");
57 // Must precede the first BrowserWindow: the taskbar reads the identity once.
58 applyAppUserModelId(app, process.platform);
59 registerTaskbarRelaunch(app, process.platform, process.execPath, app.isPackaged);
60 const dev = (process.env.REASONIX_DEV ?? "").trim() !== "";
61 const home = reasonixHome({ env: process.env, platform: process.platform, homedir, cwd: () => process.cwd(), });
62 if (home === "") {
63 console.error("reasonix-desktop-shell: cannot resolve the Reasonix data home (set REASONIX_HOME)");
64 app.exit(1);
65 } else if (!claimShellInstance(app, home, dev)) {
66 app.quit();
67 } else if (process.argv.includes(QUIT_REQUEST)) {
68 app.quit();
69 } else {
70 const graphics = loadGraphicsBootstrap(app.getPath("userData"), process.env, process.argv);
71 if (graphics.shouldDisable) app.disableHardwareAcceleration();
72 bootstrap(home);
73 }
74
75 function bootstrap(dataHome: string): void {
76 const graphicsBootstrap = loadGraphicsBootstrap(app.getPath("userData"), process.env, process.argv);
77 const graphics = new GraphicsSettingsStore(graphicsBootstrap.configPath, graphicsBootstrap);
78 const logsDir = join(app.getPath("userData"), "logs");
79 const log = createLogger(new RotatingFile(join(logsDir, "shell.log")), !app.isPackaged);
80 log.info(
81 `graphics acceleration: saved=${graphics.current.hardwareAcceleration} startup=${graphics.current.startupEnabled} override=${graphics.current.override} warning=${graphics.current.warning ?? "none"}`,
82 );
83 app.on("gpu-info-update", () => {
84 try {
85 log.info(`graphics feature status: ${JSON.stringify(app.getGPUFeatureStatus())}`);
86 } catch (error) {
87 log.warn(`graphics status unavailable: ${errorText(error)}`);
88 }
89 });
90 const serviceLog = new RotatingFile(join(logsDir, "service.log"));
91 let buildVersion = "unknown";
92 try {
93 buildVersion = loadBuildIdentity(app.isPackaged, process.resourcesPath, process.env).version;
94 } catch {
95 /* Handshake owns the visible metadata error. */
96 }
97 const status = initialShellStatus(app.getPath("userData"), buildVersion);
98 let firstHeartbeat = 0;
99 const startingPage = { code: null, name: "starting", title: "Reasonix is starting / 正在启动", detail: "Please wait. / 请稍候。" };
100 let lastFailure: HandshakeFailure = startingPage;
101 let startupTimer: ReturnType<typeof setTimeout> | undefined;
102 const startupDelay = new StartupDelay();
103 process.on("uncaughtException", (error) => log.error(`uncaught exception: ${errorText(error)}`));
104 process.on("unhandledRejection", (reason) => log.error(`unhandled rejection: ${errorText(reason)}`));
105
106 protocol.registerSchemesAsPrivileged([
107 { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: false, stream: true } },
108 ]);
109
110 const contractPath = join(__dirname, "desktopContract.json");
111 let contract: LoadedContract;
112 try {
113 contract = loadContract(contractPath);
114 } catch (error) {
115 log.warn(`desktop contract unavailable (${errorText(error)}); every desktop/invoke will be rejected`);
116 contract = emptyContract();
117 }
118 const distRoot = resolveDistRoot({ env: process.env, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged });
119 const devURL = (process.env.REASONIX_ELECTRON_DEV_URL ?? "").trim();
120 const appURL = devURL !== "" ? devURL : APP_INDEX_URL;
121 const zoomStore = new AppZoomStore(join(dataHome, "electron-app-zoom.json"), join(dataHome, "desktop-zoom.json"));
122 const icons = iconCandidates({ platform: process.platform, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged });
123 const windowIcon = process.platform === "darwin" ? undefined : (firstExisting(icons.window) ?? undefined);
124 const serviceLookup = resolveServiceBinary({
125 env: process.env,
126 platform: process.platform,
127 execPath: process.execPath,
128 resourcesPath: process.resourcesPath,
129 });
130 const serviceBinary = serviceLookup.binary;
131
132 let shellBuild = { version: buildVersion, channel: "", commit: "" };
133 try {
134 const identity = loadBuildIdentity(app.isPackaged, process.resourcesPath, process.env);
135 shellBuild = {
136 version: identity.version,
137 channel: identity.channel,
138 commit: identity.commit,
139 };
140 } catch {
141 /* Handshake reports invalid packaged identity. */
142 }
143 const shellLifecycle = new ShellLifecycle(dataHome, shellBuild);
144 log.info(
145 `startup ${status.generation}: shell pid=${process.pid} version=${shellBuild.version} channel=${shellBuild.channel || "unknown"} commit=${shellBuild.commit || "unknown"}`,
146 );
147
148 let domReadyGeneration = "";
149
150 let mainWindow: MainWindow;
151 let browser: BrowserSurfaceManager;
152 let lifecycle: QuitSequencer;
153 let guestViews: ElectronGuestViewFactory;
154 mainWindow = new MainWindow({
155 isQuitting: () => lifecycle.isQuitting,
156 preloadPath: join(__dirname, "preload.cjs"),
157 appURL,
158 platform: process.platform,
159 icon: windowIcon,
160 log,
161 onRendererLost: (reason) => {
162 status.healthy = false;
163 status.rendererVersion = "";
164 firstHeartbeat = 0;
165 browser.pauseForRendererLoss(reason);
166 for (const tab of browser.all()) documents.invalidateTab(tab.id);
167 },
168 onAppDomReady: (rendererGeneration) => {
169 const generation = service.generation;
170 if (generation === "") return;
171 const attach = () =>
172 service.request("desktop/rendererAttached", { rendererGeneration }).catch((error: unknown) => {
173 log.warn(`rendererAttached failed: ${errorText(error)}`);
174 });
175 if (domReadyGeneration === generation) {
176 void attach();
177 return;
178 }
179 domReadyGeneration = generation;
180 void service
181 .request("desktop/domReady", {})
182 .catch((error: unknown) => log.warn(`domReady failed: ${errorText(error)}`))
183 .then(attach);
184 },
185 onCloseRequested: () => lifecycle.requestWindowClose(),
186 onShellAction: (action: ShellAction) => {
187 if (action === "open-logs") void shell.openPath(logsDir);
188 else if (action === "restart") {
189 if (lifecycle.isQuitting) return;
190 const launcher = process.platform === "win32" ? supersededLauncher(process.execPath, buildVersion) : undefined;
191 if (launcher) lifecycle.relaunch(process.argv.slice(1), launcher);
192 else void service.restart().catch(() => undefined);
193 } else lifecycle.approve();
194 },
195 zoomStore,
196 });
197
198 const browserControlBootstrap = loadBrowserControlBootstrap(app.getPath("userData"));
199 const browserControlStore = new BrowserControlStore(browserControlBootstrap.configPath, browserControlBootstrap);
200 const browserControl = new BrowserControlHost({
201 store: browserControlStore,
202 sharedSession: () => session.fromPartition(SHARED_PARTITION) as unknown as BrowserSession & { cookies: CookieSink },
203 log,
204 platform: process.platform,
205 home: homedir(),
206 env: process.env,
207 list: (path) => readdirSync(path),
208 onControlEnabled: (enabled) => {
209 // The Go host reads this when it builds a session's tool set, so the new
210 // value reaches new sessions without disturbing a running turn.
211 void service.request("desktop/browserControl", { enabled }).catch((error: unknown) => {
212 log.warn(`browser control push failed: ${errorText(error)}`);
213 });
214 },
215 });
216 log.info(
217 `browser control: enabled=${browserControlBootstrap.state.controlEnabled} ignoreCertificateErrors=${browserControlBootstrap.state.ignoreCertificateErrors} warning=${browserControlBootstrap.state.warning ?? "none"}`,
218 );
219
220 const downloads = new DownloadTracker({
221 tabForWebContents: (id) => {
222 const tab = browser.all().find((entry: BrowserTab) => entry.view.page.id === id);
223 return tab ? { id: tab.id, taskId: tab.taskId } : undefined;
224 },
225 defaultDirectory: (taskId) => join(app.getPath("userData"), "downloads", safeDirName(taskId)),
226 onUpdate: (download) => mainWindow.send(IPC.browserDownload, download),
227 log,
228 });
229 guestViews = new ElectronGuestViewFactory({
230 window: () => mainWindow.browserWindow,
231 preloadPath: join(__dirname, "guest-preload.cjs"),
232 log,
233 onSession: (partition, guestSession) => {
234 browserControl.trackSession(partition, guestSession);
235 guestSession.on("will-download", (_event, item, contents) => downloads.handleWillDownload(item, contents.id));
236 },
237 });
238 browser = new BrowserSurfaceManager({
239 views: guestViews,
240 contentSize: () => mainWindow.contentSize(),
241 onTakeover: (tab, reason) =>
242 void service.hostEvent("browser.takeover", {
243 tabId: tab.id,
244 epoch: tab.epoch,
245 reason,
246 }),
247 onCrash: (tab, reason) =>
248 void service.hostEvent("browser.crash", {
249 tabId: tab.id,
250 epoch: tab.epoch,
251 reason,
252 }),
253 log,
254 });
255 browser.subscribe((tabs) => mainWindow.send(IPC.browserTabs, tabs));
256 const grants = new GrantRegistry({ generation: () => service.generation });
257 const documents = new DocumentRegistry();
258 const actions = new ActionExecutor({ surfaces: browser, documents });
259
260 const remote = new RemoteWindowHost({
261 platform: process.platform,
262 icon: windowIcon,
263 log,
264 onClosed: (hostKey) => void service.hostEvent("remoteWindow.closed", { hostKey }),
265 });
266 const tray = new TrayHost({
267 platform: process.platform,
268 iconPath: firstExisting(icons.tray),
269 onOpen: () => {
270 mainWindow.show("tray");
271 void service.hostEvent("tray.open", {});
272 },
273 onQuit: () => void service.hostEvent("tray.quit", {}),
274 log,
275 });
276 const dialogs = new DialogHost(dialog, () => mainWindow.browserWindow ?? undefined);
277
278 lifecycle = new QuitSequencer({
279 service: {
280 beforeClose: async (reason) => record(await service.request("desktop/beforeClose", { reason })).prevent === true,
281 shutdown: (reason, onProgress) => service.shutdown(reason, onProgress),
282 shutdownRequestIdentity: () => service.shutdownRequestIdentity,
283 },
284 app: {
285 quit: () => app.quit(),
286 exit: (code) => app.exit(code),
287 relaunch: (args: string[], execPath?: string) => {
288 if (execPath) delete process.env.REASONIX_DESKTOP_SERVICE;
289 app.relaunch({ args, ...(execPath ? { execPath } : {}) });
290 },
291 },
292 flushRenderer: () => mainWindow.flushSessionDraft(),
293 resumeRenderer: () => mainWindow.resumeSessionDraftEditing(),
294 onWindowClosePrevented: () => mainWindow.hide(),
295 onPrepareFailed: async (message) => {
296 const parent = mainWindow.browserWindow;
297 const options = {
298 type: "error" as const,
299 title: "Close paused / 关闭已暂停",
300 message: "Reasonix could not save the current draft. / Reasonix 无法保存当前草稿。",
301 detail: `${message}\n\nThe window will remain open so you can retry. / 窗口将保持打开,你可以重试。`,
302 buttons: ["OK / 确定"],
303 noLink: true,
304 };
305 if (parent) await dialog.showMessageBox(parent, options);
306 else await dialog.showMessageBox(options);
307 },
308 onShutdownFailed: async (message) => {
309 const options = {
310 type: "error" as const,
311 title: "Exit incomplete / 退出未完成",
312 message: "Reasonix could not safely finish saving and closing. / Reasonix 未能安全完成保存与收尾。",
313 detail: `${message}\n\nYou can retry the remaining steps or keep this window open. / 你可以重试未完成的步骤,或保留当前窗口。`,
314 buttons: ["Retry exit / 重试退出", "Keep open / 保留窗口"],
315 defaultId: 0,
316 cancelId: 1,
317 noLink: true,
318 };
319 const parent = mainWindow.browserWindow;
320 const result = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options);
321 return result.response === 0;
322 },
323 // Website views go first: a WebContents closing after its window is
324 // gone is the ordering that left orphaned renderers in the prototype.
325 onCloseAllowed: () => mainWindow.allowClose(),
326 cleanup: [
327 { name: "browser views", run: () => browser.destroyAll() },
328 { name: "remote windows", run: () => remote.closeAll() },
329 { name: "main window", run: () => mainWindow.close() },
330 { name: "tray", run: () => tray.destroy() },
331 { name: "startup deadline", run: () => clearTimeout(startupTimer) },
332 { name: "startup presentation", run: () => startupDelay.cancel() },
333 { name: "shell lifecycle", run: () => shellLifecycle.complete() },
334 ],
335 log,
336 });
337
338 const hostCalls = buildHostCallTable({
339 window: mainWindow,
340 dialogs,
341 tray,
342 remote,
343 lifecycle,
344 openExternal: (url) => {
345 new URL(url);
346 return shell.openExternal(url);
347 },
348 hideApp: () => {
349 if (process.platform === "darwin") app.hide();
350 else mainWindow.hide();
351 },
352 screens: (): ScreenInfo[] => {
353 const primary = screen.getPrimaryDisplay().id;
354 return screen.getAllDisplays().map((display) => ({
355 x: display.bounds.x,
356 y: display.bounds.y,
357 width: display.bounds.width,
358 height: display.bounds.height,
359 scale: display.scaleFactor,
360 primary: display.id === primary,
361 }));
362 },
363 browser: buildBrowserHostCalls({
364 surfaces: browser,
365 grants,
366 documents,
367 actions,
368 downloads,
369 }),
370 });
371
372 const service = new ServiceSupervisor(
373 {
374 binary: serviceBinary,
375 args: ["--host-rpc"],
376 env: process.env,
377 onStderr: (chunk) => {
378 serviceLog.write(chunk);
379 if (!app.isPackaged) process.stderr.write(chunk);
380 },
381 log,
382 },
383 {
384 hello: async (client) =>
385 validateHelloResult(
386 await client.request(
387 "desktop/hello",
388 buildHelloParams({
389 protocolVersion: contract.protocolVersion,
390 contractDigest: contract.digest,
391 ...loadBuildIdentity(app.isPackaged, process.resourcesPath, process.env),
392 hostVersion: process.versions.electron,
393 chromeVersion: process.versions.chrome,
394 platform: process.platform,
395 arch: process.arch,
396 home: dataHome,
397 dev,
398 }),
399 10_000,
400 ),
401 contract.protocolVersion,
402 ),
403 onRequest: (method, params) => {
404 if (lifecycle.isQuitting) return Promise.reject(new Error("Reasonix is shutting down"));
405 return dispatchHostCall(hostCalls, method, params);
406 },
407 onEvent: (frame) => mainWindow.send(IPC.event, frame),
408 onState: (state) => {
409 shellLifecycle.mark(`service_${state.phase}`);
410 log.info(`startup ${status.generation}: service=${state.phase} generation=${state.generation}`);
411 status.service = state.phase;
412 status.healthy = false;
413 status.rendererVersion = "";
414 firstHeartbeat = 0;
415 if (state.phase === "starting" || state.phase === "restarting") {
416 status.lifecycle = "starting";
417 startupDelay.start(() => {
418 if (lifecycle.isQuitting || status.lifecycle !== "starting" || mainWindow.browserWindow) return;
419 mainWindow.create(DEFAULT_GEOMETRY);
420 void mainWindow.showStartup(renderStartupPage());
421 });
422 clearTimeout(startupTimer);
423 startupTimer = setTimeout(() => {
424 if (lifecycle.isQuitting || status.healthy || status.lifecycle === "failed") return;
425 lastFailure = {
426 code: null,
427 name: "startup_timeout",
428 title: "Startup incomplete / 启动未完成",
429 detail: "Reasonix did not become ready within 30 seconds. Open logs or retry. / 30 秒内未完成启动,请打开日志或重试。",
430 };
431 status.lifecycle = "failed";
432 startupDelay.cancel();
433 log.error(`startup ${status.generation}: readiness timeout`);
434 if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
435 void mainWindow.showFailure(renderFailurePage(lastFailure, logsDir));
436 }, 30_000);
437 startupTimer.unref();
438 }
439 mainWindow.send(IPC.serviceState, state);
440 grants.observeGeneration(state.generation);
441 if (state.phase !== "ready") {
442 browser.pauseForRendererLoss(`service ${state.phase}`);
443 documents.clear();
444 }
445 },
446 onReady: async (hello: HelloResult) => {
447 shellLifecycle.start(hello);
448 startupDelay.cancel();
449 if (lifecycle.isQuitting) return;
450 status.lifecycle = "ready";
451 status.servicePID = hello.service.pid;
452 const identityLog = hello.instance
453 ? `, path identity v${hello.instance.identityVersion} ${hello.instance.identityDigest}`
454 : ", legacy path identity";
455 log.info(
456 `desktop service ready: generation ${hello.runtimeGeneration}, pid ${hello.service.pid}, version=${hello.service.version} channel=${hello.service.channel || "unknown"} commit=${hello.service.commit || "unknown"}${identityLog}`,
457 );
458 try {
459 await zoomStore.load();
460 } catch (error) {
461 log.warn(`app zoom initialization failed: ${errorText(error)}`);
462 }
463 if (lifecycle.isQuitting || service.generation !== hello.runtimeGeneration) return;
464 mainWindow.prepareApp(hello.window);
465 // A restarted service starts with the capability on, so the persisted
466 // switch is replayed before any session can be built.
467 void service
468 .request("desktop/browserControl", {
469 enabled: browserControl.state().controlEnabled,
470 })
471 .catch((error: unknown) => log.warn(`browser control push failed: ${errorText(error)}`));
472 if (!mainWindow.browserWindow) {
473 try {
474 await zoomStore.load();
475 mainWindow.create(hello.window);
476 } catch (error) {
477 log.warn(`app zoom initialization failed: ${errorText(error)}`);
478 mainWindow.create(DEFAULT_GEOMETRY);
479 }
480 }
481 // Reattach the surviving renderer after a service restart. Reloading
482 // would destroy unsent composer drafts; desktop:resync repairs reads.
483 if (!mainWindow.reattachApp()) void mainWindow.loadApp();
484 },
485 onFailed: (error) => {
486 startupDelay.cancel();
487 if (lifecycle.isQuitting) return;
488 const failure = describeHandshakeFailure(error);
489 lastFailure = failure;
490 status.lifecycle = "failed";
491 log.error(`desktop service failed: ${failure.name}: ${failure.detail}`);
492 if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
493 void mainWindow.showFailure(renderFailurePage(failure, logsDir));
494 },
495 },
496 );
497
498 // Session end and scripted shutdowns deliver SIGTERM; quit through the same
499 // sequence as the menu so Go snapshots sessions before the process ends.
500 process.on("SIGTERM", () => lifecycle.requestQuit("system_signal"));
501 app.on("second-instance", (_event, argv) => {
502 if (argv.includes(QUIT_REQUEST)) {
503 lifecycle.requestQuit();
504 return;
505 }
506 presentInstance(argv, "second-instance");
507 });
508 function presentInstance(argv: string[] = [], reason: StartupPresentReason = "second-instance"): void {
509 if (lifecycle.isQuitting) return;
510 if (!app.isReady()) {
511 void app.whenReady().then(() => presentInstance(argv, reason));
512 return;
513 }
514 const action = startupPresentation({
515 serviceReady: service.ready,
516 hasWindow: Boolean(mainWindow.browserWindow),
517 lifecycle: startupLifecycle(status.lifecycle),
518 });
519 if (action === "diagnostic") {
520 if (!mainWindow.browserWindow) mainWindow.create(DEFAULT_GEOMETRY);
521 void mainWindow.showFailure(renderFailurePage(lastFailure, logsDir));
522 }
523 if (action !== "none") mainWindow.focusForSecondInstance();
524 if (service.ready) void service.hostEvent("secondInstance", { argv });
525 }
526 app.on("activate", () => presentInstance([], "activate"));
527 app.on("before-quit", (event) => {
528 startupDelay.cancel();
529 if (!lifecycle.onBeforeQuit()) event.preventDefault();
530 });
531 app.on("window-all-closed", () => {
532 if (!service.ready) lifecycle.requestQuit();
533 });
534 const statusServer =
535 process.platform === "win32"
536 ? listenShellStatus(
537 () => ({
538 ...status,
539 lifecycle: lifecycle.currentPhase === "completed" ? "done" : lifecycle.isQuitting ? "quitting" : status.lifecycle,
540 visible: mainWindow.browserWindow?.isVisible() ?? false,
541 }),
542 log,
543 )
544 : undefined;
545 app.on("will-quit", () => statusServer?.close());
546
547 void app.whenReady().then(() => {
548 if (lifecycle.isQuitting) return;
549 if (process.platform === "darwin") {
550 const dockIcon = firstExisting(icons.window);
551 if (dockIcon && app.dock) app.dock.setIcon(dockIcon);
552 }
553 registerAppProtocol({
554 protocol,
555 fetch: (input, init) => net.fetch(input, init),
556 distRoot,
557 resources: () => service.helloResult?.resources ?? null,
558 log,
559 });
560 session.defaultSession.setPermissionRequestHandler((contents, permission, callback) => {
561 callback(mainWindow.isTrustedSender(contents, contents.mainFrame) && MAIN_WINDOW_PERMISSIONS.has(permission));
562 });
563 const diagnostics = new ProcessDiagnostics(
564 () => app.getAppMetrics(),
565 undefined,
566 () => Boolean(mainWindow.browserWindow?.isVisible() && mainWindow.browserWindow?.isFocused()),
567 );
568 const performanceHost = createPerformanceHost({
569 window: () => mainWindow.browserWindow,
570 dialog,
571 workerPath: join(__dirname, "profile-analysis.cjs"),
572 locale: () => app.getLocale(),
573 });
574 diagnostics.sample();
575 const diagnosticsTimer = setInterval(() => diagnostics.sample(), 30_000);
576 diagnosticsTimer.unref();
577 app.once("will-quit", () => {
578 clearInterval(diagnosticsTimer);
579 performanceHost.dispose();
580 });
581 registerRendererIpc({
582 processDiagnostics: () => diagnostics.snapshot(),
583 performance: performanceHost,
584 ipcMain,
585 contract,
586 window: mainWindow,
587 invoke: async (method, args) => {
588 const generation = service.generation;
589 const result = await service.invoke(method, args);
590 if (generation !== service.generation || lifecycle.isQuitting) return result;
591 if (method === "Version" && typeof result === "string") status.rendererVersion = result;
592 if (method === "ReportDesktopWebViewReady") {
593 if (!firstHeartbeat) firstHeartbeat = Date.now();
594 else if (Date.now() - firstHeartbeat >= 2000 && status.lifecycle === "ready") {
595 status.healthy = true;
596 clearTimeout(startupTimer);
597 }
598 if (status.rendererVersion === "")
599 void mainWindow.browserWindow?.webContents.executeJavaScript('window.reasonixDesktop.invoke("Version", [])').catch(() => undefined);
600 }
601 return result;
602 },
603 serviceState: () => service.current,
604 clipboard,
605 graphics,
606 browserControl,
607 openExternal: (url) => shell.openExternal(url),
608 browser: {
609 list: () => browser.list(),
610 open: async (url, options) => browser.view(await browser.open(url, options)),
611 close: (tabId) => browser.close(tabId),
612 activate: (tabId) => browser.activate(tabId),
613 navigate: async (tabId, target) => {
614 await browser.navigate(tabId, target);
615 },
616 setZoom: (tabId, factor) => browser.setZoom(tabId, factor),
617 toggleDevTools: (tabId) => browser.toggleDevTools(tabId),
618 resume: (tabId) => browser.resume(tabId),
619 takeover: (tabId) => browser.takeover(tabId, "user takeover"),
620 setLayout: (rect) => browser.setLayout(browserLayoutInDIP(rect, mainWindow.browserWindow?.webContents.getZoomFactor() ?? 1)),
621 setOverlay: (active) => browser.setOverlay(active),
622 },
623 log,
624 });
625 // Reports from the guest preload: the sender must be one of our website
626 // views, which takeoverFromSender checks by WebContents id.
627 ipcMain.on(IPC.browserTakeover, (event, payload: unknown) => {
628 const kind = typeof payload === "object" && payload !== null ? (payload as { kind?: unknown }).kind : undefined;
629 if (typeof kind !== "string" || !TAKEOVER_KINDS.has(kind)) return;
630 browser.takeoverFromSender(event.sender.id, kind as BrowserTakeoverKind);
631 });
632 installApplicationMenu({
633 platform: process.platform,
634 openSettings: () => mainWindow.sendShellEvent("app:open-settings", service.generation),
635 toggleDevTools: () => mainWindow.toggleDevTools(),
636 showWindow: () => mainWindow.show("menu"),
637 quit: () => lifecycle.requestQuit(),
638 zoomIn: () => {
639 void mainWindow.stepAppZoom(1);
640 },
641 zoomOut: () => {
642 void mainWindow.stepAppZoom(-1);
643 },
644 resetZoom: () => {
645 void mainWindow.resetAppZoom();
646 },
647 });
648 const probed = serviceLookup.probed.length > 0 ? ` (probed ${serviceLookup.probed.join(", ")})` : "";
649 log.info(`shell starting: service ${serviceBinary}${probed}, ui ${appURL}, dist ${distRoot}, home ${dataHome}`);
650 return service.start().catch(() => undefined);
651 })
652 .catch((error: unknown) => {
653 log.error(`shell bootstrap failed: ${errorText(error)}`);
654 app.exit(1);
655 });
656 }
657
657 lines TYPESCRIPT