pi-web-ui 0.44.2 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,8 @@
6
6
  * 预设存取 + 何时需要 reload」,真正动 runtime 的 session.reload() 走宿主回调
7
7
  * (reloadSession 里还会刷新斜杠命令目录)。
8
8
  */
9
- import { basename } from "node:path";
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
10
11
  import { extensionKey } from "./client-state.js";
11
12
  import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
12
13
  export class SettingsService {
@@ -39,20 +40,88 @@ export class SettingsService {
39
40
  this.pendingReload = false;
40
41
  return v;
41
42
  }
43
+ /** 判断某个 skill 名是否仍存在于磁盘任何来源(agent 区 / 项目 .pi / 祖先
44
+ * .agents/skills / npm 包内 skills)。被禁用且文件已删除的名字不应再
45
+ * 出现在设置面板,也不应留在持久化记录里。 */
46
+ skillStillOnDisk(name) {
47
+ const cwd = this.host.cwd();
48
+ const agentDir = this.host.agentDir();
49
+ const check = (base) => existsSync(join(base, name)) || existsSync(join(base, `${name}.md`));
50
+ // ① 用户区 <agentDir>/skills ② 项目 .pi/skills
51
+ if (check(join(agentDir, "skills")))
52
+ return true;
53
+ if (check(join(cwd, ".pi", "skills")))
54
+ return true;
55
+ // ③ 祖先链 .agents/skills(SDK collectAncestorAgentsSkillDirs 语义,最多上溯 6 层)
56
+ let dir = cwd;
57
+ for (let i = 0; i < 6 && dir !== dirname(dir); i++, dir = dirname(dir)) {
58
+ if (check(join(dir, ".agents", "skills")))
59
+ return true;
60
+ }
61
+ // ④ npm 包内 skills(agent 级 + 项目级,含 @scope 两级子包)
62
+ for (const npmRoot of [
63
+ join(agentDir, "npm", "node_modules"),
64
+ join(cwd, ".pi", "npm", "node_modules"),
65
+ ]) {
66
+ try {
67
+ for (const entry of readdirSync(npmRoot, { withFileTypes: true })) {
68
+ if (!entry.isDirectory())
69
+ continue;
70
+ if (!entry.name.startsWith("@")) {
71
+ if (check(join(npmRoot, entry.name, "skills")))
72
+ return true;
73
+ }
74
+ else {
75
+ for (const sub of readdirSync(join(npmRoot, entry.name), { withFileTypes: true })) {
76
+ if (sub.isDirectory() && check(join(npmRoot, entry.name, sub.name, "skills"))) {
77
+ return true;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // npm 目录不存在/不可读 → 不是来源
85
+ }
86
+ }
87
+ return false;
88
+ }
42
89
  push() {
43
90
  const disabledSkills = new Set(this.settings.disabledSkills);
44
91
  const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
45
92
  const disabledExts = new Set(this.settings.disabledExtensions);
93
+ let loadedSkillNames = null;
46
94
  try {
47
- // Refresh the cache with the CURRENTLY loaded set (post-filter).
48
- for (const s of this.host.getSession().resourceLoader.getSkills().skills) {
95
+ const loadedSkills = this.host.getSession().resourceLoader.getSkills().skills;
96
+ const loadedExts = this.host.getSession().resourceLoader.getExtensions().extensions;
97
+ loadedSkillNames = new Set(loadedSkills.map((s) => s.name));
98
+ // Prune entries that no longer exist on disk AND aren't disabled
99
+ // (e.g. a skill/extension file was deleted). Disabled entries are
100
+ // kept so they can be re-enabled even when filtered out of the loader.
101
+ const keepSkills = new Set([
102
+ ...loadedSkills.map((s) => s.name),
103
+ ...this.settings.disabledSkills,
104
+ ]);
105
+ const keepExts = new Set([
106
+ ...loadedExts.map((e) => extensionKey(e)),
107
+ ...this.settings.disabledExtensions,
108
+ ]);
109
+ for (const name of [...this.knownSkills.keys()]) {
110
+ if (!keepSkills.has(name))
111
+ this.knownSkills.delete(name);
112
+ }
113
+ for (const id of [...this.knownExtensions.keys()]) {
114
+ if (!keepExts.has(id))
115
+ this.knownExtensions.delete(id);
116
+ }
117
+ for (const s of loadedSkills) {
49
118
  this.knownSkills.set(s.name, {
50
119
  name: s.name,
51
120
  description: s.description,
52
121
  enabled: true,
53
122
  });
54
123
  }
55
- for (const e of this.host.getSession().resourceLoader.getExtensions().extensions) {
124
+ for (const e of loadedExts) {
56
125
  const id = extensionKey(e);
57
126
  const p = e.sourceInfo?.path ?? e.path;
58
127
  this.knownExtensions.set(id, {
@@ -68,13 +137,33 @@ export class SettingsService {
68
137
  catch {
69
138
  // Session not ready yet — keep whatever we already know.
70
139
  }
71
- // Disabled entries are filtered out of the loader — keep them in the
72
- // panel (with the last-known description) so they can be re-enabled.
73
- for (const name of this.settings.disabledSkills) {
74
- if (!this.knownSkills.has(name)) {
75
- this.knownSkills.set(name, { name, description: "", enabled: false });
140
+ // 清理“源文件已删除”的禁用残留记录:磁盘上已不存在的技能名从
141
+ // disabledSkills / reviewDisabledSkills 持久化记录中移除——否则每次
142
+ // 推送都会把已删除的 skill 以灰条形式永恒地补回面板(“关闭过的
143
+ // skill 被一直记录”)。session 未就绪时保守跳过。
144
+ if (loadedSkillNames !== null) {
145
+ const stale = [
146
+ ...new Set([...this.settings.disabledSkills, ...this.settings.reviewDisabledSkills]),
147
+ ].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
148
+ if (stale.length > 0) {
149
+ this.settings.disabledSkills = this.settings.disabledSkills.filter((n) => !stale.includes(n));
150
+ this.settings.reviewDisabledSkills = this.settings.reviewDisabledSkills.filter((n) => !stale.includes(n));
151
+ this.host.stateStore.saveSettings(this.host.clientId, {
152
+ disabledSkills: this.settings.disabledSkills,
153
+ reviewDisabledSkills: this.settings.reviewDisabledSkills,
154
+ });
76
155
  }
77
156
  }
157
+ // Disabled entries that still exist on disk are re-added (with the
158
+ // last-known description) so they can be re-enabled; entries whose
159
+ // source file was deleted are dropped instead of being resurrected.
160
+ for (const name of this.settings.disabledSkills) {
161
+ if (this.knownSkills.has(name))
162
+ continue;
163
+ if (!this.skillStillOnDisk(name))
164
+ continue;
165
+ this.knownSkills.set(name, { name, description: "", enabled: false });
166
+ }
78
167
  for (const id of this.settings.disabledExtensions) {
79
168
  if (!this.knownExtensions.has(id)) {
80
169
  this.knownExtensions.set(id, {
@@ -103,6 +192,7 @@ export class SettingsService {
103
192
  terminalBash: this.settings.terminalBash,
104
193
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
105
194
  thinkingWrap: this.settings.thinkingWrap,
195
+ toolsWrap: this.settings.toolsWrap,
106
196
  visionBridgeEnabled: this.settings.visionBridgeEnabled,
107
197
  visionBridgeModel: this.settings.visionBridgeModel,
108
198
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
@@ -174,6 +264,9 @@ export class SettingsService {
174
264
  if (partial.thinkingWrap !== undefined) {
175
265
  this.settings.thinkingWrap = partial.thinkingWrap;
176
266
  }
267
+ if (partial.toolsWrap !== undefined) {
268
+ this.settings.toolsWrap = partial.toolsWrap;
269
+ }
177
270
  if (partial.visionBridgeEnabled !== undefined) {
178
271
  this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
179
272
  }
@@ -245,10 +338,11 @@ export class SettingsService {
245
338
  reviewDisabledSkills: [
246
339
  ...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
247
340
  ],
248
- // Presets don't capture vision-bridge prefs — keep the current ones.
249
- visionBridgeEnabled: this.settings.visionBridgeEnabled,
250
341
  // 纯 UI 偏好不进预设——保留当前值。
251
342
  thinkingWrap: this.settings.thinkingWrap,
343
+ toolsWrap: this.settings.toolsWrap,
344
+ // Presets don't capture vision-bridge prefs — keep the current ones.
345
+ visionBridgeEnabled: this.settings.visionBridgeEnabled,
252
346
  visionBridgeModel: this.settings.visionBridgeModel,
253
347
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
254
348
  visionBridgePrompt: this.settings.visionBridgePrompt,
@@ -1230,11 +1230,11 @@ export const TERMINAL_TOOL_NAMES = [
1230
1230
  /** System-prompt guidance teaching the model WHEN to prefer the terminal tools
1231
1231
  * over one-shot bash. Without it models almost never pick them — bash returns
1232
1232
  * complete output in a single call, so it always wins on convenience. */
1233
- export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
1234
- - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
1235
- - You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
1236
- - The user explicitly asks you to work in the visible terminal panel.
1237
- Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
1233
+ export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
1234
+ - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
1235
+ - You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
1236
+ - The user explicitly asks you to work in the visible terminal panel.
1237
+ Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
1238
1238
  Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
1239
1239
  /** Build the agent-facing persistent terminal tools for one conversation. */
1240
1240
  export function makePersistentTerminalTools(terminals, cwd) {
@@ -0,0 +1,427 @@
1
+ /**
2
+ * pi-web-ui Electron 桌面版主进程。
3
+ *
4
+ * 架构:
5
+ * - 主进程 fork 一个隐藏子进程跑 server(ELECTRON_RUN_AS_NODE=1),
6
+ * server 使用 Electron 内置的 Node 运行时,node-pty 等原生模块
7
+ * 在 electron-builder 打包时自动 rebuild 为 Electron ABI。
8
+ * - BrowserWindow 加载 http://127.0.0.1:{PORT}(server 就绪后)。
9
+ * - 托盘:关闭窗口 → 最小化到托盘;Quit → 真正退出(杀 server 子进程)。
10
+ * - 自动更新:electron-updater + GitHub Releases。
11
+ *
12
+ * 开发模式(npm run dev:electron):
13
+ * 先构建 web + server(npm run build),然后 electron . 即可。
14
+ * 如有 Vite dev server (:5173),优先加载它获取 HMR。
15
+ */
16
+
17
+ import { app, BrowserWindow, Tray, Menu, nativeImage, dialog, Notification } from "electron";
18
+ import { fork } from "node:child_process";
19
+ import { createServer } from "node:net";
20
+ import { existsSync, mkdirSync } from "node:fs";
21
+ import { join, dirname, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { autoUpdater } from "electron-updater";
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const ROOT = resolve(__dirname, "..");
27
+ const isDev = !app.isPackaged || !!process.env.DEV;
28
+
29
+ // ── 状态 ──
30
+ /** @type {BrowserWindow | null} */
31
+ let mainWindow = null;
32
+ /** @type {Tray | null} */
33
+ let tray = null;
34
+ /** @type {import("node:child_process").ChildProcess | null} */
35
+ let serverProcess = null;
36
+ let isQuitting = false;
37
+ let serverPort = 0;
38
+
39
+ // ── 路径 ──
40
+
41
+ /** 打包后 server 在 resources/dist/server/index.js */
42
+ function getServerPath() {
43
+ if (isDev) return join(ROOT, "dist", "server", "index.js");
44
+ return join(process.resourcesPath, "dist", "server", "index.js");
45
+ }
46
+
47
+ /** 打包后 themes 在 resources/themes/ */
48
+ function getPkgRoot() {
49
+ if (isDev) return ROOT;
50
+ return process.resourcesPath;
51
+ }
52
+
53
+ // ── 端口 ──
54
+
55
+ /** 找一个随机空闲端口(防止端口冲突) */
56
+ async function findFreePort() {
57
+ return new Promise((resolve, reject) => {
58
+ const srv = createServer();
59
+ srv.listen(0, "127.0.0.1", () => {
60
+ const port = /** @type {import("node:net").AddressInfo} */ (srv.address()).port;
61
+ srv.close(() => resolve(port));
62
+ });
63
+ srv.on("error", reject);
64
+ });
65
+ }
66
+
67
+ // ── 启动 Server 子进程 ──
68
+
69
+ async function startServer() {
70
+ serverPort = await findFreePort();
71
+ const serverPath = getServerPath();
72
+
73
+ if (!existsSync(serverPath)) {
74
+ dialog.showErrorBox(
75
+ "pi-web-ui 启动失败",
76
+ `找不到 server 入口:${serverPath}\n\n请先执行 npm run build,然后重试。`,
77
+ );
78
+ app.quit();
79
+ return;
80
+ }
81
+
82
+ // 确保 ~/.pi-web 目录存在(server 需要)
83
+ const dataDir = join(process.env.HOME || process.env.USERPROFILE || "~", ".pi-web");
84
+ mkdirSync(dataDir, { recursive: true });
85
+
86
+ serverProcess = fork(serverPath, [], {
87
+ env: {
88
+ ...process.env,
89
+ PORT: String(serverPort),
90
+ PI_WEB_HOST: "127.0.0.1",
91
+ PI_WEB_NO_BROWSER: "1", // 不要自动打开浏览器
92
+ PI_WEB_PKG_ROOT: getPkgRoot(), // 告诉 server 去哪找 web/dist / themes
93
+ ELECTRON_RUN_AS_NODE: "1", // 以 Node.js 模式运行(非 Electron)
94
+ },
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ serialization: "json",
97
+ });
98
+
99
+ // 收集 stdout/stderr 用于调试
100
+ let serverOut = "";
101
+ serverProcess.stdout?.on("data", (chunk) => {
102
+ const text = chunk.toString();
103
+ serverOut += text;
104
+ process.stdout.write(`[server] ${text}`);
105
+ });
106
+ serverProcess.stderr?.on("data", (chunk) => {
107
+ const text = chunk.toString();
108
+ serverOut += text;
109
+ process.stderr.write(`[server:err] ${text}`);
110
+ });
111
+
112
+ // 等待 server 就绪:监听 stdout 中的 "⚡ pi-web-ui" 标记
113
+ await new Promise((resolve, reject) => {
114
+ const timeout = setTimeout(() => {
115
+ reject(
116
+ new Error(
117
+ `Server 启动超时(30s)。最后输出:\n${serverOut.slice(-500)}`,
118
+ ),
119
+ );
120
+ }, 30_000);
121
+
122
+ const checkOutput = (chunk) => {
123
+ const text = chunk.toString();
124
+ if (text.includes("⚡ pi-web-ui") || text.includes("http://localhost")) {
125
+ clearTimeout(timeout);
126
+ resolve();
127
+ }
128
+ };
129
+
130
+ serverProcess.stdout?.on("data", checkOutput);
131
+ serverProcess.on("error", (err) => {
132
+ clearTimeout(timeout);
133
+ reject(err);
134
+ });
135
+ serverProcess.on("exit", (code) => {
136
+ clearTimeout(timeout);
137
+ if (code !== 0) {
138
+ reject(new Error(`Server 退出了 (exit code=${code})`));
139
+ }
140
+ });
141
+ });
142
+
143
+ console.log(`[electron] server 就绪,端口 ${serverPort}`);
144
+ }
145
+
146
+ // ── 窗口 ──
147
+
148
+ function createWindow() {
149
+ mainWindow = new BrowserWindow({
150
+ width: 1200,
151
+ height: 800,
152
+ minWidth: 800,
153
+ minHeight: 600,
154
+ title: "pi-web-ui",
155
+ show: false,
156
+ webPreferences: {
157
+ preload: join(__dirname, "preload.mjs"),
158
+ nodeIntegration: false,
159
+ contextIsolation: true,
160
+ },
161
+ icon: join(__dirname, "icon.png"),
162
+ });
163
+
164
+ // 加载 localhost 上的 server
165
+ const url = `http://127.0.0.1:${serverPort}`;
166
+ mainWindow.loadURL(url);
167
+
168
+ mainWindow.once("ready-to-show", () => {
169
+ mainWindow.show();
170
+ });
171
+
172
+ // 关闭窗口 → 隐藏到托盘(不退出)
173
+ mainWindow.on("close", (event) => {
174
+ if (!isQuitting) {
175
+ event.preventDefault();
176
+ mainWindow.hide();
177
+ return false;
178
+ }
179
+ });
180
+
181
+ mainWindow.on("closed", () => {
182
+ mainWindow = null;
183
+ });
184
+
185
+ // 开发模式打开 DevTools
186
+ if (isDev) {
187
+ mainWindow.webContents.openDevTools({ mode: "detach" });
188
+ }
189
+ }
190
+
191
+ // ── 托盘 ──
192
+
193
+ function createTray() {
194
+ // 用 16x16 的图标,从 nativeImage 创建
195
+ // 如果没有图标,用 nativeImage.createEmpty()
196
+ const icon = nativeImage.createEmpty();
197
+ tray = new Tray(icon);
198
+ tray.setToolTip("pi-web-ui");
199
+
200
+ const contextMenu = Menu.buildFromTemplate([
201
+ {
202
+ label: "显示窗口",
203
+ click: () => {
204
+ if (mainWindow) {
205
+ mainWindow.show();
206
+ mainWindow.focus();
207
+ }
208
+ },
209
+ },
210
+ { type: "separator" },
211
+ {
212
+ label: "关于 pi-web-ui",
213
+ click: () => {
214
+ dialog.showMessageBox({
215
+ type: "info",
216
+ title: "关于 pi-web-ui",
217
+ message: `pi-web-ui v${app.getVersion()}`,
218
+ detail: "Web chat interface for the pi coding agent.",
219
+ });
220
+ },
221
+ },
222
+ { type: "separator" },
223
+ {
224
+ label: "退出",
225
+ click: () => {
226
+ isQuitting = true;
227
+ app.quit();
228
+ },
229
+ },
230
+ ]);
231
+
232
+ tray.setContextMenu(contextMenu);
233
+
234
+ // 点击托盘图标 → 显示/隐藏窗口
235
+ tray.on("click", () => {
236
+ if (mainWindow) {
237
+ if (mainWindow.isVisible()) {
238
+ mainWindow.hide();
239
+ } else {
240
+ mainWindow.show();
241
+ mainWindow.focus();
242
+ }
243
+ }
244
+ });
245
+ }
246
+
247
+ // ── 菜单 ──
248
+
249
+ function createAppMenu() {
250
+ const template = [
251
+ {
252
+ label: "pi-web-ui",
253
+ submenu: [
254
+ {
255
+ label: "关于 pi-web-ui",
256
+ click: () => {
257
+ dialog.showMessageBox({
258
+ type: "info",
259
+ title: "关于 pi-web-ui",
260
+ message: `pi-web-ui v${app.getVersion()}`,
261
+ detail: "Web chat interface for the pi coding agent.",
262
+ });
263
+ },
264
+ },
265
+ { type: "separator" },
266
+ { role: "hide" },
267
+ { role: "hideOthers" },
268
+ { role: "unhide" },
269
+ { type: "separator" },
270
+ {
271
+ label: "退出",
272
+ accelerator: "CmdOrCtrl+Q",
273
+ click: () => {
274
+ isQuitting = true;
275
+ app.quit();
276
+ },
277
+ },
278
+ ],
279
+ },
280
+ {
281
+ label: "编辑",
282
+ submenu: [
283
+ { role: "undo" },
284
+ { role: "redo" },
285
+ { type: "separator" },
286
+ { role: "cut" },
287
+ { role: "copy" },
288
+ { role: "paste" },
289
+ { role: "selectAll" },
290
+ ],
291
+ },
292
+ {
293
+ label: "视图",
294
+ submenu: [
295
+ { role: "reload" },
296
+ { role: "forceReload" },
297
+ { role: "toggleDevTools" },
298
+ { type: "separator" },
299
+ { role: "resetZoom" },
300
+ { role: "zoomIn" },
301
+ { role: "zoomOut" },
302
+ { type: "separator" },
303
+ { role: "togglefullscreen" },
304
+ ],
305
+ },
306
+ {
307
+ label: "窗口",
308
+ submenu: [
309
+ { role: "minimize" },
310
+ { role: "close" },
311
+ ],
312
+ },
313
+ ];
314
+
315
+ // macOS 需要第一个菜单项是应用名
316
+ const menu = Menu.buildFromTemplate(template);
317
+ Menu.setApplicationMenu(menu);
318
+ }
319
+
320
+ // ── 自动更新 ──
321
+
322
+ function setupAutoUpdater() {
323
+ if (isDev) return; // 开发模式不检查更新
324
+
325
+ autoUpdater.autoDownload = false;
326
+ autoUpdater.autoInstallOnAppQuit = true;
327
+
328
+ // 检查更新(启动后延迟 5s)
329
+ setTimeout(() => {
330
+ autoUpdater.checkForUpdates().catch(() => {
331
+ // 静默失败(无网络 / 超时等)
332
+ });
333
+ }, 5000);
334
+
335
+ autoUpdater.on("update-available", (info) => {
336
+ const notification = new Notification({
337
+ title: "pi-web-ui 更新可用",
338
+ body: `版本 ${info.version} 可下载(当前 ${app.getVersion()})`,
339
+ });
340
+ notification.on("click", () => {
341
+ autoUpdater.downloadUpdate();
342
+ });
343
+ notification.show();
344
+ });
345
+
346
+ autoUpdater.on("update-downloaded", () => {
347
+ const result = dialog.showMessageBoxSync({
348
+ type: "info",
349
+ title: "更新已下载",
350
+ message: "新版本已下载完成,是否立即重启以安装更新?",
351
+ buttons: ["立即重启", "稍后"],
352
+ });
353
+ if (result === 0) {
354
+ autoUpdater.quitAndInstall();
355
+ }
356
+ });
357
+ }
358
+
359
+ // ── 应用生命周期 ──
360
+
361
+ app.setAppUserModelId("com.xingshuyin.pi-web-ui");
362
+
363
+ // 确保只有一个实例
364
+ const gotLock = app.requestSingleInstanceLock();
365
+ if (!gotLock) {
366
+ app.quit();
367
+ } else {
368
+ app.on("second-instance", () => {
369
+ if (mainWindow) {
370
+ if (mainWindow.isMinimized()) mainWindow.restore();
371
+ mainWindow.show();
372
+ mainWindow.focus();
373
+ }
374
+ });
375
+ }
376
+
377
+ app.whenReady().then(async () => {
378
+ try {
379
+ await startServer();
380
+ } catch (err) {
381
+ dialog.showErrorBox("pi-web-ui 启动失败", err.message);
382
+ app.quit();
383
+ return;
384
+ }
385
+
386
+ createAppMenu();
387
+ createWindow();
388
+ createTray();
389
+ setupAutoUpdater();
390
+
391
+ app.on("activate", () => {
392
+ if (!mainWindow) createWindow();
393
+ if (mainWindow) {
394
+ mainWindow.show();
395
+ mainWindow.focus();
396
+ }
397
+ });
398
+ });
399
+
400
+ app.on("window-all-closed", () => {
401
+ // macOS 不退出(dock 图标还在)
402
+ // 其他平台退出
403
+ if (process.platform !== "darwin") {
404
+ isQuitting = true;
405
+ app.quit();
406
+ }
407
+ });
408
+
409
+ app.on("before-quit", () => {
410
+ isQuitting = true;
411
+ });
412
+
413
+ app.on("will-quit", () => {
414
+ // 杀 server 子进程
415
+ if (serverProcess) {
416
+ try {
417
+ serverProcess.kill("SIGTERM");
418
+ } catch {
419
+ // 可能已经退出
420
+ }
421
+ serverProcess = null;
422
+ }
423
+ if (tray) {
424
+ tray.destroy();
425
+ tray = null;
426
+ }
427
+ });