@mseep/anything-analyzer 3.6.50

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.
Files changed (172) hide show
  1. package/.codeartsdoer/.codebaseignore +0 -0
  2. package/.codeartsdoer/AGENTS.md +12 -0
  3. package/.github/workflows/build.yml +146 -0
  4. package/README.en.md +264 -0
  5. package/README.md +276 -0
  6. package/RELEASE_NOTES.md +16 -0
  7. package/USAGE.md +490 -0
  8. package/color-preview-r3.html +414 -0
  9. package/color-preview.html +414 -0
  10. package/dev-app-update.yml +3 -0
  11. package/electron-builder.yml +36 -0
  12. package/electron.vite.config.ts +40 -0
  13. package/package.json +53 -0
  14. package/report-2026-04-13-copilot-claude-sonnet-4.6.md +955 -0
  15. package/resources/doloffer-logo.png +0 -0
  16. package/resources/entitlements.mac.plist +12 -0
  17. package/resources/icon.ico +0 -0
  18. package/resources/icon.png +0 -0
  19. package/src/main/ai/ai-analyzer.ts +517 -0
  20. package/src/main/ai/crypto-script-extractor.ts +206 -0
  21. package/src/main/ai/data-assembler.ts +205 -0
  22. package/src/main/ai/llm-router.ts +1120 -0
  23. package/src/main/ai/prompt-builder.ts +349 -0
  24. package/src/main/ai/scene-detector.ts +302 -0
  25. package/src/main/capture/capture-engine.ts +130 -0
  26. package/src/main/capture/interaction-recorder.ts +171 -0
  27. package/src/main/capture/js-injector.ts +57 -0
  28. package/src/main/capture/replay-engine.ts +256 -0
  29. package/src/main/capture/storage-collector.ts +76 -0
  30. package/src/main/cdp/cdp-manager.ts +233 -0
  31. package/src/main/db/database.ts +41 -0
  32. package/src/main/db/migrations.ts +235 -0
  33. package/src/main/db/repositories.ts +574 -0
  34. package/src/main/fingerprint/http-spoofing.ts +48 -0
  35. package/src/main/fingerprint/presets.ts +173 -0
  36. package/src/main/fingerprint/profile-generator.ts +115 -0
  37. package/src/main/fingerprint/profile-store.ts +52 -0
  38. package/src/main/index.ts +260 -0
  39. package/src/main/ipc.ts +856 -0
  40. package/src/main/logger.ts +42 -0
  41. package/src/main/mcp/mcp-config.ts +66 -0
  42. package/src/main/mcp/mcp-manager.ts +155 -0
  43. package/src/main/mcp/mcp-server.ts +1038 -0
  44. package/src/main/prompt-templates.ts +170 -0
  45. package/src/main/proxy/ca-manager.ts +204 -0
  46. package/src/main/proxy/cert-download-page.ts +171 -0
  47. package/src/main/proxy/cert-installer.ts +242 -0
  48. package/src/main/proxy/mitm-proxy-config.ts +37 -0
  49. package/src/main/proxy/mitm-proxy-server.ts +1085 -0
  50. package/src/main/proxy/system-proxy.ts +248 -0
  51. package/src/main/session/session-manager.ts +724 -0
  52. package/src/main/tab-manager.ts +582 -0
  53. package/src/main/updater.ts +111 -0
  54. package/src/main/window.ts +235 -0
  55. package/src/preload/hook-script.ts +270 -0
  56. package/src/preload/index.ts +211 -0
  57. package/src/preload/interaction-hook.ts +286 -0
  58. package/src/preload/stealth-script.ts +302 -0
  59. package/src/preload/target-preload.ts +15 -0
  60. package/src/renderer/App.tsx +656 -0
  61. package/src/renderer/components/AiLogDetail.tsx +173 -0
  62. package/src/renderer/components/AiLogList.tsx +101 -0
  63. package/src/renderer/components/AiLogView.module.css +364 -0
  64. package/src/renderer/components/AiLogView.tsx +86 -0
  65. package/src/renderer/components/AnalyzeBar.module.css +79 -0
  66. package/src/renderer/components/AnalyzeBar.tsx +104 -0
  67. package/src/renderer/components/BrowserPanel.module.css +67 -0
  68. package/src/renderer/components/BrowserPanel.tsx +90 -0
  69. package/src/renderer/components/ControlBar.module.css +47 -0
  70. package/src/renderer/components/ControlBar.tsx +205 -0
  71. package/src/renderer/components/HookLog.tsx +132 -0
  72. package/src/renderer/components/InteractionLog.tsx +183 -0
  73. package/src/renderer/components/MCPServerModal.tsx +427 -0
  74. package/src/renderer/components/PromptTemplateModal.tsx +254 -0
  75. package/src/renderer/components/ReportView.module.css +413 -0
  76. package/src/renderer/components/ReportView.tsx +429 -0
  77. package/src/renderer/components/RequestDetail.module.css +191 -0
  78. package/src/renderer/components/RequestDetail.tsx +202 -0
  79. package/src/renderer/components/RequestLog.module.css +69 -0
  80. package/src/renderer/components/RequestLog.tsx +208 -0
  81. package/src/renderer/components/SessionList.module.css +245 -0
  82. package/src/renderer/components/SessionList.tsx +247 -0
  83. package/src/renderer/components/SettingsModal.tsx +100 -0
  84. package/src/renderer/components/StatusBar.module.css +44 -0
  85. package/src/renderer/components/StatusBar.tsx +102 -0
  86. package/src/renderer/components/StorageView.module.css +41 -0
  87. package/src/renderer/components/StorageView.tsx +178 -0
  88. package/src/renderer/components/TabBar.module.css +88 -0
  89. package/src/renderer/components/TabBar.tsx +70 -0
  90. package/src/renderer/components/Titlebar.module.css +254 -0
  91. package/src/renderer/components/Titlebar.tsx +169 -0
  92. package/src/renderer/components/settings/FingerprintSection.tsx +198 -0
  93. package/src/renderer/components/settings/GeneralSection.tsx +164 -0
  94. package/src/renderer/components/settings/LLMSection.tsx +148 -0
  95. package/src/renderer/components/settings/MCPServerSection.tsx +136 -0
  96. package/src/renderer/components/settings/MitmProxySection.tsx +320 -0
  97. package/src/renderer/components/settings/ProxySection.tsx +110 -0
  98. package/src/renderer/css-modules.d.ts +4 -0
  99. package/src/renderer/hooks/useCapture.ts +383 -0
  100. package/src/renderer/hooks/useConfirm.tsx +91 -0
  101. package/src/renderer/hooks/useSession.ts +136 -0
  102. package/src/renderer/hooks/useTabs.ts +103 -0
  103. package/src/renderer/i18n/en.ts +167 -0
  104. package/src/renderer/i18n/index.ts +47 -0
  105. package/src/renderer/i18n/zh.ts +170 -0
  106. package/src/renderer/index.html +12 -0
  107. package/src/renderer/main.tsx +15 -0
  108. package/src/renderer/styles/global.css +144 -0
  109. package/src/renderer/styles/themes/ayu-dark.css +59 -0
  110. package/src/renderer/styles/themes/catppuccin.css +59 -0
  111. package/src/renderer/styles/themes/discord.css +59 -0
  112. package/src/renderer/styles/themes/dracula.css +59 -0
  113. package/src/renderer/styles/themes/github-dark.css +59 -0
  114. package/src/renderer/styles/themes/gruvbox.css +59 -0
  115. package/src/renderer/styles/themes/index.css +11 -0
  116. package/src/renderer/styles/themes/light.css +59 -0
  117. package/src/renderer/styles/themes/nord.css +59 -0
  118. package/src/renderer/styles/themes/one-dark.css +59 -0
  119. package/src/renderer/styles/themes/tokyo-night.css +59 -0
  120. package/src/renderer/styles/tokens.css +137 -0
  121. package/src/renderer/theme.ts +31 -0
  122. package/src/renderer/ui/Badge.module.css +38 -0
  123. package/src/renderer/ui/Badge.tsx +36 -0
  124. package/src/renderer/ui/Button.module.css +142 -0
  125. package/src/renderer/ui/Button.tsx +46 -0
  126. package/src/renderer/ui/Collapse.module.css +49 -0
  127. package/src/renderer/ui/Collapse.tsx +57 -0
  128. package/src/renderer/ui/CopyableBlock.module.css +56 -0
  129. package/src/renderer/ui/CopyableBlock.tsx +42 -0
  130. package/src/renderer/ui/Empty.module.css +19 -0
  131. package/src/renderer/ui/Empty.tsx +34 -0
  132. package/src/renderer/ui/Icons.tsx +346 -0
  133. package/src/renderer/ui/Input.module.css +103 -0
  134. package/src/renderer/ui/Input.tsx +94 -0
  135. package/src/renderer/ui/InputNumber.module.css +68 -0
  136. package/src/renderer/ui/InputNumber.tsx +104 -0
  137. package/src/renderer/ui/Modal.module.css +83 -0
  138. package/src/renderer/ui/Modal.tsx +67 -0
  139. package/src/renderer/ui/Popconfirm.module.css +73 -0
  140. package/src/renderer/ui/Popconfirm.tsx +74 -0
  141. package/src/renderer/ui/Progress.module.css +35 -0
  142. package/src/renderer/ui/Progress.tsx +30 -0
  143. package/src/renderer/ui/Select.module.css +91 -0
  144. package/src/renderer/ui/Select.tsx +100 -0
  145. package/src/renderer/ui/Spinner.module.css +44 -0
  146. package/src/renderer/ui/Spinner.tsx +27 -0
  147. package/src/renderer/ui/Switch.module.css +39 -0
  148. package/src/renderer/ui/Switch.tsx +43 -0
  149. package/src/renderer/ui/Tabs.module.css +76 -0
  150. package/src/renderer/ui/Tabs.tsx +53 -0
  151. package/src/renderer/ui/Tag.module.css +66 -0
  152. package/src/renderer/ui/Tag.tsx +47 -0
  153. package/src/renderer/ui/Timeline.module.css +42 -0
  154. package/src/renderer/ui/Timeline.tsx +29 -0
  155. package/src/renderer/ui/Toast.module.css +99 -0
  156. package/src/renderer/ui/Toast.tsx +90 -0
  157. package/src/renderer/ui/Tooltip.module.css +26 -0
  158. package/src/renderer/ui/Tooltip.tsx +23 -0
  159. package/src/renderer/ui/VirtualTable.module.css +230 -0
  160. package/src/renderer/ui/VirtualTable.tsx +416 -0
  161. package/src/renderer/ui/index.ts +55 -0
  162. package/src/shared/types.ts +695 -0
  163. package/tests/main/ai/crypto-script-extractor.test.ts +281 -0
  164. package/tests/main/ai/llm-router.test.ts +1537 -0
  165. package/tests/main/ai/prompt-builder.test.ts +178 -0
  166. package/tests/main/ai/scene-detector.test.ts +212 -0
  167. package/tests/main/db/migrations.test.ts +134 -0
  168. package/tests/main/release-workflow.test.ts +59 -0
  169. package/tsconfig.json +7 -0
  170. package/tsconfig.node.json +23 -0
  171. package/tsconfig.web.json +24 -0
  172. package/vitest.config.ts +13 -0
@@ -0,0 +1,582 @@
1
+ import { EventEmitter } from "events";
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import { BrowserWindow, WebContentsView } from "electron";
4
+ import type { WebContents, Session as ElectronSession } from "electron";
5
+ import { join } from "path";
6
+
7
+ interface TabInfo {
8
+ id: string;
9
+ view: WebContentsView;
10
+ url: string;
11
+ title: string;
12
+ isLoading: boolean;
13
+ }
14
+
15
+ /** Snapshot of a session's tab group state (kept alive while hidden). */
16
+ interface SessionTabGroup {
17
+ tabs: Map<string, TabInfo>;
18
+ activeTabId: string | null;
19
+ electronSession: ElectronSession;
20
+ }
21
+
22
+ /**
23
+ * TabManager — Manages multiple browser tabs as WebContentsView instances.
24
+ * Each tab gets its own WebContentsView, only the active tab is displayed.
25
+ * Popup windows (window.open) are intercepted and opened as new tabs.
26
+ *
27
+ * Supports per-session tab groups: each app session owns an isolated set of
28
+ * tabs. Switching sessions hides the old group and restores (or creates) the
29
+ * new group — WebContentsView instances stay alive so page state is preserved.
30
+ */
31
+ export class TabManager extends EventEmitter {
32
+ /** Tabs for the currently visible session group. */
33
+ private tabs = new Map<string, TabInfo>();
34
+ private activeTabId: string | null = null;
35
+ private mainWindow: BrowserWindow | null = null;
36
+ private boundsCalculator: (() => Electron.Rectangle) | null = null;
37
+ private visibilityChecker: (() => boolean) | null = null;
38
+ /** Track destroyed tabs to avoid double-close */
39
+ private destroyedTabs = new Set<string>();
40
+ /** True when app is quitting: no tab recreation/new tabs allowed */
41
+ private isShuttingDown = false;
42
+ /** Electron session for the active app session (partition isolation) */
43
+ private activeElectronSession: ElectronSession | null = null;
44
+
45
+ // ---- Per-session tab groups ----
46
+ /** Stored tab groups for sessions that are not currently visible. */
47
+ private sessionGroups = new Map<string, SessionTabGroup>();
48
+ /** The session group ID currently driving `this.tabs`. */
49
+ private currentGroupId: string | null = null;
50
+
51
+ /**
52
+ * Initialize with the main window and a bounds calculator callback.
53
+ */
54
+ init(
55
+ mainWindow: BrowserWindow,
56
+ boundsCalculator: () => Electron.Rectangle,
57
+ visibilityChecker?: () => boolean,
58
+ ): void {
59
+ this.mainWindow = mainWindow;
60
+ this.boundsCalculator = boundsCalculator;
61
+ this.visibilityChecker = visibilityChecker ?? null;
62
+ }
63
+
64
+ /**
65
+ * Switch the visible tab group to a different session.
66
+ * - Hides (but keeps alive) the current session's tabs.
67
+ * - Restores a previously stored group, or creates a blank tab if first visit.
68
+ * - Emits tab events so the renderer UI stays in sync.
69
+ *
70
+ * Returns true if a new blank tab was created (caller may want to navigate).
71
+ */
72
+ switchSessionGroup(groupId: string, elSession: ElectronSession): boolean {
73
+ if (this.currentGroupId === groupId) return false;
74
+
75
+ // 1. Stash current group ---------------------------------------------------
76
+ if (this.currentGroupId !== null) {
77
+ // Remove ALL tab views from the window (stashing this group)
78
+ this.detachAllViews();
79
+
80
+ this.sessionGroups.set(this.currentGroupId, {
81
+ tabs: this.tabs,
82
+ activeTabId: this.activeTabId,
83
+ electronSession: this.activeElectronSession!,
84
+ });
85
+
86
+ // Tell renderer to clear its tab list (emit close for every visible tab)
87
+ for (const [, tab] of this.tabs) {
88
+ this.emit("tab-closed", { tabId: tab.id });
89
+ }
90
+ } else if (this.tabs.size > 0) {
91
+ // First-ever switch: there may be initial default-session tabs.
92
+ // Stash them under a special key so they don't leak.
93
+ this.detachAllViews();
94
+ for (const [, tab] of this.tabs) {
95
+ this.emit("tab-closed", { tabId: tab.id });
96
+ }
97
+ // Destroy default-session tabs — they can't be reused in a partition
98
+ for (const [tabId, tab] of this.tabs) {
99
+ try { tab.view.webContents.close(); } catch { /* ignore */ }
100
+ this.destroyedTabs.add(tabId);
101
+ }
102
+ }
103
+
104
+ // Reset working state
105
+ this.tabs = new Map();
106
+ this.activeTabId = null;
107
+ this.activeElectronSession = elSession;
108
+ this.currentGroupId = groupId;
109
+
110
+ // 2. Restore or create group -----------------------------------------------
111
+ const existing = this.sessionGroups.get(groupId);
112
+ let createdNew = false;
113
+
114
+ if (existing && existing.tabs.size > 0) {
115
+ // Restore previously stashed tabs
116
+ this.tabs = existing.tabs;
117
+ this.activeElectronSession = existing.electronSession;
118
+ this.sessionGroups.delete(groupId);
119
+
120
+ // Re-add all views as children (hidden) — they were detached when stashed
121
+ if (this.mainWindow) {
122
+ for (const [, tab] of this.tabs) {
123
+ try {
124
+ tab.view.setBounds(TabManager.HIDDEN_BOUNDS);
125
+ this.mainWindow.contentView.addChildView(tab.view);
126
+ } catch { /* view may have been destroyed */ }
127
+ }
128
+ }
129
+
130
+ // Notify renderer about restored tabs
131
+ for (const [, tab] of this.tabs) {
132
+ this.emit("tab-created", { id: tab.id, url: tab.url, title: tab.title });
133
+ }
134
+
135
+ // Activate the tab that was active before
136
+ const restoreId =
137
+ existing.activeTabId && this.tabs.has(existing.activeTabId)
138
+ ? existing.activeTabId
139
+ : this.tabs.keys().next().value;
140
+ if (restoreId) {
141
+ this.activateTab(restoreId);
142
+ }
143
+ } else {
144
+ // First visit to this session — create a blank tab
145
+ this.sessionGroups.delete(groupId); // remove stale empty entry if any
146
+ this.createTab();
147
+ createdNew = true;
148
+ }
149
+
150
+ return createdNew;
151
+ }
152
+
153
+ /**
154
+ * Destroy all tabs belonging to a specific session group.
155
+ * Used when deleting a session.
156
+ */
157
+ destroySessionGroup(groupId: string): void {
158
+ // If it's the current group, clear visible tabs
159
+ if (this.currentGroupId === groupId) {
160
+ this.destroyAllTabs();
161
+ this.currentGroupId = null;
162
+ return;
163
+ }
164
+ // Otherwise destroy the stashed group
165
+ const group = this.sessionGroups.get(groupId);
166
+ if (group) {
167
+ for (const [tabId, tab] of group.tabs) {
168
+ try { tab.view.webContents.close(); } catch { /* ignore */ }
169
+ this.destroyedTabs.add(tabId);
170
+ }
171
+ this.sessionGroups.delete(groupId);
172
+ }
173
+ }
174
+
175
+ /** Zero-size rectangle used to hide inactive tabs (avoids removeChildView). */
176
+ private static readonly HIDDEN_BOUNDS = { x: 0, y: 0, width: 0, height: 0 };
177
+
178
+ /**
179
+ * Create a new tab. Optionally navigate to a URL.
180
+ * The new tab becomes the active tab.
181
+ */
182
+ createTab(url?: string): TabInfo {
183
+ if (!this.mainWindow) throw new Error("TabManager not initialized");
184
+
185
+ const id = uuidv4();
186
+ const view = new WebContentsView({
187
+ webPreferences: {
188
+ sandbox: true,
189
+ contextIsolation: true,
190
+ nodeIntegration: false,
191
+ preload: join(__dirname, "../preload/target-preload.js"),
192
+ ...(this.activeElectronSession
193
+ ? { session: this.activeElectronSession }
194
+ : {}),
195
+ },
196
+ });
197
+
198
+ // Set dark background to avoid white flash while loading
199
+ view.setBackgroundColor("#1a1a2e");
200
+
201
+ const tab: TabInfo = { id, view, url: url || "", title: "New Tab", isLoading: false };
202
+ this.tabs.set(id, tab);
203
+
204
+ // Raise max listeners — our code + stealth/capture/injector + Electron internals
205
+ // easily exceed the default 10 for a single WebContents.
206
+ view.webContents.setMaxListeners(30);
207
+
208
+ // Add view as a child immediately (hidden). activateTab will show it.
209
+ // We keep ALL tab views as children to avoid native widget detach/reattach
210
+ // which triggers blink.mojom.WidgetHost crashes.
211
+ view.setBounds(TabManager.HIDDEN_BOUNDS);
212
+ try {
213
+ this.mainWindow.contentView.addChildView(view);
214
+ } catch { /* window may be destroyed */ }
215
+
216
+ this.setupTabListeners(tab);
217
+ this.activateTab(id);
218
+
219
+ if (url) {
220
+ view.webContents.loadURL(url).catch(() => {
221
+ // Navigation might fail for invalid URLs
222
+ });
223
+ }
224
+
225
+ this.emit("tab-created", { id: tab.id, url: tab.url, title: tab.title });
226
+ return tab;
227
+ }
228
+
229
+ /**
230
+ * Close a tab. If closing the last tab, create a new blank tab first.
231
+ */
232
+ closeTab(tabId: string): void {
233
+ const tab = this.tabs.get(tabId);
234
+ if (!tab) return;
235
+
236
+ const isLastTab = this.tabs.size <= 1;
237
+
238
+ // If this is the last tab, create a replacement before closing
239
+ if (isLastTab) {
240
+ this.createTab();
241
+ }
242
+
243
+ // If closing the active tab, activate another one first
244
+ if (this.activeTabId === tabId) {
245
+ const tabIds = Array.from(this.tabs.keys());
246
+ const idx = tabIds.indexOf(tabId);
247
+ const nextId = tabIds[idx + 1] || tabIds[idx - 1];
248
+ if (nextId) {
249
+ this.activateTab(nextId);
250
+ }
251
+ }
252
+
253
+ this.tabs.delete(tabId);
254
+ this.destroyedTabs.add(tabId);
255
+
256
+ // Now remove from window and destroy (only removeChildView on actual destruction)
257
+ if (this.mainWindow) {
258
+ try {
259
+ this.mainWindow.contentView.removeChildView(tab.view);
260
+ } catch { /* already removed */ }
261
+ }
262
+ try {
263
+ tab.view.webContents.close();
264
+ } catch { /* already destroyed */ }
265
+
266
+ this.emit("tab-closed", { tabId });
267
+ }
268
+
269
+ /**
270
+ * Switch the active tab. Hides the old tab (zero bounds) and shows the new one.
271
+ * Views are never removed/re-added — only bounds change — to avoid
272
+ * blink.mojom.WidgetHost Mojo IPC crashes.
273
+ */
274
+ activateTab(tabId: string): void {
275
+ if (!this.mainWindow) return;
276
+ const tab = this.tabs.get(tabId);
277
+ if (!tab) return;
278
+
279
+ // Hide the previous active tab by setting zero bounds
280
+ if (this.activeTabId && this.activeTabId !== tabId) {
281
+ const oldTab = this.tabs.get(this.activeTabId);
282
+ if (oldTab) {
283
+ try {
284
+ oldTab.view.setBounds(TabManager.HIDDEN_BOUNDS);
285
+ } catch { /* view destroyed */ }
286
+ }
287
+ }
288
+
289
+ this.activeTabId = tabId;
290
+
291
+ // Show the new tab with proper bounds (or hide if browser area is invisible)
292
+ const shouldShow = this.visibilityChecker ? this.visibilityChecker() : true;
293
+ if (shouldShow && this.boundsCalculator) {
294
+ try {
295
+ tab.view.setBounds(this.boundsCalculator());
296
+ } catch { /* view may have been destroyed */ }
297
+ } else {
298
+ try {
299
+ tab.view.setBounds(TabManager.HIDDEN_BOUNDS);
300
+ } catch { /* view destroyed */ }
301
+ }
302
+
303
+ this.emit("tab-activated", { tabId, url: tab.url, title: tab.title });
304
+ }
305
+
306
+ /**
307
+ * Update bounds on the active tab (e.g., on window resize).
308
+ */
309
+ updateBounds(): void {
310
+ if (!this.activeTabId || !this.boundsCalculator) return;
311
+ const tab = this.tabs.get(this.activeTabId);
312
+ if (tab) {
313
+ try {
314
+ if (!tab.view.webContents.isDestroyed()) {
315
+ tab.view.setBounds(this.boundsCalculator());
316
+ }
317
+ } catch { /* view destroyed */ }
318
+ }
319
+ }
320
+
321
+ getActiveTab(): TabInfo | null {
322
+ if (!this.activeTabId) return null;
323
+ return this.tabs.get(this.activeTabId) || null;
324
+ }
325
+
326
+ getActiveWebContents(): WebContents | null {
327
+ return this.getActiveTab()?.view.webContents || null;
328
+ }
329
+
330
+ getAllTabs(): TabInfo[] {
331
+ return Array.from(this.tabs.values());
332
+ }
333
+
334
+ /** Mark manager as shutting down (disables tab auto-recreation paths). */
335
+ setShuttingDown(shuttingDown: boolean): void {
336
+ this.isShuttingDown = shuttingDown;
337
+ }
338
+
339
+ /** Set the Electron session used for new tabs (partition isolation). */
340
+ setActiveElectronSession(s: ElectronSession | null): void {
341
+ this.activeElectronSession = s;
342
+ }
343
+
344
+ /** Get the current session group ID. */
345
+ getCurrentGroupId(): string | null {
346
+ return this.currentGroupId;
347
+ }
348
+
349
+ /**
350
+ * Destroy all tabs and clean up (current visible group only).
351
+ */
352
+ destroyAllTabs(): void {
353
+ for (const [tabId, tab] of this.tabs) {
354
+ if (this.mainWindow) {
355
+ try { this.mainWindow.contentView.removeChildView(tab.view); } catch { /* ignore */ }
356
+ }
357
+ try { tab.view.webContents.close(); } catch { /* ignore */ }
358
+ this.destroyedTabs.add(tabId);
359
+ }
360
+ this.tabs.clear();
361
+ this.activeTabId = null;
362
+ }
363
+
364
+ /**
365
+ * Destroy ALL tabs across ALL session groups (used on app quit).
366
+ */
367
+ destroyEverything(): void {
368
+ this.destroyAllTabs();
369
+ for (const [, group] of this.sessionGroups) {
370
+ for (const [tabId, tab] of group.tabs) {
371
+ try { tab.view.webContents.close(); } catch { /* ignore */ }
372
+ this.destroyedTabs.add(tabId);
373
+ }
374
+ }
375
+ this.sessionGroups.clear();
376
+ }
377
+
378
+ // ---- Internal helpers ----
379
+
380
+ /**
381
+ * Remove ALL current-group tab views from the window.
382
+ * Used when stashing a session group — those views will sit dormant and
383
+ * must not remain as children (they belong to a different partition).
384
+ */
385
+ private detachAllViews(): void {
386
+ if (!this.mainWindow) return;
387
+ for (const [, tab] of this.tabs) {
388
+ try {
389
+ this.mainWindow.contentView.removeChildView(tab.view);
390
+ } catch { /* not in view */ }
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Set up event listeners on a tab's WebContents.
396
+ */
397
+ private setupTabListeners(tab: TabInfo): void {
398
+ const wc = tab.view.webContents;
399
+
400
+ // Prevent page scripts from closing the window via window.close()
401
+ // This avoids crashes when the WebContentsView is destroyed unexpectedly.
402
+ wc.on("will-prevent-unload", (event) => {
403
+ // Always prevent the close — do not show the "Leave site?" dialog
404
+ event.preventDefault();
405
+ });
406
+
407
+ // Track loading state
408
+ wc.on("did-start-loading", () => {
409
+ if (wc.isDestroyed()) return;
410
+ tab.isLoading = true;
411
+ this.emit("tab-updated", {
412
+ tabId: tab.id,
413
+ url: tab.url,
414
+ title: tab.title,
415
+ isLoading: true,
416
+ });
417
+ });
418
+ wc.on("did-stop-loading", () => {
419
+ if (wc.isDestroyed()) return;
420
+ tab.isLoading = false;
421
+ this.emit("tab-updated", {
422
+ tabId: tab.id,
423
+ url: tab.url,
424
+ title: tab.title,
425
+ isLoading: false,
426
+ });
427
+ });
428
+
429
+ // Handle page load failures — show inline error instead of white screen
430
+ wc.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
431
+ if (wc.isDestroyed()) return;
432
+ if (!isMainFrame) return; // Ignore sub-frame failures
433
+ if (errorCode === -3) return; // ERR_ABORTED — user navigated away, not a real error
434
+
435
+ // Sanitize values to prevent XSS in the error page
436
+ const esc = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
437
+ const safeDesc = esc(errorDescription || "");
438
+ const safeUrl = esc(validatedURL || "");
439
+ // Encode the original URL for the retry button (safe inside a JS string literal)
440
+ const retryUrl = JSON.stringify(validatedURL || "");
441
+
442
+ const errorPage = `data:text/html;charset=utf-8,${encodeURIComponent(`
443
+ <!DOCTYPE html><html><head><style>
444
+ body { background: #1a1a2e; color: #a0a0b8; font-family: -apple-system, system-ui, sans-serif;
445
+ display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
446
+ .box { text-align: center; max-width: 420px; }
447
+ h2 { color: #e0e0f0; margin-bottom: 8px; }
448
+ code { background: #2a2a4a; padding: 2px 6px; border-radius: 3px; font-size: 12px; }
449
+ .url { word-break: break-all; color: #7a7a9a; font-size: 13px; margin-top: 12px; }
450
+ button { margin-top: 16px; background: #3a3a6a; border: none; color: #e0e0f0; padding: 8px 20px;
451
+ border-radius: 6px; cursor: pointer; font-size: 13px; }
452
+ button:hover { background: #4a4a8a; }
453
+ </style></head><body><div class="box">
454
+ <h2>\u65E0\u6CD5\u52A0\u8F7D\u6B64\u9875\u9762</h2>
455
+ <p><code>${errorCode}</code> ${safeDesc}</p>
456
+ <p class="url">${safeUrl}</p>
457
+ <button onclick="location.href=${retryUrl.replace(/"/g, '&quot;')}">\u91CD\u8BD5</button>
458
+ </div></body></html>
459
+ `)}`;
460
+ wc.loadURL(errorPage).catch(() => {});
461
+ });
462
+
463
+ // Override window.close() in page context to make it a no-op
464
+ wc.on("did-finish-load", () => {
465
+ if (wc.isDestroyed()) return;
466
+ wc.executeJavaScript("window.close = function() {};").catch(() => {});
467
+ });
468
+ wc.on("did-navigate-in-page", () => {
469
+ if (wc.isDestroyed()) return;
470
+ wc.executeJavaScript("window.close = function() {};").catch(() => {});
471
+ });
472
+
473
+ // Intercept window.open / target="_blank" — open as new internal tab
474
+ wc.setWindowOpenHandler((details) => {
475
+ // Create a new tab with the popup URL
476
+ this.createTab(details.url);
477
+ return { action: "deny" };
478
+ });
479
+
480
+ // Track URL changes
481
+ const onNavigate = (): void => {
482
+ if (wc.isDestroyed()) return;
483
+ tab.url = wc.getURL();
484
+ this.emit("tab-updated", {
485
+ tabId: tab.id,
486
+ url: tab.url,
487
+ title: tab.title,
488
+ });
489
+ };
490
+ wc.on("did-navigate", onNavigate);
491
+ wc.on("did-navigate-in-page", onNavigate);
492
+
493
+ // Track title changes
494
+ wc.on("page-title-updated", (_event, title) => {
495
+ if (wc.isDestroyed()) return;
496
+ tab.title = title;
497
+ this.emit("tab-updated", {
498
+ tabId: tab.id,
499
+ url: tab.url,
500
+ title: tab.title,
501
+ });
502
+ });
503
+
504
+ // Handle renderer process crash (GPU OOM, heavy WebGL, etc.)
505
+ // Without this, the dead webContents stays in the view tree and any
506
+ // subsequent operation on it crashes the main process.
507
+ wc.on("render-process-gone", (_event, details) => {
508
+ if (this.destroyedTabs.has(tab.id) || !this.tabs.has(tab.id)) return;
509
+ if (this.isShuttingDown) return;
510
+
511
+ console.warn(`[TabManager] Renderer process gone for tab ${tab.id}: ${details.reason}`);
512
+
513
+ // Show a crash recovery page by replacing the tab
514
+ const crashUrl = tab.url;
515
+ const esc = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
516
+ const safeUrl = esc(crashUrl);
517
+
518
+ // Remove the crashed view from the window and destroy it
519
+ if (this.mainWindow) {
520
+ try { this.mainWindow.contentView.removeChildView(tab.view); } catch { /* already removed */ }
521
+ }
522
+ try { tab.view.webContents.close(); } catch { /* already dead */ }
523
+ this.tabs.delete(tab.id);
524
+ this.destroyedTabs.add(tab.id);
525
+ if (this.activeTabId === tab.id) this.activeTabId = null;
526
+ this.emit("tab-closed", { tabId: tab.id });
527
+
528
+ // Create a new tab with crash info page
529
+ const crashPage = `data:text/html;charset=utf-8,${encodeURIComponent(`
530
+ <!DOCTYPE html><html><head><style>
531
+ body { background: #1a1a2e; color: #a0a0b8; font-family: -apple-system, system-ui, sans-serif;
532
+ display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
533
+ .box { text-align: center; max-width: 420px; }
534
+ h2 { color: #e0e0f0; margin-bottom: 8px; }
535
+ code { background: #2a2a4a; padding: 2px 6px; border-radius: 3px; font-size: 12px; }
536
+ .url { word-break: break-all; color: #7a7a9a; font-size: 13px; margin-top: 12px; }
537
+ button { margin-top: 16px; background: #3a3a6a; border: none; color: #e0e0f0; padding: 8px 20px;
538
+ border-radius: 6px; cursor: pointer; font-size: 13px; }
539
+ button:hover { background: #4a4a8a; }
540
+ </style></head><body><div class="box">
541
+ <h2>\\u9875\\u9762\\u5D29\\u6E83\\u4E86</h2>
542
+ <p>\\u8BE5\\u9875\\u9762\\u7684\\u6E32\\u67D3\\u8FDB\\u7A0B\\u5DF2\\u7EC8\\u6B62</p>
543
+ <p class="url">${safeUrl}</p>
544
+ <button onclick="location.href=${JSON.stringify(crashUrl).replace(/"/g, '&quot;')}">\\u91CD\\u65B0\\u52A0\\u8F7D</button>
545
+ </div></body></html>
546
+ `)}`;
547
+ this.createTab(crashPage);
548
+ });
549
+
550
+ // Handle unexpected WebContents destruction (e.g., window.close()
551
+ // bypassed our safeguards). Clean up gracefully instead of crashing.
552
+ wc.on("destroyed", () => {
553
+ if (this.destroyedTabs.has(tab.id) || !this.tabs.has(tab.id)) return;
554
+
555
+ // During app quit, WebContents are expected to be destroyed; do not recreate tabs.
556
+ if (this.isShuttingDown) {
557
+ this.tabs.delete(tab.id);
558
+ this.destroyedTabs.add(tab.id);
559
+ if (this.activeTabId === tab.id) this.activeTabId = null;
560
+ this.emit("tab-closed", { tabId: tab.id });
561
+ return;
562
+ }
563
+
564
+ // If this is the last tab, replace it with a new blank tab
565
+ // instead of letting the app crash with no view.
566
+ if (this.tabs.size <= 1) {
567
+ this.tabs.delete(tab.id);
568
+ this.destroyedTabs.add(tab.id);
569
+ this.activeTabId = null;
570
+ // Remove destroyed view from window
571
+ if (this.mainWindow) {
572
+ try { this.mainWindow.contentView.removeChildView(tab.view); } catch { /* already removed */ }
573
+ }
574
+ // Create a replacement tab
575
+ this.createTab();
576
+ this.emit("tab-closed", { tabId: tab.id });
577
+ } else {
578
+ this.closeTab(tab.id);
579
+ }
580
+ });
581
+ }
582
+ }
@@ -0,0 +1,111 @@
1
+ import { autoUpdater } from "electron-updater";
2
+ import { app } from "electron";
3
+ import type { BrowserWindow } from "electron";
4
+ import type { UpdateStatus } from "@shared/types";
5
+
6
+ /**
7
+ * Updater — Wraps electron-updater's autoUpdater singleton.
8
+ *
9
+ * Pushes update lifecycle events to the renderer via IPC.
10
+ */
11
+ export class Updater {
12
+ private mainWindow: BrowserWindow | null = null;
13
+
14
+ constructor() {
15
+ autoUpdater.autoDownload = true;
16
+ autoUpdater.autoInstallOnAppQuit = true;
17
+
18
+ autoUpdater.on("checking-for-update", () => {
19
+ this.sendStatus({ state: "checking" });
20
+ });
21
+
22
+ autoUpdater.on("update-available", (info) => {
23
+ this.sendStatus({
24
+ state: "available",
25
+ info: {
26
+ version: info.version,
27
+ releaseNotes: this.formatNotes(info.releaseNotes),
28
+ },
29
+ });
30
+ });
31
+
32
+ autoUpdater.on("update-not-available", (info) => {
33
+ this.sendStatus({
34
+ state: "not-available",
35
+ info: { version: info.version },
36
+ });
37
+ });
38
+
39
+ autoUpdater.on("download-progress", (progress) => {
40
+ this.sendStatus({
41
+ state: "downloading",
42
+ progress: {
43
+ percent: progress.percent,
44
+ bytesPerSecond: progress.bytesPerSecond,
45
+ transferred: progress.transferred,
46
+ total: progress.total,
47
+ },
48
+ });
49
+ });
50
+
51
+ autoUpdater.on("update-downloaded", (info) => {
52
+ this.sendStatus({
53
+ state: "downloaded",
54
+ info: {
55
+ version: info.version,
56
+ releaseNotes: this.formatNotes(info.releaseNotes),
57
+ },
58
+ });
59
+ });
60
+
61
+ autoUpdater.on("error", (err) => {
62
+ this.sendStatus({ state: "error", error: this.formatError(err.message) });
63
+ });
64
+ }
65
+
66
+ /** Bind to the main window for pushing status events. */
67
+ setMainWindow(win: BrowserWindow): void {
68
+ this.mainWindow = win;
69
+ }
70
+
71
+ /** Trigger an update check. Safe to call at any time. */
72
+ checkForUpdates(): void {
73
+ // 开发模式下跳过,避免 electron-updater 输出 "Skip checkForUpdates" 警告
74
+ if (!app.isPackaged) {
75
+ this.sendStatus({ state: "not-available", info: { version: app.getVersion() } });
76
+ return;
77
+ }
78
+ autoUpdater.checkForUpdates().catch((err) => {
79
+ this.sendStatus({ state: "error", error: err.message });
80
+ });
81
+ }
82
+
83
+ /** Quit and install the downloaded update. */
84
+ quitAndInstall(): void {
85
+ autoUpdater.quitAndInstall(false, true);
86
+ }
87
+
88
+ private sendStatus(status: UpdateStatus): void {
89
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
90
+ this.mainWindow.webContents.send("update:status", status);
91
+ }
92
+ }
93
+
94
+ private formatNotes(
95
+ notes: string | Array<{ note: string }> | undefined | null,
96
+ ): string | undefined {
97
+ if (!notes) return undefined;
98
+ if (typeof notes === "string") return notes;
99
+ return notes.map((n) => n.note).join("\n");
100
+ }
101
+
102
+ private formatError(message: string): string {
103
+ if (
104
+ process.platform === "darwin" &&
105
+ /code signature|did not satisfy designated requirement|not signed|代码对象根本未签名/i.test(message)
106
+ ) {
107
+ return `${message}\n\n当前 macOS 更新包未通过代码签名校验。请从 GitHub Release 手动下载安装最新 DMG,或重新发布已签名/已公证的 macOS 安装包。`;
108
+ }
109
+ return message;
110
+ }
111
+ }