返回 DeepSeek-Reasonix
tray.ts
根目录 / desktop / electron / src / main / tray.ts
1 import { existsSync } from "node:fs";
2 import { basename } from "node:path";
3 import { Menu, nativeImage, Tray } from "electron";
4 import type { TrayLabels } from "./hostCalls.js";
5 import { errorText, type Logger } from "./log.js";
6
7 export interface TrayDeps {
8 platform: NodeJS.Platform;
9 iconPath: string | null;
10 onOpen(): void;
11 onQuit(): void;
12 log: Logger;
13 }
14
15 // macOS template-icon naming convention (`nameTemplate.png` / `nameTemplate@2x.png`).
16 const TEMPLATE_ICON_FILE = /Template(@2x)?\.png$/;
17
18 function isTemplateIconPath(path: string): boolean {
19 return TEMPLATE_ICON_FILE.test(basename(path));
20 }
21
22 // Loads a 1x template PNG and attaches its @2x sibling as a retina
23 // representation so the menu bar glyph stays sharp on scaled displays.
24 function templateTrayImage(path: string): Electron.NativeImage | null {
25 const image = nativeImage.createFromPath(path);
26 if (image.isEmpty()) return null;
27 const retinaPath = path.replace(/\.png$/, "@2x.png");
28 if (existsSync(retinaPath)) {
29 const retina = nativeImage.createFromPath(retinaPath);
30 if (!retina.isEmpty()) image.addRepresentation({ scaleFactor: 2, buffer: retina.toPNG() });
31 }
32 return image;
33 }
34
35 export class TrayHost {
36 private tray: Tray | null = null;
37
38 constructor(private readonly deps: TrayDeps) {}
39
40 ensure(labels: TrayLabels): { ready: boolean; reason: string } {
41 const { deps } = this;
42 if (!deps.iconPath) return { ready: false, reason: "tray icon asset missing" };
43 try {
44 if (!this.tray) {
45 let image = nativeImage.createFromPath(deps.iconPath);
46 if (image.isEmpty()) return { ready: false, reason: `tray icon could not be decoded: ${deps.iconPath}` };
47 if (deps.platform === "darwin") {
48 const template = isTemplateIconPath(deps.iconPath) ? templateTrayImage(deps.iconPath) : null;
49 if (template) {
50 template.setTemplateImage(true);
51 image = template;
52 } else {
53 // Full-canvas color artwork must never be template-ized: the alpha
54 // mask renders as a solid black block (#10715). A small color icon
55 // is the least-broken fallback when the template asset is missing.
56 image = image.resize({ width: 18, height: 18 });
57 }
58 } else if (deps.platform === "win32") {
59 image = image.resize({ width: 16, height: 16 });
60 }
61 const tray = new Tray(image);
62 tray.on("click", () => deps.onOpen());
63 this.tray = tray;
64 }
65 this.tray.setToolTip(labels.tooltip || "Reasonix");
66 this.tray.setContextMenu(Menu.buildFromTemplate([
67 { label: labels.openTitle, toolTip: labels.openTooltip, click: () => deps.onOpen() },
68 { label: labels.quitTitle, toolTip: labels.quitTooltip, click: () => deps.onQuit() },
69 ]));
70 return { ready: true, reason: "" };
71 } catch (error) {
72 deps.log.warn(`tray unavailable: ${errorText(error)}`);
73 this.destroy();
74 return { ready: false, reason: errorText(error) };
75 }
76 }
77
78 destroy(): void {
79 try {
80 this.tray?.destroy();
81 } catch {
82 // Already gone.
83 }
84 this.tray = null;
85 }
86 }
87
87 lines TYPESCRIPT