@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.
- package/.codeartsdoer/.codebaseignore +0 -0
- package/.codeartsdoer/AGENTS.md +12 -0
- package/.github/workflows/build.yml +146 -0
- package/README.en.md +264 -0
- package/README.md +276 -0
- package/RELEASE_NOTES.md +16 -0
- package/USAGE.md +490 -0
- package/color-preview-r3.html +414 -0
- package/color-preview.html +414 -0
- package/dev-app-update.yml +3 -0
- package/electron-builder.yml +36 -0
- package/electron.vite.config.ts +40 -0
- package/package.json +53 -0
- package/report-2026-04-13-copilot-claude-sonnet-4.6.md +955 -0
- package/resources/doloffer-logo.png +0 -0
- package/resources/entitlements.mac.plist +12 -0
- package/resources/icon.ico +0 -0
- package/resources/icon.png +0 -0
- package/src/main/ai/ai-analyzer.ts +517 -0
- package/src/main/ai/crypto-script-extractor.ts +206 -0
- package/src/main/ai/data-assembler.ts +205 -0
- package/src/main/ai/llm-router.ts +1120 -0
- package/src/main/ai/prompt-builder.ts +349 -0
- package/src/main/ai/scene-detector.ts +302 -0
- package/src/main/capture/capture-engine.ts +130 -0
- package/src/main/capture/interaction-recorder.ts +171 -0
- package/src/main/capture/js-injector.ts +57 -0
- package/src/main/capture/replay-engine.ts +256 -0
- package/src/main/capture/storage-collector.ts +76 -0
- package/src/main/cdp/cdp-manager.ts +233 -0
- package/src/main/db/database.ts +41 -0
- package/src/main/db/migrations.ts +235 -0
- package/src/main/db/repositories.ts +574 -0
- package/src/main/fingerprint/http-spoofing.ts +48 -0
- package/src/main/fingerprint/presets.ts +173 -0
- package/src/main/fingerprint/profile-generator.ts +115 -0
- package/src/main/fingerprint/profile-store.ts +52 -0
- package/src/main/index.ts +260 -0
- package/src/main/ipc.ts +856 -0
- package/src/main/logger.ts +42 -0
- package/src/main/mcp/mcp-config.ts +66 -0
- package/src/main/mcp/mcp-manager.ts +155 -0
- package/src/main/mcp/mcp-server.ts +1038 -0
- package/src/main/prompt-templates.ts +170 -0
- package/src/main/proxy/ca-manager.ts +204 -0
- package/src/main/proxy/cert-download-page.ts +171 -0
- package/src/main/proxy/cert-installer.ts +242 -0
- package/src/main/proxy/mitm-proxy-config.ts +37 -0
- package/src/main/proxy/mitm-proxy-server.ts +1085 -0
- package/src/main/proxy/system-proxy.ts +248 -0
- package/src/main/session/session-manager.ts +724 -0
- package/src/main/tab-manager.ts +582 -0
- package/src/main/updater.ts +111 -0
- package/src/main/window.ts +235 -0
- package/src/preload/hook-script.ts +270 -0
- package/src/preload/index.ts +211 -0
- package/src/preload/interaction-hook.ts +286 -0
- package/src/preload/stealth-script.ts +302 -0
- package/src/preload/target-preload.ts +15 -0
- package/src/renderer/App.tsx +656 -0
- package/src/renderer/components/AiLogDetail.tsx +173 -0
- package/src/renderer/components/AiLogList.tsx +101 -0
- package/src/renderer/components/AiLogView.module.css +364 -0
- package/src/renderer/components/AiLogView.tsx +86 -0
- package/src/renderer/components/AnalyzeBar.module.css +79 -0
- package/src/renderer/components/AnalyzeBar.tsx +104 -0
- package/src/renderer/components/BrowserPanel.module.css +67 -0
- package/src/renderer/components/BrowserPanel.tsx +90 -0
- package/src/renderer/components/ControlBar.module.css +47 -0
- package/src/renderer/components/ControlBar.tsx +205 -0
- package/src/renderer/components/HookLog.tsx +132 -0
- package/src/renderer/components/InteractionLog.tsx +183 -0
- package/src/renderer/components/MCPServerModal.tsx +427 -0
- package/src/renderer/components/PromptTemplateModal.tsx +254 -0
- package/src/renderer/components/ReportView.module.css +413 -0
- package/src/renderer/components/ReportView.tsx +429 -0
- package/src/renderer/components/RequestDetail.module.css +191 -0
- package/src/renderer/components/RequestDetail.tsx +202 -0
- package/src/renderer/components/RequestLog.module.css +69 -0
- package/src/renderer/components/RequestLog.tsx +208 -0
- package/src/renderer/components/SessionList.module.css +245 -0
- package/src/renderer/components/SessionList.tsx +247 -0
- package/src/renderer/components/SettingsModal.tsx +100 -0
- package/src/renderer/components/StatusBar.module.css +44 -0
- package/src/renderer/components/StatusBar.tsx +102 -0
- package/src/renderer/components/StorageView.module.css +41 -0
- package/src/renderer/components/StorageView.tsx +178 -0
- package/src/renderer/components/TabBar.module.css +88 -0
- package/src/renderer/components/TabBar.tsx +70 -0
- package/src/renderer/components/Titlebar.module.css +254 -0
- package/src/renderer/components/Titlebar.tsx +169 -0
- package/src/renderer/components/settings/FingerprintSection.tsx +198 -0
- package/src/renderer/components/settings/GeneralSection.tsx +164 -0
- package/src/renderer/components/settings/LLMSection.tsx +148 -0
- package/src/renderer/components/settings/MCPServerSection.tsx +136 -0
- package/src/renderer/components/settings/MitmProxySection.tsx +320 -0
- package/src/renderer/components/settings/ProxySection.tsx +110 -0
- package/src/renderer/css-modules.d.ts +4 -0
- package/src/renderer/hooks/useCapture.ts +383 -0
- package/src/renderer/hooks/useConfirm.tsx +91 -0
- package/src/renderer/hooks/useSession.ts +136 -0
- package/src/renderer/hooks/useTabs.ts +103 -0
- package/src/renderer/i18n/en.ts +167 -0
- package/src/renderer/i18n/index.ts +47 -0
- package/src/renderer/i18n/zh.ts +170 -0
- package/src/renderer/index.html +12 -0
- package/src/renderer/main.tsx +15 -0
- package/src/renderer/styles/global.css +144 -0
- package/src/renderer/styles/themes/ayu-dark.css +59 -0
- package/src/renderer/styles/themes/catppuccin.css +59 -0
- package/src/renderer/styles/themes/discord.css +59 -0
- package/src/renderer/styles/themes/dracula.css +59 -0
- package/src/renderer/styles/themes/github-dark.css +59 -0
- package/src/renderer/styles/themes/gruvbox.css +59 -0
- package/src/renderer/styles/themes/index.css +11 -0
- package/src/renderer/styles/themes/light.css +59 -0
- package/src/renderer/styles/themes/nord.css +59 -0
- package/src/renderer/styles/themes/one-dark.css +59 -0
- package/src/renderer/styles/themes/tokyo-night.css +59 -0
- package/src/renderer/styles/tokens.css +137 -0
- package/src/renderer/theme.ts +31 -0
- package/src/renderer/ui/Badge.module.css +38 -0
- package/src/renderer/ui/Badge.tsx +36 -0
- package/src/renderer/ui/Button.module.css +142 -0
- package/src/renderer/ui/Button.tsx +46 -0
- package/src/renderer/ui/Collapse.module.css +49 -0
- package/src/renderer/ui/Collapse.tsx +57 -0
- package/src/renderer/ui/CopyableBlock.module.css +56 -0
- package/src/renderer/ui/CopyableBlock.tsx +42 -0
- package/src/renderer/ui/Empty.module.css +19 -0
- package/src/renderer/ui/Empty.tsx +34 -0
- package/src/renderer/ui/Icons.tsx +346 -0
- package/src/renderer/ui/Input.module.css +103 -0
- package/src/renderer/ui/Input.tsx +94 -0
- package/src/renderer/ui/InputNumber.module.css +68 -0
- package/src/renderer/ui/InputNumber.tsx +104 -0
- package/src/renderer/ui/Modal.module.css +83 -0
- package/src/renderer/ui/Modal.tsx +67 -0
- package/src/renderer/ui/Popconfirm.module.css +73 -0
- package/src/renderer/ui/Popconfirm.tsx +74 -0
- package/src/renderer/ui/Progress.module.css +35 -0
- package/src/renderer/ui/Progress.tsx +30 -0
- package/src/renderer/ui/Select.module.css +91 -0
- package/src/renderer/ui/Select.tsx +100 -0
- package/src/renderer/ui/Spinner.module.css +44 -0
- package/src/renderer/ui/Spinner.tsx +27 -0
- package/src/renderer/ui/Switch.module.css +39 -0
- package/src/renderer/ui/Switch.tsx +43 -0
- package/src/renderer/ui/Tabs.module.css +76 -0
- package/src/renderer/ui/Tabs.tsx +53 -0
- package/src/renderer/ui/Tag.module.css +66 -0
- package/src/renderer/ui/Tag.tsx +47 -0
- package/src/renderer/ui/Timeline.module.css +42 -0
- package/src/renderer/ui/Timeline.tsx +29 -0
- package/src/renderer/ui/Toast.module.css +99 -0
- package/src/renderer/ui/Toast.tsx +90 -0
- package/src/renderer/ui/Tooltip.module.css +26 -0
- package/src/renderer/ui/Tooltip.tsx +23 -0
- package/src/renderer/ui/VirtualTable.module.css +230 -0
- package/src/renderer/ui/VirtualTable.tsx +416 -0
- package/src/renderer/ui/index.ts +55 -0
- package/src/shared/types.ts +695 -0
- package/tests/main/ai/crypto-script-extractor.test.ts +281 -0
- package/tests/main/ai/llm-router.test.ts +1537 -0
- package/tests/main/ai/prompt-builder.test.ts +178 -0
- package/tests/main/ai/scene-detector.test.ts +212 -0
- package/tests/main/db/migrations.test.ts +134 -0
- package/tests/main/release-workflow.test.ts +59 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +23 -0
- package/tsconfig.web.json +24 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, '"')}">\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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, '"')}">\\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
|
+
}
|