@glittering0717/ai-browser 0.0.0-stage → 1.2.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.
@@ -0,0 +1,137 @@
1
+ // main/download_monitor.js — 让"点导出"真的落盘,并且 agent 找得到文件。
2
+ //
3
+ // # 为什么需要它(2026-10-12 审计发现)
4
+ // src/ 里没有任何 `will-download` 处理器。Electron 的默认行为是**取消下载**:
5
+ // agent 点"导出 CSV",ui.act 返回 success,然后什么都没有发生 —— 又一处
6
+ // `success ≠ 做成了`,而且比动作失败更隐蔽,因为连错误都没有。
7
+ //
8
+ // # 设计约束
9
+ // 1. 落盘路径必须**可预期**:`<userData>/downloads/<tabId>/<文件名>`,可用
10
+ // AI_BROWSER_DOWNLOAD_DIR 覆盖。随机临时目录对 agent 没用 —— 它得能告诉用户
11
+ // "文件在这儿"。
12
+ // 2. 每个 tab 独立子目录:多 tab 并发导出时不会互相覆盖,也能按 tab 清理。
13
+ // 3. 状态机完整:progressing → completed / cancelled / interrupted。
14
+ // 只记 completed 会在"下载到一半被取消"时给 agent 一个不存在的文件。
15
+ // 4. 关应用要清空:与 _snapshots 同理,长会话不能无限累积。
16
+ import path from 'node:path';
17
+ import fs from 'node:fs';
18
+ import { config } from '../shared/config.js';
19
+
20
+ const MAX_RECORDS_PER_TAB = 200;
21
+
22
+ export class DownloadMonitor {
23
+ constructor(session, opts = {}) {
24
+ this.session = session;
25
+ this.baseDir = opts.dir || process.env.AI_BROWSER_DOWNLOAD_DIR
26
+ || path.join(config.userDataDir, 'downloads');
27
+ // tabId -> Map<downloadId, record>(Map 保序,便于"最近的在后面")
28
+ this._byTab = new Map();
29
+ this._seq = 0;
30
+ this._broadcast = opts.broadcast || (() => {});
31
+ this._installed = false;
32
+ }
33
+
34
+ install() {
35
+ if (this._installed || !this.session || typeof this.session.on !== 'function') return;
36
+ this._installed = true;
37
+ // ⚠️ 第三个参数才是发起下载的 webContents。漏掉它就没办法把下载归属到 tab,
38
+ // 于是所有记录都落到 'unknown' 下,而按 tab 查询(ui.downloads 的默认行为)
39
+ // 会返回空 —— 表现就是"下载明明发生了却查不到"。
40
+ this.session.on('will-download', (_event, item, webContents) => this._onWillDownload(item, webContents));
41
+ }
42
+
43
+ _onWillDownload(item, webContents) {
44
+ const tabId = this._tabIdOf(item, webContents);
45
+ const id = 'dl-' + (++this._seq);
46
+ // 文件名要净化:站点可能给出含路径分隔符或 `..` 的名字,直接拼会写到目录外。
47
+ const raw = String(item.getFilename() || 'download');
48
+ const safe = path.basename(raw).replace(/[\u0000-\u001f"*/:<>?\\|]/g, '_') || 'download';
49
+ const dir = path.join(this.baseDir, String(tabId));
50
+ const savePath = path.join(dir, safe);
51
+
52
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (_) { /* 落盘失败在下面体现 */ }
53
+ item.setSavePath(savePath);
54
+
55
+ const rec = {
56
+ id,
57
+ tab: tabId,
58
+ filename: safe,
59
+ path: savePath,
60
+ url: item.getURL(),
61
+ mime: item.getMimeType(),
62
+ total_bytes: item.getTotalBytes(),
63
+ received_bytes: 0,
64
+ state: 'progressing',
65
+ started_at: new Date().toISOString(),
66
+ };
67
+ this._push(tabId, rec);
68
+
69
+ item.on('updated', (_e, state) => {
70
+ rec.received_bytes = item.getReceivedBytes();
71
+ rec.total_bytes = item.getTotalBytes();
72
+ // 'progressing' 是唯一值得反复推的状态;interrupted 会紧跟一个 done。
73
+ if (state === 'interrupted') rec.state = 'interrupted';
74
+ });
75
+ item.on('done', (_e, state) => {
76
+ rec.received_bytes = item.getReceivedBytes();
77
+ rec.total_bytes = item.getTotalBytes();
78
+ rec.state = state; // completed | cancelled | interrupted
79
+ rec.finished_at = new Date().toISOString();
80
+ if (state === 'completed') {
81
+ // 报告"文件在哪儿"才是终点。setSavePath 之后 Electron 可能因重名改名,
82
+ // 所以用回读的 getSavePath(),而不是我们自己拼的那个。
83
+ rec.path = item.getSavePath() || savePath;
84
+ rec.filename = path.basename(rec.path);
85
+ }
86
+ this._broadcast('download', { ...rec });
87
+ });
88
+ }
89
+
90
+ /**
91
+ * 决定这条下载归属哪个 tab。
92
+ *
93
+ * webContents 来自 will-download 的第三个参数(**不是** item.webContents ——
94
+ * DownloadItem 上并没有可靠的公开属性)。由 PageManager 注入解析器做
95
+ * webContents → tabId。查不到就归到 'unknown' —— **不能因此丢掉记录**,
96
+ * 丢掉就等于回到"静默失败",那正是这个模块要消灭的东西。
97
+ */
98
+ _tabIdOf(item, webContents) {
99
+ const wc = webContents || (item && item.webContents) || null;
100
+ try {
101
+ if (typeof this._resolveTab === 'function') {
102
+ const t = this._resolveTab(wc);
103
+ if (t !== undefined && t !== null) return t;
104
+ }
105
+ } catch (_) { /* ignore */ }
106
+ return 'unknown';
107
+ }
108
+
109
+ _push(tabId, rec) {
110
+ if (!this._byTab.has(tabId)) this._byTab.set(tabId, new Map());
111
+ const m = this._byTab.get(tabId);
112
+ m.set(rec.id, rec);
113
+ while (m.size > MAX_RECORDS_PER_TAB) {
114
+ const oldest = m.keys().next();
115
+ if (oldest.done) break;
116
+ m.delete(oldest.value);
117
+ }
118
+ }
119
+
120
+ list(tabId) {
121
+ if (tabId === undefined || tabId === null) {
122
+ const all = [];
123
+ for (const m of this._byTab.values()) for (const r of m.values()) all.push(r);
124
+ return { ok: true, downloads: all, dir: this.baseDir };
125
+ }
126
+ const m = this._byTab.get(tabId);
127
+ return { ok: true, downloads: m ? Array.from(m.values()) : [], dir: this.baseDir };
128
+ }
129
+
130
+ clearTab(tabId) {
131
+ this._byTab.delete(tabId);
132
+ }
133
+
134
+ clearAll() {
135
+ this._byTab.clear();
136
+ }
137
+ }
@@ -0,0 +1,359 @@
1
+ // main/index.js — Electron entry v5 (multi-tab, real UI)
2
+ // One process, one WS server, multiple tabs with real tab bar.
3
+ import { app, BrowserWindow, BrowserView, ipcMain, net, session } from 'electron';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { startWSServer } from './ws_server.js';
7
+ import { PageManager } from './page_manager.js';
8
+ import { config } from '../shared/config.js';
9
+ import { parseWatchdogTargets, isWatchdogEnabled } from '../shared/watchdog.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+
14
+ // CRITICAL: set userData BEFORE app.whenReady — session/cookie storage
15
+ // is initialized during ready, so setting it after loses persistence.
16
+ app.setPath('userData', config.userDataDir);
17
+
18
+ // Disable GPU compositing to avoid a whole class of GPU driver crashes
19
+ // (exit_code=6) that take down the whole app on some systems. This only turns
20
+ // off hardware acceleration — the window itself stays a real, visible window
21
+ // and a human can drive it with mouse/keyboard at any time (login, QR scan,
22
+ // captcha, correcting the agent).
23
+ app.disableHardwareAcceleration();
24
+
25
+ // Disable Chromium sandbox — required when launched from restricted
26
+ // environments (e.g. TRAE IDE shell) where the sandbox helper cannot
27
+ // acquire the privileges it needs, causing "sandbox initialization failed:
28
+ // Operation not permitted" and immediate app exit. Safe here because we
29
+ // already run with contextIsolation:true + nodeIntegration:false, so
30
+ // renderer code has no Node access regardless of OS-level sandbox.
31
+ app.commandLine.appendSwitch('no-sandbox');
32
+
33
+ // Keep renderers fully active even when the window is occluded (e.g. an IDE
34
+ // fullscreen window sits on top of the browser). By default Chromium
35
+ // backgroundses occluded windows: the page goes visibilityState=hidden and
36
+ // the renderer drops CDP-injected mouse/keyboard events — clicks report
37
+ // success (elementFromPoint check passes) yet never reach the page, and
38
+ // typing inserts nothing. These switches stop that backgrounding so the
39
+ // agent's input pipeline works regardless of window stacking.
40
+ app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
41
+ app.commandLine.appendSwitch('disable-renderer-backgrounding');
42
+ app.commandLine.appendSwitch('disable-background-timer-throttling');
43
+
44
+ const PORT = config.wsPort;
45
+ const TAB_BAR_HEIGHT = config.tabBarHeight;
46
+
47
+ // Network watchdog tuning (#5). We only need the NetworkService to round-trip,
48
+ // not any specific host — so we probe MULTIPLE hosts concurrently and treat the
49
+ // network as alive if ANY answers. A single hardcoded host (example.com) is
50
+ // unreliable from mainland China and caused false "wedged" verdicts → reboot
51
+ // loops. Override with AI_BROWSER_WATCHDOG_URLS (comma-separated, no spaces
52
+ // required) or turn the whole watchdog off with AI_BROWSER_WATCHDOG=0.
53
+ const WATCHDOG_TARGETS = parseWatchdogTargets(process.env.AI_BROWSER_WATCHDOG_URLS);
54
+ const WATCHDOG_INTERVAL = 30000; // probe cadence
55
+ const WATCHDOG_TIMEOUT = 5000; // per-target per-probe deadline
56
+ const WATCHDOG_FAIL_LIMIT = 5; // relaunch after this many consecutive failures
57
+
58
+ let mainWindow = null;
59
+ let wsServer = null;
60
+ let tabBarView = null;
61
+ let pageManager = null;
62
+ let tabRefreshInterval = null;
63
+ let networkWatchdog = null;
64
+ let isQuitting = false;
65
+
66
+ // === Single instance lock — second launch just focuses the existing window ===
67
+ const gotLock = app.requestSingleInstanceLock();
68
+ if (gotLock) {
69
+ app.on('second-instance', () => {
70
+ if (mainWindow) {
71
+ if (mainWindow.isMinimized()) mainWindow.restore();
72
+ mainWindow.focus();
73
+ }
74
+ });
75
+ } else if (!(process.platform === 'darwin' && app.isPackaged)) {
76
+ // A real second instance would fight the first one over the :9223 WS port.
77
+ // Still tolerant of weird environments (EPERM on sandboxed setups, and
78
+ // macOS packaged builds where the lock can report false and quitting would
79
+ // make the app unlaunchable) — everywhere else, the duplicate exits and
80
+ // lets the existing window handle the request.
81
+ console.error('[index] another AI Browser instance is already running — quitting');
82
+ app.quit();
83
+ }
84
+
85
+ function createWindow() {
86
+ mainWindow = new BrowserWindow({
87
+ width: 1280,
88
+ height: 800,
89
+ // 先不显示:如何显示由 showInitialWindow() 按焦点策略决定 —— 窗口必须真实可见
90
+ // (产品承诺人可随时介入),但被 agent 拉起时不该把用户正在做的事打断。
91
+ show: false,
92
+ webPreferences: {
93
+ contextIsolation: true,
94
+ nodeIntegration: false,
95
+ },
96
+ });
97
+
98
+ // Tab bar — needs nodeIntegration for ipcRenderer
99
+ tabBarView = new BrowserView({
100
+ webPreferences: {
101
+ contextIsolation: false,
102
+ nodeIntegration: true,
103
+ },
104
+ });
105
+ mainWindow.addBrowserView(tabBarView);
106
+ tabBarView.setBounds({ x: 0, y: 0, width: 1280, height: TAB_BAR_HEIGHT });
107
+ tabBarView.webContents.loadFile(path.join(__dirname, '../renderer/tab_bar.html'));
108
+
109
+ // Init PageManager
110
+ pageManager = PageManager.getInstance(mainWindow);
111
+ pageManager._preloadPath = path.join(__dirname, '../preload/bridge.cjs');
112
+
113
+ // Create first tab
114
+ pageManager.newTab('https://www.baidu.com');
115
+
116
+ // Start WS server — pass cleanupAndQuit so ui.quit can shut down the app
117
+ wsServer = startWSServer(pageManager, PORT, cleanupAndQuit);
118
+
119
+ // === Tab bar IPC ===
120
+ ipcMain.on('tab:new', () => {
121
+ pageManager.newTab('https://www.baidu.com');
122
+ refreshTabBar();
123
+ });
124
+ ipcMain.on('tab:close', (_e, tabId) => {
125
+ pageManager.closeTab(tabId);
126
+ refreshTabBar();
127
+ });
128
+ ipcMain.on('tab:activate', (_e, tabId) => {
129
+ pageManager.setActive(tabId);
130
+ refreshTabBar();
131
+ });
132
+ ipcMain.handle('tab:list', () => pageManager.listTabs());
133
+
134
+ // Intercept new-window / window.open → new tab instead of new window
135
+ ipcMain.on('tab:new-url', (_e, url) => {
136
+ pageManager.newTab(url);
137
+ refreshTabBar();
138
+ });
139
+
140
+ // Resize handler
141
+ mainWindow.on('resize', () => {
142
+ const bounds = mainWindow.getContentBounds();
143
+ if (tabBarView && !tabBarView.webContents.isDestroyed()) {
144
+ tabBarView.setBounds({ x: 0, y: 0, width: bounds.width, height: TAB_BAR_HEIGHT });
145
+ }
146
+ pageManager._layoutAllViews(bounds);
147
+ });
148
+
149
+ // === Window becoming visible again → force a compositor repaint ===
150
+ //
151
+ // The switches above keep renderers *running* while occluded, but they do
152
+ // not guarantee the *compositor* produces a fresh frame when the window comes
153
+ // back: macOS/Chromium can keep showing the last composited surface. The
154
+ // switches also only apply from process start, so they cannot fix a window
155
+ // that was hidden/occluded by other means.
156
+ //
157
+ // Why this matters here specifically: the product invariant is "never steal
158
+ // focus", so this window routinely sits behind the user's editor for minutes
159
+ // while the agent works. When the user finally looks at it, they may be
160
+ // looking at a stale frame. Repainting on show/restore/focus means the view
161
+ // catches up the moment it becomes visible — no clicking tabs required.
162
+ const repaintActive = () => { try { pageManager.repaint(pageManager.activeTab); } catch (e) {} };
163
+ mainWindow.on('show', repaintActive);
164
+ mainWindow.on('restore', repaintActive);
165
+ mainWindow.on('focus', repaintActive);
166
+ mainWindow.on('app-command-added', (_e, cmd) => {
167
+ // macOS window-management commands (zoom / minimize / fullscreen).
168
+ if (cmd === 'togglefullscreen' || cmd === 'zoom' || cmd === 'minimize') repaintActive();
169
+ });
170
+ mainWindow.on('browser-window-focus', repaintActive);
171
+
172
+ // === Renderer crash handling — log only, don't kill the app ===
173
+ // The main BrowserWindow has no real content (it hosts BrowserViews), so a
174
+ // renderer-gone event here is usually a transient GPU/sandbox hiccup.
175
+ // Quitting on it makes the whole app flash-exit on startup. Just log.
176
+ mainWindow.webContents.on('render-process-gone', (_e, details) => {
177
+ console.error('[index] mainWindow render-process-gone:', details.reason);
178
+ });
179
+ app.on('web-contents-created', (_event, webContents) => {
180
+ webContents.on('render-process-gone', (_e, details) => {
181
+ console.error('[index] tab render-process-gone:', details.reason);
182
+ });
183
+ });
184
+
185
+ // Ensure the app actually exits when the main window is closed.
186
+ mainWindow.on('close', cleanupAndQuit);
187
+
188
+ // Refresh tab bar periodically
189
+ tabRefreshInterval = setInterval(refreshTabBar, 2000);
190
+
191
+ // === #5: NetworkService self-heal watchdog ===
192
+ // The Electron NetworkService can wedge as a whole (port stays listening but
193
+ // every ui.navigate fails ERR_FAILED while curl works fine). A clean relaunch
194
+ // restores it. Probe via the MAIN process net.request (shares the same
195
+ // NetworkService as all renderers) every ~30s; after ≥5 consecutive failures,
196
+ // broadcast the event then relaunch — the MCP WS client auto-reconnects.
197
+ // Relaunch is skipped while a human appears to be actively using the window.
198
+ StartNetworkWatchdog();
199
+
200
+ showInitialWindow();
201
+ }
202
+
203
+ /**
204
+ * 首次显示窗口。
205
+ *
206
+ * 窗口必须在屏幕上真实可见 —— 这是产品承诺(人可以随时手动介入,例如扫码登录)。
207
+ * 但"可见"不等于"抢焦点":如果是 agent 通过 MCP 拉起的(mcp_server 会传
208
+ * AI_BROWSER_LAUNCHED_BY_AGENT=1),用 showInactive() 让窗口出现但**不**夺走键盘
209
+ * 焦点,人可以继续在原来的窗口里做事;人手动 `npm start` 时窗口照常显示在前台。
210
+ *
211
+ * AI_BROWSER_FOCUS=always / never 可整体覆盖(见 page_manager._focusWindow)。
212
+ */
213
+ function showInitialWindow() {
214
+ if (!mainWindow) return;
215
+ const policy = String(process.env.AI_BROWSER_FOCUS || 'auto').toLowerCase();
216
+ const byAgent = process.env.AI_BROWSER_LAUNCHED_BY_AGENT === '1';
217
+ // 窗口**一定**要显示出来 —— 这是产品不变量(人可随时手动介入:登录、扫码、过验证码、
218
+ // 纠正 agent 的操作),任何策略值都不允许把窗口变成看不见的黑盒。
219
+ // 策略只决定**显示时抢不抢焦点**:只有人手动启动、或显式 always,才显示并激活;
220
+ // agent 拉起(或 never)走 showInactive —— 窗口出现,但键盘焦点留在用户那边。
221
+ const shouldActivate = policy === 'always' || (policy !== 'never' && !byAgent);
222
+ try {
223
+ if (shouldActivate) mainWindow.show();
224
+ else mainWindow.showInactive();
225
+ } catch (e) {
226
+ try { mainWindow.show(); } catch (e2) { /* 窗口已销毁 */ }
227
+ }
228
+ }
229
+
230
+ // Probe one target through the NetworkService from the main process. Reuses
231
+ // Electron's own stack, so a wedged service here means wedged for every tab.
232
+ // Resolves true on any HTTP response (even 4xx — network is alive), false on
233
+ // error/timeout.
234
+ function probeTarget(url) {
235
+ return new Promise((resolve) => {
236
+ let req;
237
+ try {
238
+ req = net.request({ url, method: 'HEAD' });
239
+ } catch (e) {
240
+ resolve(false); // malformed url in AI_BROWSER_WATCHDOG_URLS
241
+ return;
242
+ }
243
+ let done = false;
244
+ let timer = null;
245
+ const finish = (ok) => {
246
+ if (done) return;
247
+ done = true;
248
+ if (timer) clearTimeout(timer);
249
+ resolve(ok);
250
+ };
251
+ // Electron 的 net.ClientRequest 没有 setTimeout()(调用会抛
252
+ // "req.setTimeout is not a function"),所以超时只能用外部定时器兜。
253
+ // 之前正是这里每次探测都抛异常,导致看门狗从未真正工作过。
254
+ timer = setTimeout(() => {
255
+ try { req.abort(); } catch (e) {}
256
+ finish(false);
257
+ }, WATCHDOG_TIMEOUT);
258
+ req.on('response', () => finish(true));
259
+ req.on('error', () => finish(false));
260
+ req.on('abort', () => finish(false));
261
+ try {
262
+ req.end();
263
+ } catch (e) {
264
+ finish(false);
265
+ }
266
+ });
267
+ }
268
+
269
+ // Probe every configured target concurrently. Any single success means the
270
+ // NetworkService is alive — only a full sweep of failures counts as one failure.
271
+ // This is what stops a flaky/geo-blocked host from looking like a dead stack.
272
+ async function probeNetwork() {
273
+ const results = await Promise.all(WATCHDOG_TARGETS.map((url) => probeTarget(url)));
274
+ return results.some(Boolean);
275
+ }
276
+
277
+ function StartNetworkWatchdog() {
278
+ if (!isWatchdogEnabled(process.env.AI_BROWSER_WATCHDOG)) {
279
+ console.error('[index] watchdog disabled via AI_BROWSER_WATCHDOG');
280
+ return;
281
+ }
282
+ if (networkWatchdog) clearInterval(networkWatchdog);
283
+ let failStreak = 0;
284
+ const spin = async () => {
285
+ if (isQuitting) return;
286
+ let ok;
287
+ try {
288
+ ok = await probeNetwork();
289
+ } catch (e) {
290
+ // 探测本身抛异常不该变成 unhandled rejection,也不该计入 failStreak
291
+ // (那是"探测坏了",不是"网络坏了")。
292
+ console.error('[index] watchdog probe error:', (e && e.message) || e);
293
+ return;
294
+ }
295
+ if (ok) { failStreak = 0; return; }
296
+ failStreak += 1;
297
+ if (failStreak >= WATCHDOG_FAIL_LIMIT) {
298
+ console.error('[index] NetworkService wedged — relaunching AI Browser');
299
+ failStreak = 0; // prevent double-relaunch in flight
300
+ if (pageManager) { try { pageManager._broadcast('network_wedged', { reason: 'network service unreachable' }); } catch (e) {} }
301
+ app.relaunch();
302
+ app.exit(0);
303
+ }
304
+ };
305
+ spin();
306
+ networkWatchdog = setInterval(spin, WATCHDOG_INTERVAL);
307
+ }
308
+
309
+ function refreshTabBar() {
310
+ if (tabBarView && !tabBarView.webContents.isDestroyed()) {
311
+ tabBarView.webContents.executeJavaScript('renderTabs && renderTabs()').catch(() => {});
312
+ }
313
+ }
314
+
315
+ app.whenReady().then(() => {
316
+ createWindow();
317
+ // 下载与权限的 session 级钩子。必须装在 will-download 上,否则 Electron 默认
318
+ // **取消**下载:agent 点"导出"只看到 success,然后什么都没有,连错误都没有
319
+ // (2026-10-12 审计发现 src/ 里原先没有任何下载处理)。
320
+ // 权限同理:不装 setPermissionRequestHandler 时每个请求被静默拒绝,站点要
321
+ // pointerLock / clipboard 写入才能继续时,agent 完全看不出是被权限挡住。
322
+ //
323
+ // ⚠️ 必须是 tab 的 BrowserView 实际所在的那个 session。它们用的是
324
+ // `partition: config.viewPartition`,不是 defaultSession —— 第一版传了
325
+ // defaultSession,钩子装到了另一个 session 上,表现是"页面确实发出了下载请求
326
+ // (服务端收到了 GET),但 will-download 从不触发、文件不落盘"。
327
+ try {
328
+ pageManager.installSessionHooks(session.fromPartition(config.viewPartition));
329
+ } catch (e) {
330
+ console.error('[index] failed to install session hooks:', e && e.message);
331
+ }
332
+ });
333
+
334
+ function cleanupAndQuit() {
335
+ if (isQuitting) return;
336
+ isQuitting = true;
337
+ if (tabRefreshInterval) {
338
+ clearInterval(tabRefreshInterval);
339
+ tabRefreshInterval = null;
340
+ }
341
+ if (networkWatchdog) {
342
+ clearInterval(networkWatchdog);
343
+ networkWatchdog = null;
344
+ }
345
+ // Close PageManager first — it rejects pending IPC requests and tears down
346
+ // BrowserViews cleanly. Otherwise pending requests hang the WS server close.
347
+ if (pageManager) {
348
+ try { pageManager.close(); } catch (e) { console.error('[index] pageManager.close error:', e.message); }
349
+ pageManager = null;
350
+ }
351
+ if (wsServer) {
352
+ wsServer.close();
353
+ wsServer = null;
354
+ }
355
+ app.quit();
356
+ }
357
+
358
+ app.on('window-all-closed', cleanupAndQuit);
359
+ app.on('before-quit', cleanupAndQuit);