my-provider-dash-manager 0.2.8

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/src/main.js ADDED
@@ -0,0 +1,1259 @@
1
+ const { app, BaseWindow, BrowserWindow, WebContentsView, session, ipcMain, shell, Menu } = require('electron');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const SERVICES = require('./services');
5
+ const tokens = require('./api-tokens');
6
+ const { PROVIDERS } = require('./providers');
7
+ const content = require('./content');
8
+ const creds = require('./credentials');
9
+ const plugins = require('./plugins');
10
+ const updater = require('./updater');
11
+
12
+ const statusCache = new Map(); // accountKey -> { summary?, error?, fetchedAt } — summaries only, tokens never leave main
13
+
14
+ const TAB_STRIP_H = 48;
15
+ const RAIL_W = 240; // left account rail — account views must never cover it (must match --rail in ui.html)
16
+ const SETTLE_MS = Number(process.env.SPIKE_SETTLE_MS || 8000);
17
+ const COLORS = ['#e5484d', '#f76b15', '#ffc53d', '#46a758', '#00a2c7', '#4f8cff', '#8e4ec6', '#e93d82', '#6e56cf', '#00b0a0'];
18
+ const permissionConfigured = new WeakSet(); // one permission policy per partition session
19
+
20
+ // The package was renamed to my-provider-dash-manager for npm, which would move Electron's userData
21
+ // (it derives from the package name) and orphan every existing account, session and saved login.
22
+ // Pin it to the original folder so upgrades keep their data.
23
+ const LEGACY_USER_DATA = 'multi-acc-manager';
24
+ if (process.env.MAM_USER_DATA) app.setPath('userData', process.env.MAM_USER_DATA);
25
+ else app.setPath('userData', path.join(app.getPath('appData'), LEGACY_USER_DATA));
26
+
27
+ // ---------- persistence ----------
28
+ function userDataPath(name) { return path.join(app.getPath('userData'), name); }
29
+
30
+ function readJson(file, fallback) {
31
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
32
+ }
33
+ function writeJson(file, data) {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
36
+ }
37
+
38
+ const store = {
39
+ accounts: [], // {svc, id, label, colorIdx, proxy, createdAt, lastState}
40
+ settings: { warmLimit: 5, groupTabs: false, collapsed: [], stripCollapsed: [], adBlock: true, darkMode: true, forceDark: false, saveLogins: true, autofill: true },
41
+ openTabs: [], // session memory: [{svc, id, url, lastActivated, active}] — survives restarts
42
+ customServices: [], // Phase 2: user-added services {key,name,dashboardUrl,loginUrl,color,custom:true}
43
+ save() {
44
+ writeJson(userDataPath('accounts.json'), this.accounts);
45
+ writeJson(userDataPath('settings.json'), this.settings);
46
+ writeJson(userDataPath('services.json'), this.customServices);
47
+ },
48
+ saveSession() { writeJson(userDataPath('session.json'), this.openTabs); },
49
+ load() {
50
+ this.accounts = readJson(userDataPath('accounts.json'), []);
51
+ this.settings = Object.assign({ warmLimit: 5, groupTabs: false, collapsed: [], stripCollapsed: [], adBlock: true, darkMode: true, forceDark: false, saveLogins: true, autofill: true }, readJson(userDataPath('settings.json'), {}));
52
+ this.openTabs = readJson(userDataPath('session.json'), []);
53
+ this.customServices = readJson(userDataPath('services.json'), []);
54
+ },
55
+ };
56
+ function userData(name) { return userDataPath(name); }
57
+
58
+ // Built-in curated services + user-added generic ones (Phase 2) + plugin manifests (Phase 3).
59
+ let pluginServices = [];
60
+ let pluginErrors = [];
61
+ function refreshManagedHosts() {
62
+ const hosts = new Set();
63
+ for (const s of allServices()) for (const h of serviceHosts(s.key)) hosts.add(h);
64
+ content.setManagedHosts([...hosts]);
65
+ }
66
+ function reloadPlugins() {
67
+ const r = plugins.load();
68
+ pluginServices = r.services;
69
+ pluginErrors = r.errors;
70
+ if (pluginErrors.length) console.error('[plugins] invalid manifests:', pluginErrors.map(e => `${e.file}: ${e.error}`).join('; '));
71
+ refreshManagedHosts();
72
+ return r;
73
+ }
74
+ // Precedence on key collisions: built-in > user-added > plugin. Two services must never share a key,
75
+ // or their accounts would resolve to the same partition namespace.
76
+ function allServices() {
77
+ const out = []; const seen = new Set();
78
+ for (const s of [...SERVICES, ...(store.customServices || []), ...pluginServices]) {
79
+ if (seen.has(s.key)) continue;
80
+ seen.add(s.key);
81
+ out.push(s);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // Status providers: built-ins, plus generic ones synthesised from a plugin's api block.
87
+ function providerFor(svcKey) {
88
+ if (PROVIDERS[svcKey]) return PROVIDERS[svcKey];
89
+ const winner = serviceByKey(svcKey); // same precedence as allServices(): built-in > custom > plugin
90
+ return winner && winner.plugin && winner.api ? plugins.makeProvider(winner.api) : null;
91
+ }
92
+ function serviceByKey(key) { return allServices().find(s => s.key === key); }
93
+
94
+ function sanitizeId(name) {
95
+ return String(name).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'acc';
96
+ }
97
+ function partitionFor(svc, id) { return `persist:${svc}__${sanitizeId(id)}`; }
98
+ function partitionDir(svc, id) { return path.join(app.getPath('userData'), 'Partitions', `${svc}__${sanitizeId(id)}`); }
99
+
100
+ function findAccount(svc, id) { return store.accounts.find(a => a.svc === svc && a.id === id); }
101
+ function registerAccount(svc, id, label) {
102
+ let acc = findAccount(svc, id);
103
+ if (!acc) {
104
+ acc = { svc, id, label: label || id, colorIdx: store.accounts.length % COLORS.length, proxy: '', createdAt: new Date().toISOString(), lastState: 'unknown' };
105
+ store.accounts.push(acc);
106
+ store.save();
107
+ }
108
+ return acc;
109
+ }
110
+ function accountKey(svc, id) { return `${svc}::${id}`; }
111
+
112
+ // ---- credential origin guard ----
113
+ // A tab can navigate anywhere (outbound links, OAuth popups routed into the same partition), so a
114
+ // saved password must only ever be captured from, or filled into, the service's own domains.
115
+ function serviceHosts(svcKey) {
116
+ const s = serviceByKey(svcKey);
117
+ if (!s) return [];
118
+ return [...new Set([s.dashboardUrl, s.loginUrl, s.signupUrl].filter(Boolean).map(u => creds.hostOf(u)).filter(Boolean))];
119
+ }
120
+ function originAllowed(svcKey, url) {
121
+ const host = creds.hostOf(url || '');
122
+ if (!host) return false;
123
+ return serviceHosts(svcKey).some(h => creds.hostRelated(host, h));
124
+ }
125
+
126
+ // ---------- view manager ----------
127
+ class ViewManager {
128
+ constructor() {
129
+ this.tabs = new Map(); // key -> {view, svc, id, url, visible, title, lastState, lastActivated} — LIVE views only
130
+ this.session = []; // ordered open-tab set [{svc,id,url,lastActivated}] — includes slept tabs, persisted across restarts
131
+ this.activeKey = null;
132
+ this.window = null;
133
+ this.contentBounds = { x: 0, y: TAB_STRIP_H, width: 800, height: 600 };
134
+ this.modalOpen = false; // while a modal is open, all account views stay hidden (they paint above the chrome DOM)
135
+ }
136
+
137
+ attach(win) { this.window = win; }
138
+
139
+ // ---- session memory: the open-tab set survives sleeping AND app restarts ----
140
+ sessionEntry(svc, id) { return this.session.find(s => s.svc === svc && s.id === id); }
141
+
142
+ trackSession(svc, id, url) {
143
+ let e = this.sessionEntry(svc, id);
144
+ if (!e) { e = { svc, id, url, lastActivated: Date.now() }; this.session.push(e); }
145
+ else if (url) e.url = url;
146
+ this.persistSession();
147
+ return e;
148
+ }
149
+
150
+ untrackSession(svc, id) {
151
+ this.session = this.session.filter(s => !(s.svc === svc && s.id === id));
152
+ this.persistSession();
153
+ }
154
+
155
+ persistSession() {
156
+ store.openTabs = this.session.map(s => ({ svc: s.svc, id: s.id, url: s.url, lastActivated: s.lastActivated, active: accountKey(s.svc, s.id) === this.activeKey }));
157
+ // coalesce bursts (every tab click calls this) into one write off the hot path
158
+ if (this._persistTimer) clearTimeout(this._persistTimer);
159
+ this._persistTimer = setTimeout(() => { this._persistTimer = null; store.saveSession(); }, 250);
160
+ }
161
+
162
+ // Write any pending debounced session state immediately (used on quit).
163
+ flushSession() {
164
+ if (this._persistTimer) { clearTimeout(this._persistTimer); this._persistTimer = null; }
165
+ store.saveSession();
166
+ }
167
+
168
+ // Restore the tab set from the previous run: most-recent tabs come back live (bounded by warmLimit),
169
+ // the rest appear in the strip asleep and load on click.
170
+ restoreSession() {
171
+ const saved = (store.openTabs || []).filter(s => findAccount(s.svc, s.id) && serviceByKey(s.svc)); // skip deleted accounts and missing services
172
+ if (!saved.length) return 0;
173
+ saved.sort((a, b) => (a.lastActivated || 0) - (b.lastActivated || 0));
174
+ this.session = saved.map(s => ({ svc: s.svc, id: s.id, url: s.url, lastActivated: s.lastActivated || 0 }));
175
+ const limit = Math.max(1, Number(store.settings.warmLimit) || 5);
176
+ const toWake = saved.slice(-limit);
177
+ // Tabs come back warm but nothing is auto-activated: the app lands on Home (the account grid),
178
+ // which is the view the owner wants as the default surface.
179
+ for (const s of toWake) this.create(s.svc, s.id, s.url, { show: false, restoring: true });
180
+ this.activeKey = null;
181
+ this.emitState();
182
+ return toWake.length;
183
+ }
184
+
185
+ setBounds(b) {
186
+ this.contentBounds = b;
187
+ for (const t of this.tabs.values()) if (t.visible) t.view.setBounds(b);
188
+ }
189
+
190
+ setModalOpen(open) {
191
+ this.modalOpen = !!open;
192
+ if (open) {
193
+ // account views are native children that paint ABOVE the chrome DOM: hide them so the modal is reachable
194
+ for (const t of this.tabs.values()) if (t.visible) { t.view.setVisible(false); t.visible = false; }
195
+ } else if (this.activeKey && this.tabs.has(this.activeKey)) {
196
+ const t = this.tabs.get(this.activeKey);
197
+ t.view.setBounds(this.contentBounds);
198
+ t.view.setVisible(true);
199
+ t.visible = true;
200
+ }
201
+ this.emitState();
202
+ }
203
+
204
+ makeView(svc, id) {
205
+ const acc = findAccount(svc, id);
206
+ const view = new WebContentsView({
207
+ webPreferences: {
208
+ partition: partitionFor(svc, id),
209
+ contextIsolation: true,
210
+ nodeIntegration: false,
211
+ sandbox: true,
212
+ // isolated-world helper: remembers logins you type and fills them back on request.
213
+ // It exposes nothing to the page.
214
+ preload: path.join(__dirname, 'account-preload.js'),
215
+ },
216
+ });
217
+ view.setBackgroundColor('#ffffff'); // sync background: avoids paint-flash on switch (Electron #43293)
218
+ // deny-by-default permissions: embedded pages never get notifications/geo/media without an explicit allowlist
219
+ const ses = view.webContents.session;
220
+ if (!permissionConfigured.has(ses)) {
221
+ permissionConfigured.add(ses);
222
+ const ALLOWED = new Set(['fullscreen', 'clipboard-sanitized-write']);
223
+ ses.setPermissionRequestHandler((_wc, permission, cb) => cb(ALLOWED.has(permission)));
224
+ ses.setPermissionCheckHandler((_wc, permission, _origin) => ALLOWED.has(permission));
225
+ }
226
+ content.attachAdBlock(ses, () => !!store.settings.adBlock); // network-level ad/tracker blocking per partition
227
+ if (acc && acc.proxy) this.applyProxy(view.webContents.session, acc.proxy);
228
+ return view;
229
+ }
230
+
231
+ async applyProxy(ses, proxy) {
232
+ try {
233
+ if (!proxy) await ses.setProxy({ mode: 'system' });
234
+ else await ses.setProxy({ mode: 'fixed_servers', proxyRules: proxy });
235
+ await ses.closeAllConnections();
236
+ await ses.clearHostResolverCache();
237
+ await ses.forceReloadProxyConfig();
238
+ } catch (err) { console.error('proxy error:', err.message); }
239
+ }
240
+
241
+ create(svc, id, url, opts = {}) {
242
+ const { show = true, label = null } = opts;
243
+ const key = accountKey(svc, id);
244
+ if (this.tabs.has(key)) { if (show) this.activate(key); return key; }
245
+ const service = serviceByKey(svc);
246
+ if (!service) { console.error(`[tabs] cannot open ${svc}::${id} — service definition is missing`); return null; }
247
+ registerAccount(svc, id, label || id);
248
+ const acc = findAccount(svc, id);
249
+ const view = this.makeView(svc, id);
250
+ const tab = { view, svc, id, url: url || service.dashboardUrl, visible: false, title: '', lastState: acc.lastState || 'unknown', lastActivated: Date.now() };
251
+ this.tabs.set(key, tab);
252
+ this.trackSession(svc, id, tab.url); // tab joins the remembered open set
253
+ this.window.contentView.addChildView(view);
254
+ if (show) this.activate(key);
255
+ view.webContents.loadURL(tab.url).catch(err => console.error(`load failed ${key}:`, err.message));
256
+ view.webContents.on('page-title-updated', (_e, title) => { tab.title = title; this.emitState(); });
257
+ // Escape must reach Home even while the dashboard page has focus (the renderer's keydown
258
+ // handler only sees events when the chrome DOM is focused).
259
+ view.webContents.on('before-input-event', (_e, input) => {
260
+ if (input.type === 'keyDown' && input.key === 'Escape' && !this.modalOpen) this.deactivate();
261
+ });
262
+ view.webContents.on('did-finish-load', () => {
263
+ content.applyToPage(view.webContents, { adBlock: !!store.settings.adBlock, forceDark: !!store.settings.forceDark });
264
+ });
265
+ const onNav = (_e, url2) => {
266
+ tab.url = url2;
267
+ // conservative sign-in heuristic: sitting on the service's login page means not signed in
268
+ tab.lastState = url2.startsWith(service.loginUrl) ? 'signin' : 'ok';
269
+ const a = findAccount(svc, id);
270
+ if (a) { a.lastState = tab.lastState; store.save(); }
271
+ this.emitState();
272
+ };
273
+ view.webContents.on('did-navigate', onNav);
274
+ view.webContents.on('did-navigate-in-page', onNav); // dashboards route client-side (pushState) — must track too
275
+ view.webContents.on('render-process-gone', () => {
276
+ console.error(`renderer gone: ${key}`);
277
+ this.hibernate(key); // drop the dead view; partition survives, reopening rehydrates
278
+ });
279
+ view.webContents.setWindowOpenHandler(({ url: popupUrl }) => {
280
+ if (!/^https?:/i.test(popupUrl)) return { action: 'deny' };
281
+ const key = accountKey(svc, id);
282
+ const tab = this.tabs.get(key);
283
+ if (tab) {
284
+ // target=_blank / OAuth popups load in the SAME account's tab (same partition); the popup window itself is denied
285
+ tab.view.webContents.loadURL(popupUrl).catch(err => console.error('popup load failed:', err.message));
286
+ if (!this.modalOpen) this.activate(key);
287
+ } else {
288
+ this.create(svc, id, popupUrl, { show: !this.modalOpen });
289
+ }
290
+ return { action: 'deny' };
291
+ });
292
+ this.emitState();
293
+ return key;
294
+ }
295
+
296
+ activate(key) {
297
+ const tab = this.tabs.get(key);
298
+ if (!tab) return null;
299
+ for (const [k, t] of this.tabs) {
300
+ const vis = k === key && !this.modalOpen; // while a modal is open, views stay hidden
301
+ if (vis && !t.visible) { t.view.setBounds(this.contentBounds); t.view.setVisible(true); t.visible = true; }
302
+ else if (!vis && t.visible) { t.view.setVisible(false); t.visible = false; }
303
+ }
304
+ tab.lastActivated = Date.now();
305
+ this.activeKey = key;
306
+ const se = this.sessionEntry(tab.svc, tab.id);
307
+ if (se) { se.lastActivated = tab.lastActivated; se.url = tab.url; }
308
+ this.persistSession();
309
+ this.enforceWarmLimit();
310
+ this.emitState();
311
+ return key;
312
+ }
313
+
314
+ warmTabsSorted() {
315
+ return [...this.tabs.entries()]
316
+ .filter(([k]) => k !== this.activeKey)
317
+ .sort((a, b) => a[1].lastActivated - b[1].lastActivated);
318
+ }
319
+
320
+ // Evict least-recently-used tabs down to warmLimit. Each live dashboard costs ~350–375 MB of RAM,
321
+ // so this is the difference between a 600 MB footprint (5 tabs) and unbounded growth that would
322
+ // exhaust memory on machines with many accounts. Sled tabs stay in the strip and resume on click.
323
+ enforceWarmLimit() {
324
+ const limit = Math.max(1, Number(store.settings.warmLimit) || 5);
325
+ const warm = this.warmTabsSorted();
326
+ let excess = warm.length - (limit - (this.activeKey ? 1 : 0)); // the active tab, if any, occupies one slot
327
+ for (const [k] of warm) {
328
+ if (excess <= 0) break;
329
+ this.hibernate(k);
330
+ excess--;
331
+ }
332
+ }
333
+
334
+ // Home: hide every account view and show the chrome's grid. Tabs stay LIVE and warm — no teardown,
335
+ // so returning to a tab is instant and its page keeps its place.
336
+ deactivate() {
337
+ for (const t of this.tabs.values()) if (t.visible) { t.view.setVisible(false); t.visible = false; }
338
+ this.activeKey = null;
339
+ this.persistSession();
340
+ this.emitState();
341
+ }
342
+
343
+ // Close = remove from the remembered tab set entirely (disappears from the strip).
344
+ closeTab(key) {
345
+ const [svc, id] = key.split('::');
346
+ this.hibernate(key);
347
+ this.untrackSession(svc, id);
348
+ this.emitState();
349
+ }
350
+
351
+ hibernate(key) {
352
+ const tab = this.tabs.get(key);
353
+ if (!tab) return;
354
+ try { this.window.contentView.removeChildView(tab.view); } catch {}
355
+ try { tab.view.webContents.close(); } catch {} // separate: a dead contentView must not skip closing the renderer
356
+ this.tabs.delete(key);
357
+ // NOTE: the session entry is intentionally kept — a slept tab stays in the strip and can be resumed.
358
+ if (this.activeKey === key) {
359
+ this.activeKey = null;
360
+ const remaining = [...this.tabs.values()].sort((a, b) => b.lastActivated - a.lastActivated);
361
+ if (remaining.length) this.activate(accountKey(remaining[0].svc, remaining[0].id));
362
+ else this.persistSession();
363
+ }
364
+ this.emitState();
365
+ }
366
+
367
+ hibernateAll() {
368
+ this.activeKey = null; // clear first: otherwise each hibernate() re-activates a neighbour and re-persists
369
+ for (const k of [...this.tabs.keys()]) this.hibernate(k);
370
+ this.persistSession();
371
+ }
372
+
373
+ async deleteAccount(svc, id) {
374
+ const key = accountKey(svc, id);
375
+ if (this.tabs.has(key)) {
376
+ try { this.window.contentView.removeChildView(this.tabs.get(key).view); this.tabs.get(key).view.webContents.close(); } catch {}
377
+ this.tabs.delete(key);
378
+ }
379
+ this.untrackSession(svc, id); // also drop it from the remembered tab set
380
+ store.accounts = store.accounts.filter(a => !(a.svc === svc && a.id === id));
381
+ store.save();
382
+ try {
383
+ const ses = session.fromPartition(partitionFor(svc, id));
384
+ await ses.clearStorageData(); // no options = all storage types (websql/domstorage are not valid enum values)
385
+ } catch (err) { console.error('clearStorageData failed:', err.message); }
386
+ // best-effort dir removal (may fail while Chromium holds handles; leftover is inert once registry entry is gone)
387
+ try { fs.rmSync(partitionDir(svc, id), { recursive: true, force: true }); } catch {}
388
+ if (this.activeKey === key) this.activeKey = null;
389
+ this.emitState();
390
+ }
391
+
392
+ async updateAccount(svc, id, patch) {
393
+ const acc = findAccount(svc, id);
394
+ if (!acc) return;
395
+ if (typeof patch.label === 'string' && patch.label.trim()) { acc.label = patch.label.trim(); }
396
+ if (typeof patch.proxy === 'string') {
397
+ acc.proxy = patch.proxy.trim();
398
+ const tab = this.tabs.get(accountKey(svc, id));
399
+ if (tab) await this.applyProxy(tab.view.webContents.session, acc.proxy);
400
+ }
401
+ store.save();
402
+ this.emitState();
403
+ }
404
+
405
+ emitState() {
406
+ if (!this.window || !this.window.webContents) return;
407
+ this.window.webContents.send('tabs-state', {
408
+ activeKey: this.activeKey,
409
+ warmLimit: store.settings.warmLimit,
410
+ registry: store.accounts.map(a => ({
411
+ ...a,
412
+ key: accountKey(a.svc, a.id),
413
+ hasToken: !!tokens.tokenMeta(accountKey(a.svc, a.id)),
414
+ status: statusCache.get(accountKey(a.svc, a.id)) || null,
415
+ })), // live registry projection for the home grid (token presence + status summaries; never raw tokens)
416
+ groupTabs: !!store.settings.groupTabs,
417
+ collapsed: store.settings.collapsed || [],
418
+ stripCollapsed: store.settings.stripCollapsed || [],
419
+ settings: { ...store.settings },
420
+ blocked: content.stats.blocked,
421
+ credCount: creds.stats().total,
422
+ services: allServices(), // includes user-added services so the renderer stays in sync
423
+ // the strip shows the whole remembered open set: live tabs AND slept ones (resume on click)
424
+ tabs: this.session.map(s => {
425
+ const k = accountKey(s.svc, s.id);
426
+ const t = this.tabs.get(k);
427
+ const a = findAccount(s.svc, s.id) || {};
428
+ return {
429
+ key: k, svc: s.svc, id: s.id, label: a.label || s.id, colorIdx: a.colorIdx || 0,
430
+ title: t ? t.title : '', url: t ? t.url : s.url,
431
+ live: !!t, visible: t ? t.visible : false,
432
+ lastState: t ? t.lastState : (a.lastState || 'unknown'),
433
+ };
434
+ }),
435
+ });
436
+ }
437
+
438
+ snapshotMemory() {
439
+ // Summing workingSetSize over-reports badly: Chromium maps shared pages (the framework binary and
440
+ // friends) into every process, so they are counted once per process — with 10 processes that read
441
+ // 1,388 MB while Windows/Task Manager reported 803 MB private. privateBytes is the number that
442
+ // matches Task Manager, so it is what the UI shows; the working-set total is kept for reference.
443
+ const metrics = app.getAppMetrics();
444
+ let totalWS = 0; let totalPrivate = 0; const byType = {};
445
+ for (const m of metrics) {
446
+ totalWS += (m.memory && m.memory.workingSetSize) || 0;
447
+ totalPrivate += (m.memory && m.memory.privateBytes) || 0;
448
+ byType[m.type] = (byType[m.type] || 0) + 1;
449
+ }
450
+ const privateMB = Math.round(totalPrivate / 1024);
451
+ const workingSetMB = Math.round(totalWS / 1024);
452
+ return {
453
+ totalWorkingSetKB: totalWS,
454
+ workingSetMB,
455
+ privateMB,
456
+ totalMB: privateMB || workingSetMB, // privateBytes is Windows-only; fall back elsewhere
457
+ processes: byType,
458
+ processCount: metrics.length,
459
+ liveTabs: this.tabs.size,
460
+ };
461
+ }
462
+ }
463
+
464
+ // ---------- benchmark mode (unchanged contract): electron . --spike ----------
465
+ async function runSpike() {
466
+ const argIdx = process.argv.indexOf('--counts');
467
+ const counts = argIdx > -1 ? process.argv[argIdx + 1].split(',').map(Number) : [5, 10, 20, 30];
468
+ const report = { date: new Date().toISOString(), platform: process.platform, electron: process.versions.electron, chrome: process.versions.chrome, settleMs: SETTLE_MS, runs: [] };
469
+
470
+ const win = new BaseWindow({ width: 1280, height: 800, show: false });
471
+ const vm = new ViewManager();
472
+ vm.attach(win);
473
+
474
+ const waitLoad = (tab, timeoutMs = 45000) => {
475
+ const wc = tab.view.webContents;
476
+ return new Promise(resolve => {
477
+ if (!wc.isLoadingMainFrame()) return resolve();
478
+ const timer = setTimeout(done, timeoutMs);
479
+ function done() { clearTimeout(timer); wc.removeListener('did-finish-load', done); wc.removeListener('did-fail-load', done); resolve(); }
480
+ wc.once('did-finish-load', done);
481
+ wc.once('did-fail-load', done);
482
+ });
483
+ };
484
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
485
+
486
+ let comboIdx = 0;
487
+ const nextCombo = () => {
488
+ const svc = SERVICES[comboIdx % SERVICES.length];
489
+ const n = Math.floor(comboIdx / SERVICES.length) + 1;
490
+ comboIdx++;
491
+ return { svc, id: `acc${n}` };
492
+ };
493
+
494
+ for (const count of counts) {
495
+ const created = [];
496
+ while (vm.tabs.size < count) {
497
+ const { svc, id } = nextCombo();
498
+ vm.create(svc.key, id, svc.loginUrl, { show: false });
499
+ created.push({ key: accountKey(svc.key, id), svc: svc.key, id });
500
+ }
501
+ const tLoads0 = Date.now();
502
+ await Promise.all(created.map(c => waitLoad(vm.tabs.get(c.key))));
503
+ const loadMs = Date.now() - tLoads0;
504
+ await sleep(SETTLE_MS);
505
+
506
+ const live = vm.snapshotMemory();
507
+ report.runs.push({ phase: 'live-all-visible', count, loadMs, ...live });
508
+ console.log(`[spike] count=${count} live(all visible)=${live.totalMB}MB procs=${JSON.stringify(live.processes)} loadWallMs=${loadMs}`);
509
+
510
+ const keys = [...vm.tabs.keys()];
511
+ for (const k of keys.slice(1)) { vm.tabs.get(k).view.setVisible(false); vm.tabs.get(k).visible = false; }
512
+ await sleep(3000);
513
+ const hidden = vm.snapshotMemory();
514
+ report.runs.push({ phase: 'live-only-one-visible', count, ...hidden });
515
+ console.log(`[spike] count=${count} live(one visible)=${hidden.totalMB}MB`);
516
+
517
+ vm.hibernateAll();
518
+ await sleep(2000);
519
+ const hibernated = vm.snapshotMemory();
520
+ report.runs.push({ phase: 'hibernated', count, ...hibernated });
521
+ console.log(`[spike] count=${count} hibernated=${hibernated.totalMB}MB`);
522
+ }
523
+
524
+ const lastCount = counts[counts.length - 1];
525
+ const restoreKeys = [];
526
+ const t0 = Date.now();
527
+ for (let i = 0; i < lastCount; i++) {
528
+ const { svc, id } = nextCombo();
529
+ vm.create(svc.key, id, svc.loginUrl, { show: false });
530
+ restoreKeys.push(accountKey(svc.key, id));
531
+ }
532
+ await Promise.all(restoreKeys.map(k => vm.tabs.get(k) ? waitLoad(vm.tabs.get(k)) : Promise.resolve()));
533
+ const restoreMs = Date.now() - t0;
534
+ await sleep(SETTLE_MS);
535
+ const restored = vm.snapshotMemory();
536
+ report.runs.push({ phase: 'restored-from-disk', count: restoreKeys.length, restoreMs, ...restored });
537
+ console.log(`[spike] restore of ${restoreKeys.length} partitions: ${restoreMs}ms load, ${restored.totalMB}MB after settle`);
538
+
539
+ fs.writeFileSync(path.join(__dirname, '..', 'spike-report.json'), JSON.stringify(report, null, 2));
540
+ console.log('[spike] report written to spike-report.json');
541
+ app.quit();
542
+ }
543
+
544
+ // ---------- smoke test: create fresh accounts, verify, quit ----------
545
+ async function runSmoke() {
546
+ const win = new BaseWindow({ width: 1280, height: 800, show: false });
547
+ const vm = new ViewManager();
548
+ vm.attach(win);
549
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
550
+ const results = { mode: 'create', checks: [] };
551
+ const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
552
+
553
+ vm.create('netlify', 'a1', SERVICES.find(s => s.key === 'netlify').loginUrl, { show: false });
554
+ vm.create('supabase', 'a1', SERVICES.find(s => s.key === 'supabase').loginUrl, { show: false });
555
+ vm.create('vercel', 'a1', SERVICES.find(s => s.key === 'vercel').loginUrl, { show: false });
556
+
557
+ await sleep(15000); // let pages load and partitions hit disk
558
+
559
+ check('registry-has-3-accounts', store.accounts.length === 3, JSON.stringify(store.accounts.map(a => `${a.svc}/${a.id}`)));
560
+ const partDirs = ['netlify', 'supabase', 'vercel'].map(s => partitionDir(s, 'a1'));
561
+ const dirsExist = partDirs.filter(p => fs.existsSync(p));
562
+ check('partition-dirs-on-disk', dirsExist.length === 3, `${dirsExist.length}/3: ${dirsExist.join(', ')}`);
563
+ const accountsFile = fs.existsSync(userDataPath('accounts.json'));
564
+ check('accounts-json-written', accountsFile, userDataPath('accounts.json'));
565
+ const mem = vm.snapshotMemory();
566
+ check('live-tabs', mem.liveTabs === 3, `${mem.totalMB}MB`);
567
+
568
+ fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
569
+ app.exit(results.checks.every(c => c.ok) ? 0 : 1);
570
+ }
571
+
572
+ // ---------- smoke tokens: DPAPI round-trip + validation error path (no real token needed) ----------
573
+ async function runSmokeTokens() {
574
+ const results = { mode: 'tokens', checks: [] };
575
+ const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:tokens] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
576
+ const { safeStorage } = require('electron');
577
+ check('safeStorage-available', safeStorage.isEncryptionAvailable());
578
+ const rt = await tokens.setToken('vercel::toktest', 'vcp_fake_token_1234567890');
579
+ void rt;
580
+ const back = await tokens.getToken('vercel::toktest');
581
+ check('roundtrip', back === 'vcp_fake_token_1234567890');
582
+ check('ciphertext-not-plaintext', !fs.readFileSync(path.join(app.getPath('userData'), 'tokens.json'), 'utf8').includes('vcp_fake_token'));
583
+ // real-network validation error path: a fake token must be rejected as TOKEN_INVALID
584
+ try {
585
+ await PROVIDERS.vercel.validate('vcp_fake_token_1234567890');
586
+ check('fake-token-rejected', false, 'API accepted a fake token?!');
587
+ } catch (err) {
588
+ check('fake-token-rejected', err.message === 'TOKEN_INVALID', `err=${err.message}`);
589
+ }
590
+ await tokens.clearToken('vercel::toktest');
591
+ check('clear', (await tokens.getToken('vercel::toktest')) === null);
592
+ fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
593
+ app.exit(results.checks.every(c => c.ok) ? 0 : 1);
594
+ }
595
+
596
+ // ---------- smoke creds: CSV import, per-account remember/recall, encryption at rest ----------
597
+ async function runSmokeCreds() {
598
+ const results = { mode: 'creds', checks: [] };
599
+ const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:creds] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
600
+ const csv = path.join(app.getPath('temp'), `mam-creds-${process.pid}.csv`);
601
+ fs.writeFileSync(csv, 'url,username,password\nhttps://vercel.com/login,alice@example.com,hunter2\nhttps://app.netlify.com/login,bob@example.com,s3cret\n');
602
+ try {
603
+ const r = creds.importFromCsv(csv);
604
+ check('csv-import', r.added === 2 && r.total >= 2, JSON.stringify({ added: r.added, total: r.total }));
605
+
606
+ const rem = creds.rememberForAccount('vercel', 'acc9', { url: 'https://vercel.com/login', username: 'typed@example.com', password: 'typed-pw' });
607
+ check('remember-for-account', rem.saved === true);
608
+ const got = creds.forAccount('vercel', 'acc9', 'https://vercel.com/login');
609
+ check('recall-bound-to-account', !!got && got.username === 'typed@example.com' && got.password === 'typed-pw');
610
+
611
+ // an account with no saved login falls back to an imported credential for the same host
612
+ const fb = creds.forAccount('vercel', 'never-signed-in', 'https://vercel.com/login');
613
+ check('host-fallback-from-import', !!fb && fb.username === 'alice@example.com', fb ? fb.username : 'none');
614
+
615
+ const raw = fs.readFileSync(path.join(app.getPath('userData'), 'credentials.json'), 'utf8');
616
+ check('encrypted-at-rest', !raw.includes('hunter2') && !raw.includes('typed-pw'));
617
+
618
+ const st = creds.stats();
619
+ check('stats', st.total >= 3, JSON.stringify(st.bySource));
620
+ const browsers = creds.listChromiumProfiles();
621
+ console.log(`[smoke:creds] INFO detected browser profiles: ${browsers.map(b => `${b.browser}/${b.profile}`).join(', ') || 'none'}`);
622
+ creds.clearAll();
623
+ check('clear', creds.stats().total === 0);
624
+ } catch (err) {
625
+ check('exception', false, err.message);
626
+ } finally {
627
+ try { fs.rmSync(csv, { force: true }); } catch {}
628
+ }
629
+ fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
630
+ app.exit(results.checks.every(c => c.ok) ? 0 : 1);
631
+ }
632
+
633
+ // ---------- smoke autofill: real page, real preload — fill AND capture ----------
634
+ async function runSmokeAutofill() {
635
+ const results = { mode: 'autofill', checks: [] };
636
+ const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:autofill] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
637
+ const page = path.join(app.getPath('temp'), `mam-login-${process.pid}.html`);
638
+ fs.writeFileSync(page, `<!doctype html><meta charset=utf-8><title>Login</title>
639
+ <form id=f><input name=email type=email><input name=pw type=password><button type=submit>Sign in</button></form>`);
640
+
641
+ const win = new BaseWindow({ width: 900, height: 700, show: false });
642
+ const vm = new ViewManager();
643
+ vm.attach(win);
644
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
645
+
646
+ // ---- origin guard regression checks (the review found both of these as critical) ----
647
+ check('origin-guard-accepts-service-host', originAllowed('vercel', 'https://vercel.com/login'));
648
+ check('origin-guard-accepts-subdomain', originAllowed('supabase', 'https://api.supabase.com/x'));
649
+ check('origin-guard-rejects-foreign-host', !originAllowed('vercel', 'https://accounts.google.com/signin'));
650
+ check('origin-guard-rejects-lookalike', !originAllowed('vercel', 'https://vercel.com.evil.example/login'));
651
+ creds.rememberForAccount('vercel', 'guardtest', { url: 'https://vercel.com/login', username: 'v@example.com', password: 'vercel-pw' });
652
+ const crossOrigin = creds.forAccount('vercel', 'guardtest', 'https://accounts.google.com/signin');
653
+ check('credential-not-returned-for-foreign-origin', !crossOrigin || crossOrigin.host !== 'vercel.com',
654
+ crossOrigin ? `leaked ${crossOrigin.host}` : 'none');
655
+ const sameOrigin = creds.forAccount('vercel', 'guardtest', 'https://vercel.com/login');
656
+ check('credential-returned-for-own-origin', !!sameOrigin && sameOrigin.password === 'vercel-pw');
657
+
658
+ // capture: the preload must report credentials typed into a real form
659
+ let captured = null;
660
+ ipcMain.on('credential-captured', (_e, d) => { captured = d; });
661
+ let formSeen = false;
662
+ ipcMain.on('login-form-present', (_e, i) => { if (i && i.present) formSeen = true; });
663
+
664
+ vm.create('vercel', 'autofilltest', `file:///${page.replace(/\\/g, '/')}`, { show: false });
665
+ const tab = vm.tabs.get(accountKey('vercel', 'autofilltest'));
666
+ await new Promise(r => tab.view.webContents.once('did-finish-load', r));
667
+ await sleep(1500);
668
+ check('preload-detected-login-form', formSeen);
669
+
670
+ // fill: main pushes a stored credential into the page
671
+ creds.rememberForAccount('vercel', 'autofilltest', { url: 'https://vercel.com/login', username: 'me@example.com', password: 'p@ss-fill' });
672
+ const cred = creds.forAccount('vercel', 'autofilltest', 'https://vercel.com/login');
673
+ tab.view.webContents.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
674
+ await sleep(800);
675
+ const filled = await tab.view.webContents.executeJavaScript(
676
+ `({ email: document.querySelector('[name=email]').value, pw: document.querySelector('[name=pw]').value })`);
677
+ check('fill-username', filled.email === 'me@example.com', filled.email);
678
+ check('fill-password', filled.pw === 'p@ss-fill', filled.pw ? '(set)' : '(empty)');
679
+
680
+ // capture: submitting the form must hand the credentials back to main
681
+ await tab.view.webContents.executeJavaScript(`(() => {
682
+ const f = document.getElementById('f');
683
+ document.querySelector('[name=email]').value = 'typed@example.com';
684
+ document.querySelector('[name=pw]').value = 'typed-secret';
685
+ f.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
686
+ return true;
687
+ })()`);
688
+ await sleep(800);
689
+ check('capture-on-submit', !!captured && captured.username === 'typed@example.com' && captured.password === 'typed-secret',
690
+ captured ? captured.username : 'nothing captured');
691
+
692
+ creds.clearAll();
693
+ try { fs.rmSync(page, { force: true }); } catch {}
694
+ fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
695
+ app.exit(results.checks.every(c => c.ok) ? 0 : 1);
696
+ }
697
+
698
+ // ---------- smoke updates: version comparison + real release-feed check ----------
699
+ async function runSmokeUpdates() {
700
+ const results = { mode: 'updates', checks: [] };
701
+ const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:updates] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
702
+
703
+ check('newer-detects-patch', updater.isNewer('0.2.1', '0.2.2'));
704
+ check('newer-detects-minor', updater.isNewer('0.2.9', '0.3.0'));
705
+ check('newer-handles-double-digits', updater.isNewer('0.2.9', '0.2.10'));
706
+ check('not-newer-when-same', !updater.isNewer('0.2.2', '0.2.2'));
707
+ check('not-newer-when-older', !updater.isNewer('0.3.0', '0.2.9'));
708
+
709
+ const init = updater.init(() => {});
710
+ check('reports-current-version', init.currentVersion === app.getVersion(), init.currentVersion);
711
+ check('knows-it-cannot-install-in-dev', init.canDownload === false && init.packaged === false);
712
+
713
+ const s = await updater.check();
714
+ check('check-completes-with-a-verdict', ['available', 'up-to-date', 'error'].includes(s.status), `status=${s.status}`);
715
+ console.log(`[smoke:updates] INFO latest=${s.latestVersion || 'n/a'} error=${s.error || 'none'}`);
716
+ // A private repo returns 404 unauthenticated — the message must say so rather than being cryptic.
717
+ if (s.status === 'error') check('error-is-actionable', /private|release|network|token/i.test(s.error || ''), s.error);
718
+
719
+ fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
720
+ app.exit(results.checks.every(c => c.ok) ? 0 : 1);
721
+ }
722
+
723
+ // ---------- smoke restore: app restart, verify partitions persisted ----------
724
+ async function runSmokeRestore() {
725
+ const win = new BaseWindow({ width: 1280, height: 800, show: false });
726
+ const vm = new ViewManager();
727
+ vm.attach(win);
728
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
729
+ const results = { mode: 'restore', checks: [] };
730
+ const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:restore] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
731
+
732
+ const pre = ['netlify', 'supabase', 'vercel'].map(s => partitionDir(s, 'a1'));
733
+ const preExisting = pre.filter(p => fs.existsSync(p));
734
+ check('partitions-exist-before-open', preExisting.length === 3, `${preExisting.length}/3`);
735
+
736
+ // reopen the same accounts; if cookies persisted, the login pages should still carry session cookies
737
+ vm.create('netlify', 'a1', SERVICES.find(s => s.key === 'netlify').dashboardUrl, { show: false });
738
+ vm.create('supabase', 'a1', SERVICES.find(s => s.key === 'supabase').dashboardUrl, { show: false });
739
+ vm.create('vercel', 'a1', SERVICES.find(s => s.key === 'vercel').dashboardUrl, { show: false });
740
+ await sleep(12000);
741
+
742
+ const cookieCounts = [];
743
+ for (const [svc] of [['netlify'], ['supabase'], ['vercel']]) {
744
+ const ses = session.fromPartition(partitionFor(svc, 'a1'));
745
+ const cookies = await ses.cookies.get({});
746
+ cookieCounts.push({ svc, count: cookies.length });
747
+ }
748
+ check('cookies-persisted-across-restart', cookieCounts.every(c => c.count > 0), JSON.stringify(cookieCounts));
749
+
750
+ fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
751
+ app.exit(results.checks.every(c => c.ok) ? 0 : 1);
752
+ }
753
+
754
+ // ---------- interactive mode ----------
755
+ function runInteractive(captureMode = false) {
756
+ // BrowserWindow, not BaseWindow: the chrome page needs loadFile/webContents/ready-to-show, which
757
+ // BaseWindow lacks entirely. BrowserWindow extends BaseWindow, so contentView.addChildView (account
758
+ // views), getContentSize, and Menu accelerators all keep working.
759
+ const win = new BrowserWindow({
760
+ width: 1400, height: 900,
761
+ backgroundColor: '#1b1d22',
762
+ show: captureMode, // capturePage fails on hidden windows — capture mode must SHOW the window
763
+ webPreferences: { preload: path.join(__dirname, 'preload.js') },
764
+ });
765
+ const vm = new ViewManager();
766
+ vm.attach(win);
767
+ reloadPlugins(); // Phase 3: service manifests from <userData>/plugins
768
+ content.setNativeDark(!!store.settings.darkMode); // sites with their own dark theme follow it
769
+ content.loadFilters().then(src => console.log(`[adblock] filters from ${src}: ${content.filters.hosts.size} hosts, ${content.filters.cosmetic.length} cosmetic`));
770
+ win.loadFile(path.join(__dirname, 'ui.html'));
771
+ // Update system: version tracking + release info + explicit user-driven download/install.
772
+ // Nothing downloads or installs on its own; the UI shows what is available and you decide.
773
+ updater.init(s => { if (win && win.webContents && !win.webContents.isDestroyed()) win.webContents.send('update-state', s); });
774
+ updater.check().catch(() => {}); // one check at startup
775
+ setInterval(() => updater.check().catch(() => {}), 60 * 60 * 1000); // and hourly after that
776
+ const applyBounds = () => {
777
+ const [w, h] = win.getContentSize();
778
+ // reserve the left rail: account views start after it, so the rail (and Home) is always reachable
779
+ vm.setBounds({ x: RAIL_W, y: TAB_STRIP_H, width: Math.max(0, w - RAIL_W), height: h - TAB_STRIP_H });
780
+ };
781
+ win.once('ready-to-show', () => { applyBounds(); if (!captureMode) win.show(); });
782
+ if (captureMode) {
783
+ // Screenshot options (used to produce the README images):
784
+ // --capture-out <file> where to write the PNG (default ui-screenshot.png)
785
+ // --capture-open <svc::id> open this account tab before shooting
786
+ // --capture-scroll <px> scroll the Home grid down first
787
+ // --capture-wait <ms> extra settle time
788
+ // --capture-js "<expr>" run an expression in the chrome page (e.g. open a modal)
789
+ const argVal = name => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : null; };
790
+ const outFile = argVal('--capture-out') || 'ui-screenshot.png';
791
+ const openKey = argVal('--capture-open');
792
+ const scrollPx = Number(argVal('--capture-scroll') || 0);
793
+ const extraWait = Number(argVal('--capture-wait') || 0);
794
+ const runJs = argVal('--capture-js');
795
+ win.webContents.on('console-message', (_e, level, message, line, sourceId) => {
796
+ console.log(`[renderer:${level}] ${message} (${path.basename(String(sourceId))}:${line})`);
797
+ });
798
+ win.webContents.once('did-finish-load', () => {
799
+ setTimeout(async () => {
800
+ if (openKey) {
801
+ const [svc, id] = openKey.split('::');
802
+ vm.create(svc, id, (serviceByKey(svc) || {}).dashboardUrl);
803
+ await new Promise(r => setTimeout(r, Number(argVal('--capture-open-wait') || 20000))); // let the real dashboard paint
804
+ }
805
+ if (scrollPx) await win.webContents.executeJavaScript(`document.getElementById('home').scrollTop = ${scrollPx}`).catch(() => {});
806
+ if (runJs) await win.webContents.executeJavaScript(runJs).catch(err => console.error('[capture] js failed:', err.message));
807
+ if (extraWait) await new Promise(r => setTimeout(r, extraWait));
808
+ // UnknownVizError / empty frames clear once the compositor produces a frame — retry
809
+ let img = null; let lastErr = null;
810
+ for (let i = 0; i < 10 && !img; i++) {
811
+ try {
812
+ const candidate = await win.webContents.capturePage();
813
+ if (!candidate.isEmpty()) img = candidate;
814
+ } catch (err) { lastErr = err; }
815
+ if (!img) await new Promise(r => setTimeout(r, 1000));
816
+ }
817
+ try {
818
+ const dockInfo = await win.webContents.executeJavaScript(`(() => {
819
+ const d = document.getElementById('dock-home');
820
+ const l = document.getElementById('left');
821
+ return JSON.stringify({
822
+ dock: !!d,
823
+ leftExists: !!l,
824
+ leftHtmlLen: l ? l.innerHTML.length : -1,
825
+ bodyClass: document.body.className,
826
+ apiExists: typeof window.api !== 'undefined',
827
+ cardCount: document.querySelectorAll('.card').length,
828
+ updatePanel: (() => { const h = [...document.querySelectorAll('.svcgroup .head .name')].find(n => n.textContent === 'Updates'); return h ? h.closest('.svcgroup').innerText.replace(/\\s+/g, ' ').slice(0, 160) : 'MISSING'; })(),
829
+ });
830
+ })()`);
831
+ console.log('[capture] state:', dockInfo);
832
+ } catch (err) { console.error('[capture] state probe failed:', err.message); }
833
+ if (img) {
834
+ const target = path.isAbsolute(outFile) ? outFile : path.join(__dirname, '..', outFile);
835
+ fs.mkdirSync(path.dirname(target), { recursive: true });
836
+ fs.writeFileSync(target, img.toPNG());
837
+ console.log(`[capture] saved ${outFile}`, JSON.stringify(img.getSize()));
838
+ // The active account view is a NATIVE child view: it is absent from the window capture and
839
+ // from PrintWindow. Save it separately so it can be composited at (RAIL_W, TAB_STRIP_H).
840
+ const act = vm.activeKey && vm.tabs.get(vm.activeKey);
841
+ if (act) {
842
+ // same UnknownVizError/empty-frame retry as the window capture
843
+ let vimg = null; let vErr = null;
844
+ for (let i = 0; i < 12 && !vimg; i++) {
845
+ try {
846
+ const cand = await act.view.webContents.capturePage();
847
+ if (!cand.isEmpty()) vimg = cand;
848
+ } catch (err) { vErr = err; }
849
+ if (!vimg) await new Promise(r => setTimeout(r, 1000));
850
+ }
851
+ if (vimg) {
852
+ const vpath = target.replace(/\.png$/i, '.view.png');
853
+ fs.writeFileSync(vpath, vimg.toPNG());
854
+ console.log(`[capture] saved view layer ${path.basename(vpath)}`, JSON.stringify(vimg.getSize()), `offset=${RAIL_W},${TAB_STRIP_H}`);
855
+ } else {
856
+ console.error('[capture] view layer failed:', vErr ? vErr.message : 'empty frames');
857
+ }
858
+ }
859
+ } else {
860
+ console.error('[capture] failed after retries:', lastErr ? lastErr.message : 'empty frames');
861
+ }
862
+ app.exit(0);
863
+ }, 5000);
864
+ });
865
+ }
866
+ win.on('resize', applyBounds);
867
+ win.on('close', () => { vm.hibernateAll(); }); // 'close' fires before destruction — contentView is still usable here
868
+
869
+ // keyboard accelerators work even when a webview has focus
870
+ const menu = Menu.buildFromTemplate([
871
+ { label: 'App', submenu: [{ role: 'about' }, { type: 'separator' }, { role: 'quit' }] },
872
+ {
873
+ label: 'Tabs',
874
+ submenu: [
875
+ { label: 'Sleep Tab', accelerator: 'CmdOrCtrl+W', click: () => { if (vm.activeKey) vm.hibernate(vm.activeKey); } },
876
+ { label: 'Sleep All', accelerator: 'CmdOrCtrl+Shift+W', click: () => vm.hibernateAll() },
877
+ { type: 'separator' },
878
+ { label: 'Next Tab', accelerator: 'CmdOrCtrl+Tab', click: () => cycleTab(vm, 1) },
879
+ { label: 'Previous Tab', accelerator: 'CmdOrCtrl+Shift+Tab', click: () => cycleTab(vm, -1) },
880
+ ...Array.from({ length: 9 }, (_, i) => ({
881
+ label: `Tab ${i + 1}`, accelerator: `CmdOrCtrl+${i + 1}`,
882
+ click: () => { const keys = [...vm.tabs.keys()]; if (keys[i]) vm.activate(keys[i]); },
883
+ })),
884
+ ],
885
+ },
886
+ { label: 'Edit', submenu: [{ role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }] },
887
+ { label: 'View', submenu: [{ role: 'reload' }, { role: 'forceReload' }, { type: 'separator' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'zoomReset' }, { type: 'separator' }, { role: 'toggleDevTools' }] },
888
+ ]);
889
+ Menu.setApplicationMenu(menu);
890
+
891
+ ipcMain.on('ui-ready', () => {
892
+ if (!vm.sessionRestored) {
893
+ vm.sessionRestored = true;
894
+ const n = vm.restoreSession(); // bring back the tabs that were open at exit
895
+ if (n) console.log(`[session] restored ${n} live tab(s) of ${vm.session.length} remembered`);
896
+ // --open <svc::id>: open and focus one account at startup (demos, screenshots, shortcuts)
897
+ const oi = process.argv.indexOf('--open');
898
+ if (oi > -1 && process.argv[oi + 1]) {
899
+ const [svc, id] = String(process.argv[oi + 1]).split('::');
900
+ const service = serviceByKey(svc);
901
+ if (service) vm.create(svc, id, service.dashboardUrl);
902
+ }
903
+ }
904
+ vm.emitState();
905
+ });
906
+ ipcMain.handle('list-services', () => allServices());
907
+ ipcMain.handle('registry-list', () => store.accounts.map(a => ({ ...a, key: accountKey(a.svc, a.id) })));
908
+ ipcMain.handle('get-settings', () => store.settings);
909
+ ipcMain.handle('set-warm-limit', (_e, n) => {
910
+ store.settings.warmLimit = Math.max(1, Math.min(15, Number(n) || 5));
911
+ store.save();
912
+ vm.enforceWarmLimit();
913
+ vm.emitState();
914
+ return store.settings.warmLimit;
915
+ });
916
+ ipcMain.handle('add-account', (_e, svc, opts = {}) => {
917
+ const service = serviceByKey(svc);
918
+ if (!service) throw new Error(`unknown service: ${svc}`);
919
+ const { id, label, mode } = opts;
920
+ let cleanId = sanitizeId(id || `a${store.accounts.filter(a => a.svc === svc).length + 1}`);
921
+ let n = 2;
922
+ while (findAccount(svc, cleanId)) cleanId = `${sanitizeId(id || 'acc')}-${n++}`; // uniquify: never alias two accounts onto one session
923
+ const url = mode === 'signup' ? (service.signupUrl || service.loginUrl) : service.loginUrl;
924
+ vm.create(svc, cleanId, url, { label: (label || cleanId).trim() });
925
+ return accountKey(svc, cleanId);
926
+ });
927
+ ipcMain.handle('open-account', (_e, svc, id) => {
928
+ const key = accountKey(svc, id);
929
+ if (!vm.tabs.has(key)) {
930
+ if (!findAccount(svc, id)) return null; // never resurrect a deleted account from a stale UI click
931
+ const service = serviceByKey(svc);
932
+ vm.create(svc, id, service.dashboardUrl);
933
+ } else vm.activate(key);
934
+ return key;
935
+ });
936
+ ipcMain.handle('activate-tab', (_e, key) => vm.activate(key));
937
+ ipcMain.handle('sleep-tab', (_e, key) => vm.hibernate(key));
938
+ ipcMain.handle('sleep-all', () => vm.hibernateAll());
939
+ ipcMain.handle('update-account', (_e, svc, id, patch) => vm.updateAccount(svc, id, patch || {}));
940
+ ipcMain.handle('delete-account', (_e, svc, id) => vm.deleteAccount(svc, id));
941
+ ipcMain.handle('memory', () => vm.snapshotMemory());
942
+ ipcMain.handle('open-external', (_e, url) => {
943
+ if (!url) return;
944
+ try { const p = new URL(String(url)); if (p.protocol !== 'https:' && p.protocol !== 'http:') return; } catch { return; }
945
+ shell.openExternal(String(url)); // http/https only: never launch arbitrary OS-registered schemes from web content
946
+ });
947
+ ipcMain.handle('set-modal-open', (_e, open) => vm.setModalOpen(!!open));
948
+ ipcMain.handle('show-home', () => {
949
+ vm.deactivate(); // hide views, keep tabs live and warm — instant return to any tab
950
+ return true;
951
+ });
952
+ ipcMain.handle('close-tab', (_e, key) => vm.closeTab(key));
953
+
954
+ // ---------- updates ----------
955
+ ipcMain.handle('update-state', () => updater.snapshot());
956
+ ipcMain.handle('update-check', () => updater.check());
957
+ ipcMain.handle('update-download', () => updater.download());
958
+ ipcMain.handle('update-install', () => updater.install());
959
+ ipcMain.handle('app-version', () => app.getVersion());
960
+ ipcMain.handle('update-set-token', async (_e, plain) => {
961
+ await updater.setToken(plain);
962
+ return updater.check();
963
+ });
964
+
965
+ // ---------- saved logins: capture what you type, fill it back later ----------
966
+ function tabForSender(senderWc) {
967
+ for (const [key, t] of vm.tabs) if (t.view.webContents.id === senderWc.id) return { key, tab: t };
968
+ return null;
969
+ }
970
+ ipcMain.on('credential-captured', (e, data) => {
971
+ if (!store.settings.saveLogins) return;
972
+ const found = tabForSender(e.sender);
973
+ if (!found || !data || !data.password) return;
974
+ // Origin guard: only a login typed on the service's OWN domain may be bound to this account.
975
+ // Without this, a password typed on an OAuth/phishing page reached from the tab would overwrite
976
+ // the account's saved login and later be autofilled into the real service.
977
+ if (!originAllowed(found.tab.svc, data.url)) {
978
+ console.warn(`[logins] ignored a credential captured on a foreign origin (${creds.hostOf(data.url)}) for ${found.key}`);
979
+ return;
980
+ }
981
+ try {
982
+ const res = creds.rememberForAccount(found.tab.svc, found.tab.id, data);
983
+ if (res.saved) {
984
+ console.log(`[logins] remembered ${data.username || '(no user)'} for ${found.key}`);
985
+ vm.emitState();
986
+ }
987
+ } catch (err) { console.error('[logins] save failed:', err.message); }
988
+ });
989
+ ipcMain.on('login-form-present', (e, info) => {
990
+ const found = tabForSender(e.sender);
991
+ if (!found) return;
992
+ found.tab.loginForm = !!(info && info.present);
993
+ // auto-fill this account's own remembered login when its sign-in page appears
994
+ if (found.tab.loginForm && store.settings.autofill) {
995
+ const pageUrl = (info && info.url) || found.tab.view.webContents.getURL();
996
+ if (!originAllowed(found.tab.svc, pageUrl)) {
997
+ vm.emitState();
998
+ return; // never auto-type a password into a page that is not the service's own site
999
+ }
1000
+ const cred = creds.forAccount(found.tab.svc, found.tab.id, pageUrl);
1001
+ if (cred) e.sender.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
1002
+ }
1003
+ vm.emitState();
1004
+ });
1005
+ ipcMain.on('fill-result', () => {});
1006
+
1007
+ ipcMain.handle('fill-login', (_e, key) => {
1008
+ const t = vm.tabs.get(key);
1009
+ if (!t) throw new Error('That tab is asleep — open it first');
1010
+ const [svc, id] = key.split('::');
1011
+ const pageUrl = t.view.webContents.getURL();
1012
+ if (!originAllowed(svc, pageUrl)) {
1013
+ throw new Error(`This tab is on ${creds.hostOf(pageUrl) || 'another site'}, not ${serviceHosts(svc)[0] || 'the service'} — refusing to fill a saved password here`);
1014
+ }
1015
+ const cred = creds.forAccount(svc, id, pageUrl);
1016
+ if (!cred) throw new Error('No saved login for this account yet — sign in once and it will be remembered');
1017
+ t.view.webContents.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
1018
+ return true;
1019
+ });
1020
+ ipcMain.handle('creds-stats', () => creds.stats());
1021
+ ipcMain.handle('creds-browsers', () => creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData })));
1022
+ ipcMain.handle('creds-import-browser', async (_e, id) => {
1023
+ const p = creds.listChromiumProfiles().find(x => x.loginData === id);
1024
+ if (!p) throw new Error('Browser profile not found');
1025
+ return creds.importFromChromium(p);
1026
+ });
1027
+ ipcMain.handle('creds-import-csv', async () => {
1028
+ const { dialog } = require('electron');
1029
+ const r = await dialog.showOpenDialog({ title: 'Import passwords from CSV', filters: [{ name: 'CSV', extensions: ['csv'] }], properties: ['openFile'] });
1030
+ if (r.canceled || !r.filePaths[0]) return null;
1031
+ return creds.importFromCsv(r.filePaths[0]);
1032
+ });
1033
+ ipcMain.handle('creds-clear', () => creds.clearAll());
1034
+ ipcMain.handle('creds-for-account', (_e, svc, id) => {
1035
+ const svcCfg = serviceByKey(svc);
1036
+ const c = creds.forAccount(svc, id, svcCfg && svcCfg.loginUrl);
1037
+ return c ? { username: c.username, host: c.host, source: c.source } : null; // never the password
1038
+ });
1039
+ // ---------- Phase 3: service plugins (JSON manifests) ----------
1040
+ ipcMain.handle('plugins-list', () => ({
1041
+ dir: plugins.pluginsDir(),
1042
+ services: pluginServices.map(s => ({
1043
+ key: s.key, name: s.name, sourceFile: s.sourceFile, hasApi: !!s.api,
1044
+ shadowed: serviceByKey(s.key) !== s, // another service already owns this key
1045
+ })),
1046
+ errors: pluginErrors,
1047
+ }));
1048
+ ipcMain.handle('plugin-install', async () => {
1049
+ const { dialog } = require('electron');
1050
+ const r = await dialog.showOpenDialog({ title: 'Install a service plugin', filters: [{ name: 'Service manifest', extensions: ['json'] }], properties: ['openFile'] });
1051
+ if (r.canceled || !r.filePaths[0]) return null;
1052
+ const preview = plugins.validate(JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8')), path.basename(r.filePaths[0]));
1053
+ if (preview.ok) {
1054
+ const clash = [...SERVICES, ...(store.customServices || [])].find(s => s.key === preview.service.key);
1055
+ if (clash) throw new Error(`"${preview.service.key}" is already used by ${clash.name} — change the plugin's key`);
1056
+ }
1057
+ const svc = plugins.install(r.filePaths[0]);
1058
+ reloadPlugins();
1059
+ vm.emitState();
1060
+ return svc;
1061
+ });
1062
+ ipcMain.handle('plugin-remove', (_e, key) => {
1063
+ if (store.accounts.some(a => a.svc === key)) throw new Error('Delete this service’s accounts first');
1064
+ plugins.remove(key);
1065
+ reloadPlugins();
1066
+ vm.emitState();
1067
+ return true;
1068
+ });
1069
+ ipcMain.handle('plugin-write-example', () => {
1070
+ fs.mkdirSync(plugins.pluginsDir(), { recursive: true });
1071
+ const p = path.join(plugins.pluginsDir(), 'example-flyio.json.txt');
1072
+ fs.writeFileSync(p, JSON.stringify(plugins.EXAMPLE, null, 2));
1073
+ shell.showItemInFolder(p);
1074
+ return p;
1075
+ });
1076
+
1077
+ // ---------- Phase 2: user-added generic services (any URL) ----------
1078
+ ipcMain.handle('add-service', (_e, { name, url }) => {
1079
+ const clean = String(name || '').trim();
1080
+ if (!clean) throw new Error('Name is required');
1081
+ let parsed;
1082
+ try { parsed = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`); } catch { throw new Error('Enter a valid URL'); }
1083
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('Only http(s) URLs are supported');
1084
+ let key = sanitizeId(clean);
1085
+ if (serviceByKey(key)) { let n = 2; while (serviceByKey(`${key}-${n}`)) n++; key = `${key}-${n}`; }
1086
+ const svc = {
1087
+ key, name: clean.slice(0, 40),
1088
+ dashboardUrl: parsed.href, loginUrl: parsed.href, signupUrl: parsed.href,
1089
+ color: COLORS[(store.customServices.length + SERVICES.length) % COLORS.length],
1090
+ multiAccountPolicy: 'ok', custom: true,
1091
+ };
1092
+ store.customServices.push(svc);
1093
+ store.save();
1094
+ refreshManagedHosts();
1095
+ vm.emitState();
1096
+ return svc;
1097
+ });
1098
+ ipcMain.handle('remove-service', (_e, key) => {
1099
+ const svc = (store.customServices || []).find(s => s.key === key);
1100
+ if (!svc) throw new Error('Only user-added services can be removed');
1101
+ if (store.accounts.some(a => a.svc === key)) throw new Error('Delete this service’s accounts first');
1102
+ store.customServices = store.customServices.filter(s => s.key !== key);
1103
+ store.save();
1104
+ vm.emitState();
1105
+ return true;
1106
+ });
1107
+ ipcMain.handle('set-content-setting', (_e, name, value) => {
1108
+ if (!['adBlock', 'darkMode', 'forceDark', 'saveLogins', 'autofill'].includes(name)) throw new Error(`unknown setting: ${name}`);
1109
+ store.settings[name] = !!value;
1110
+ // Inverting a page that is already dark produces a washed-out light page, so the two modes
1111
+ // are mutually exclusive.
1112
+ if (name === 'forceDark' && value) store.settings.darkMode = false;
1113
+ if (name === 'darkMode' && value) store.settings.forceDark = false;
1114
+ store.save();
1115
+ content.setNativeDark(!!store.settings.darkMode);
1116
+ // re-apply to every live tab so the change is visible immediately
1117
+ for (const t of vm.tabs.values()) {
1118
+ if ((name === 'forceDark' || name === 'adBlock') && !value) t.view.webContents.reload(); // injected CSS can only be undone by reloading
1119
+ else content.applyToPage(t.view.webContents, { adBlock: !!store.settings.adBlock, forceDark: !!store.settings.forceDark });
1120
+ }
1121
+ vm.emitState();
1122
+ return store.settings[name];
1123
+ });
1124
+ ipcMain.handle('content-stats', () => ({
1125
+ blocked: content.stats.blocked,
1126
+ listSource: content.filters.loadedFrom,
1127
+ hosts: content.filters.hosts.size,
1128
+ cosmetic: content.filters.cosmetic.length,
1129
+ }));
1130
+ ipcMain.handle('set-group-tabs', (_e, on) => {
1131
+ store.settings.groupTabs = !!on;
1132
+ store.save();
1133
+ vm.emitState();
1134
+ return store.settings.groupTabs;
1135
+ });
1136
+ ipcMain.handle('toggle-collapsed', (_e, svc, scope = 'rail') => {
1137
+ const field = scope === 'strip' ? 'stripCollapsed' : 'collapsed'; // rail and strip collapse independently
1138
+ const set = new Set(store.settings[field] || []);
1139
+ if (set.has(svc)) set.delete(svc); else set.add(svc);
1140
+ store.settings[field] = [...set];
1141
+ store.save();
1142
+ vm.emitState();
1143
+ return store.settings[field];
1144
+ });
1145
+
1146
+ // ---------- API-token layer (tokens stay in main; renderer gets summaries only) ----------
1147
+ async function refreshAccountStatus(svc, id) {
1148
+ const key = accountKey(svc, id);
1149
+ const provider = providerFor(svc);
1150
+ const token = await tokens.getToken(key);
1151
+ if (!provider || !token) return;
1152
+ try {
1153
+ const { summary } = await provider.validate(token);
1154
+ statusCache.set(key, { summary, error: null, fetchedAt: Date.now() });
1155
+ tokens.markValidated(key, null);
1156
+ } catch (err) {
1157
+ const msg = err.message === 'TOKEN_INVALID' ? 'token invalid or expired' : err.message;
1158
+ statusCache.set(key, { summary: null, error: msg, fetchedAt: Date.now() });
1159
+ if (err.message === 'TOKEN_INVALID') tokens.markValidated(key, msg);
1160
+ }
1161
+ }
1162
+
1163
+ ipcMain.handle('set-account-token', async (_e, svc, id, plain) => {
1164
+ const key = accountKey(svc, id);
1165
+ if (!providerFor(svc)) throw new Error(`unknown service: ${svc}`);
1166
+ if (!plain || !plain.trim()) throw new Error('empty token');
1167
+ const provider = providerFor(svc);
1168
+ try {
1169
+ const { summary } = await provider.validate(plain.trim()); // validate BEFORE storing
1170
+ await tokens.setToken(key, plain.trim());
1171
+ tokens.markValidated(key, null);
1172
+ statusCache.set(key, { summary, error: null, fetchedAt: Date.now() });
1173
+ vm.emitState();
1174
+ return { ok: true, summary };
1175
+ } catch (err) {
1176
+ const msg = err.message === 'TOKEN_INVALID' ? 'token rejected by the service (invalid or lacking read scope)' : `could not validate: ${err.message}`;
1177
+ vm.emitState();
1178
+ throw new Error(msg);
1179
+ }
1180
+ });
1181
+ ipcMain.handle('clear-account-token', async (_e, svc, id) => {
1182
+ const key = accountKey(svc, id);
1183
+ await tokens.clearToken(key);
1184
+ statusCache.delete(key);
1185
+ vm.emitState();
1186
+ return true;
1187
+ });
1188
+ ipcMain.handle('refresh-all-status', async () => {
1189
+ const withTokens = store.accounts.filter(a => tokens.tokenMeta(accountKey(a.svc, a.id)));
1190
+ const CONC = 4;
1191
+ for (let i = 0; i < withTokens.length; i += CONC) {
1192
+ await Promise.all(withTokens.slice(i, i + CONC).map(a => refreshAccountStatus(a.svc, a.id)));
1193
+ }
1194
+ vm.emitState();
1195
+ return withTokens.length;
1196
+ });
1197
+ ipcMain.handle('smoke-token-roundtrip', async (_e, svc, id, plain) => {
1198
+ // verification-only path (used by --smoke-tokens): exercises storage without touching the network
1199
+ await tokens.setToken(accountKey(svc, id), plain);
1200
+ const back = await tokens.getToken(accountKey(svc, id));
1201
+ const meta = tokens.tokenMeta(accountKey(svc, id));
1202
+ await tokens.clearToken(accountKey(svc, id));
1203
+ const gone = await tokens.getToken(accountKey(svc, id));
1204
+ return { roundtrip: back === plain, meta, cleared: gone === null };
1205
+ });
1206
+ ipcMain.handle('active-tab-url', () => {
1207
+ const t = vm.tabs.get(vm.activeKey);
1208
+ return t ? t.url : null;
1209
+ });
1210
+ }
1211
+
1212
+ function cycleTab(vm, dir) {
1213
+ const keys = [...vm.tabs.keys()];
1214
+ if (!keys.length) return;
1215
+ const idx = vm.activeKey ? keys.indexOf(vm.activeKey) : 0;
1216
+ const next = keys[(idx + dir + keys.length) % keys.length];
1217
+ vm.activate(next);
1218
+ }
1219
+
1220
+ // test modes default to an isolated userData: they can never pollute the real registry/partitions
1221
+ const TEST_FLAGS = ['--spike', '--smoke', '--smoke-restore', '--smoke-tokens', '--smoke-creds', '--smoke-autofill', '--smoke-updates'];
1222
+ const testFlag = TEST_FLAGS.find(f => process.argv.includes(f));
1223
+ if (testFlag && !process.env.MAM_USER_DATA) {
1224
+ const dirName = testFlag === '--smoke-restore' ? 'mam-test-smoke' : `mam-test-${testFlag.slice(2)}`;
1225
+ app.setPath('userData', path.join(app.getPath('temp'), dirName)); // smoke and smoke-restore share a dir by design
1226
+ }
1227
+
1228
+ const gotLock = app.requestSingleInstanceLock();
1229
+ if (!gotLock) {
1230
+ app.exit(testFlag ? 3 : 0); // test modes must fail loudly (non-zero) when another instance holds the lock
1231
+ } else {
1232
+ app.on('second-instance', () => {
1233
+ const [w] = BaseWindow.getAllWindows();
1234
+ if (w) { if (w.isMinimized()) w.restore(); w.focus(); }
1235
+ });
1236
+ app.whenReady().then(() => {
1237
+ // --print-paths: where does this build keep its data? (support + rename verification)
1238
+ if (process.argv.includes('--print-paths')) {
1239
+ console.log(JSON.stringify({
1240
+ appName: app.getName(),
1241
+ version: app.getVersion(),
1242
+ userData: app.getPath('userData'),
1243
+ partitions: path.join(app.getPath('userData'), 'Partitions'),
1244
+ }, null, 2));
1245
+ return app.exit(0);
1246
+ }
1247
+ store.load();
1248
+ if (process.argv.includes('--spike')) runSpike().catch(err => { console.error(err); app.exit(1); });
1249
+ else if (process.argv.includes('--smoke')) runSmoke().catch(err => { console.error(err); app.exit(1); });
1250
+ else if (process.argv.includes('--smoke-restore')) runSmokeRestore().catch(err => { console.error(err); app.exit(1); });
1251
+ else if (process.argv.includes('--smoke-tokens')) runSmokeTokens().catch(err => { console.error(err); app.exit(1); });
1252
+ else if (process.argv.includes('--smoke-creds')) runSmokeCreds().catch(err => { console.error(err); app.exit(1); });
1253
+ else if (process.argv.includes('--smoke-autofill')) runSmokeAutofill().catch(err => { console.error(err); app.exit(1); });
1254
+ else if (process.argv.includes('--smoke-updates')) runSmokeUpdates().catch(err => { console.error(err); app.exit(1); });
1255
+ else if (process.argv.includes('--capture')) runInteractive(true);
1256
+ else runInteractive();
1257
+ });
1258
+ }
1259
+ app.on('window-all-closed', () => app.quit());