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/LICENSE +63 -0
- package/README.md +228 -0
- package/package.json +93 -0
- package/scripts/install-latest.js +114 -0
- package/src/main.js +1259 -0
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());
|