my-provider-dash-manager 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,23 +1,30 @@
1
- # My Provider Dash Manager
1
+ # My Provider Dash Manager — multi-account manager for 37 cloud services
2
2
 
3
- **One desktop app for every account you own.** Keep several Google, Microsoft, AWS, Vercel, Netlify,
4
- Cloudflare, Supabase, GitHub, npm, Stripe, Gmail, Outlook and Proton Mail accounts (37 services built
5
- in, any site by URL) signed in at the same time, each in a fully isolated session, and switch between
6
- them instantly — the way a browser handles tabs, but built around *accounts* instead of pages.
3
+ [![version](https://img.shields.io/github/v/release/FiredMosquito831/my-provider-dash-manager?label=version)](https://github.com/FiredMosquito831/my-provider-dash-manager/releases/latest)
4
+ [![npm](https://img.shields.io/npm/v/my-provider-dash-manager)](https://www.npmjs.com/package/my-provider-dash-manager)
5
+ [![downloads](https://img.shields.io/npm/dm/my-provider-dash-manager)](https://www.npmjs.com/package/my-provider-dash-manager)
6
+ [![stars](https://img.shields.io/github/stars/FiredMosquito831/my-provider-dash-manager)](https://github.com/FiredMosquito831/my-provider-dash-manager)
7
+ [![licence](https://img.shields.io/badge/licence-source--available-green)](LICENSE)
8
+
9
+ **For freelancers, agencies and developers running client plus personal infrastructure:** one desktop
10
+ app for every account you own. Keep several Google, Microsoft, AWS, Vercel, Netlify, Cloudflare,
11
+ Supabase, GitHub, npm, Stripe, Gmail, Outlook and Proton Mail accounts (37 services built in, any
12
+ site by URL) signed in at the same time, each in a fully isolated session, and switch between them
13
+ instantly — the way a browser handles tabs, but built around *accounts* instead of pages.
7
14
 
8
15
  <sub>Windows desktop app · Electron 43 · local-first · no telemetry, no account, no server ·
9
16
  source-available licence: free for personal and commercial use</sub>
10
17
 
11
- ![Home — the account rail on the left, every account as a card, the installed version bottom-left](docs/screenshots/home.png)
18
+ ![Home — the account rail on the left, every account as a card, the installed version bottom-left](https://raw.githubusercontent.com/FiredMosquito831/my-provider-dash-manager/main/docs/screenshots/home.png)
12
19
 
13
20
  Further down Home: the **Add an account** picker with every built-in service you are not using yet
14
21
  (37 in total, or add any site by URL), the **Updates** panel with the installed version, and the
15
22
  saved-login controls. Install with `npx my-provider-dash-manager` or straight from
16
23
  [Releases](https://github.com/FiredMosquito831/my-provider-dash-manager/releases) — see [Install](#install).
17
24
 
18
- ![Home, scrolled down — the service picker grouped by category, the Updates panel and saved logins](docs/screenshots/services.png)
25
+ ![Home, scrolled down — the service picker grouped by category, the Updates panel and saved logins](https://raw.githubusercontent.com/FiredMosquito831/my-provider-dash-manager/main/docs/screenshots/services.png)
19
26
 
20
- ![A Gmail account open in the app, with back / forward / reload / dashboard and the address bar in the strip](docs/screenshots/navigation.png)
27
+ ![A Gmail account open in the app, with back / forward / reload / dashboard and the address bar in the strip](https://raw.githubusercontent.com/FiredMosquito831/my-provider-dash-manager/main/docs/screenshots/navigation.png)
21
28
 
22
29
  <sub>Screenshots use a demo profile with placeholder account names.</sub>
23
30
 
@@ -127,7 +134,9 @@ Keyboard: `Ctrl+W` sleep tab · `Ctrl+Shift+W` sleep all · `Ctrl+Tab` cycle ·
127
134
  ## Features
128
135
 
129
136
  ### Accounts and sessions
130
- - **Unlimited accounts per service.** Each account gets its own Chromium storage partition — separate
137
+
138
+ **Multi-account session isolation** — every account in its own Chromium partition:
139
+ - **Unlimited accounts per service.** Each account gets its own storage partition — separate
131
140
  cookies, localStorage, IndexedDB and cache. Accounts can never see each other's session.
132
141
  - **Sessions persist across restarts.** Sign in once; you stay signed in (verified by an automated
133
142
  restart test that counts real cookies after relaunch).
@@ -170,10 +179,15 @@ Every login page is verified to load inside an isolated partition, landing on a
170
179
  guard trusts (`npm run smoke:services`). Google sign-in was verified against Google's own flow and
171
180
  works in the app. Anything else can be added by URL or as a plugin.
172
181
 
173
- ### Saved logins
182
+ ### Password manager with per-service import
174
183
  - **Remembers logins you type** inside an account tab, bound to *that* account, so two accounts on the
175
184
  same service refill with different credentials.
176
185
  - **Auto-fills sign-in pages** when it recognises one (toggleable), plus a manual fill action.
186
+ - **Manage logins** from Home → Saved logins → *Manage logins…*: every stored login grouped by
187
+ service, with delete. No password is ever shown.
188
+ - **Choose which login fills:** when more than one matches a site the app asks instead of guessing,
189
+ and your choice is remembered as that account's default (marked ★ in the manager).
190
+ - **Re-importing updates changed passwords** instead of silently keeping the old one.
177
191
  - **Imports existing passwords** from installed Chromium browsers (Chrome, Edge, Brave, Vivaldi,
178
192
  Opera) or from a CSV exported by any browser, including Firefox.
179
193
  - Encrypted at rest with Windows DPAPI. Passwords never reach the app's own UI process.
@@ -188,7 +202,7 @@ are upload-only), latest deploy state on Vercel, account name plus charges/payou
188
202
  The token field says which credential each service expects. Tokens are validated before being stored,
189
203
  encrypted with DPAPI, revocable in one click, and never leave the main process.
190
204
 
191
- ### Updates
205
+ ### Automatic silent updates
192
206
  - **Silent in-place updates** by default: downloaded in the background, installed without a wizard
193
207
  when the app closes or on **Restart & update**, relaunched on the new version.
194
208
  - **Auto-update toggle** for people who want to press every button themselves.
@@ -197,12 +211,17 @@ encrypted with DPAPI, revocable in one click, and never leave the main process.
197
211
  - Updates panel on Home: installed vs latest, release notes, progress. Startup and hourly checks
198
212
  against the public release feed. The interactive installer is only for first installs.
199
213
 
200
- ### Content
214
+ ### Built-in ad-blocking and dark mode
201
215
  - **Ad-blocking** driven by EasyList (~52,000 blocked hosts plus generic cosmetic rules, refreshed
202
216
  weekly), applied per account partition. Main-frame navigation and sign-in infrastructure are never
203
- blocked.
204
- - **Dark mode**: sites that support `prefers-color-scheme` go dark natively, and an optional
205
- force-dark inversion covers the ones that don't.
217
+ blocked. uBlock Origin cannot run inside Electron — its service worker needs browser APIs Electron
218
+ does not expose — so this built-in blocker covers that need.
219
+ - **Dark mode** for every account tab, powered by Dark Reader's own engine: one toggle on Home
220
+ applies it everywhere. Flipping it is instant — pages do not reload, so your scroll position and
221
+ anything half-typed into a form is preserved. It runs Dark Reader inside each tab's isolated
222
+ session, costing roughly 2 MB per open account. Dark Reader's browser extension cannot run in
223
+ Electron (its background worker needs APIs Electron does not expose, and its content script
224
+ would touch the page's own JavaScript), so the engine is bundled and driven directly.
206
225
 
207
226
  ### Extensibility
208
227
  - **Add any service by URL** — it gets its own rail row and isolated per-account sessions.
@@ -242,6 +261,78 @@ http/https only. Nothing syncs anywhere: there is no server, no account and no t
242
261
 
243
262
  ---
244
263
 
264
+ ## Requirements
265
+
266
+ - **Windows 10 or 11** (64-bit). The app is Windows-only today; macOS and Linux are not supported yet.
267
+ - About **350 MB** of memory with all tabs asleep, plus roughly **125 MB per live dashboard** and
268
+ **2 MB per open account** when dark mode is on. A machine with 8 GB of RAM handles the default
269
+ five warm tabs comfortably.
270
+ - **Node.js 18+ only for the `npx` route** — the app itself is a standalone Windows executable and
271
+ needs nothing else installed.
272
+
273
+ ## Frequently asked questions
274
+
275
+ **Can I be signed into two accounts on the same service at once?**
276
+ Yes. Each account gets its own Chromium storage partition, so both stay signed in simultaneously and
277
+ you switch between them like tabs. Nothing is ever signed out to reach the other.
278
+
279
+ **How much memory does an extra account use?**
280
+ Roughly 125 MB of private memory per live dashboard (the figure Task Manager reports), on a ~350 MB
281
+ baseline with everything asleep. The app keeps five tabs warm by default and sleeps the rest; slept
282
+ tabs stay in the strip and resume on click.
283
+
284
+ **Is it safe to store my passwords?**
285
+ Saved logins are encrypted with Windows DPAPI and bound to the account *and* the service's own
286
+ domains — a password typed on an unrelated page reached from a tab is never captured, and a saved
287
+ password is never filled into a page outside the service's domains. DPAPI protects against another
288
+ user on the machine, a stolen disk and casual copying; it does not protect against malware running as
289
+ you, which no desktop app can. Passwords never reach the app's own UI process.
290
+
291
+ **Does it work on macOS or Linux?**
292
+ Not yet — the app and its installer are Windows-only. The architecture is cross-platform, so other
293
+ operating systems stay open as future work.
294
+
295
+ **How do I import passwords from Chrome or Edge?**
296
+ Home → *Import from browser* reads installed Chromium browsers directly. Chrome 127 and newer lock
297
+ some entries with app-bound encryption that no other app can read; export those to CSV from the
298
+ browser and use *Import CSV* instead. Firefox always uses the CSV path. Re-importing updates
299
+ passwords you have changed.
300
+
301
+ **Why does SmartScreen warn me?**
302
+ The installer is not code-signed yet, so Windows shows a warning the first time. Choose **More info →
303
+ Run anyway**. Code signing is planned and removes the warning entirely.
304
+
305
+ **Can I add a service that is not on the list?**
306
+ Yes — *Add service by URL* gives any site its own rail row and isolated per-account sessions, and
307
+ JSON plugin manifests can define a service plus how to read its API for status cards.
308
+
309
+ **What happens to my accounts when I update?**
310
+ Nothing is lost. Accounts, sessions, saved logins and settings live in
311
+ `%APPDATA%\multi-acc-manager` and survive updates and reinstalls. Updates install silently in place
312
+ and relaunch the app.
313
+
314
+ ## Compared to the alternatives
315
+
316
+ | Approach | What it gives you | What it does not |
317
+ |---|---|---|
318
+ | Separate browser profiles | Isolated sessions per profile | One window each, no single place to see every account, no per-account password management |
319
+ | Incognito / private windows | A throwaway session | Nothing persists — you sign in again every time, and juggling windows is manual |
320
+ | Other multi-account managers | Similar switching | Usually extension-based in a normal browser, so one crash or update can take every session with it |
321
+ | **This app** | One window, persistent isolated sessions, a password manager, ad-blocking, dark mode and silent updates | Windows-only, unsigned installer, and no Chrome Web Store extensions (Electron cannot run them) |
322
+
323
+ ## Troubleshooting
324
+
325
+ - **SmartScreen warns on first run.** Expected while the installer is unsigned: **More info → Run
326
+ anyway**.
327
+ - **A service will not load inside the app** (Google Cloud Console is a known case). Use the
328
+ **Browser** button in the strip to open that page in your system browser — it keeps working from
329
+ there, and the rest of the app is unaffected.
330
+ - **"Which account am I in right now?"** Every tab and rail row is colour-coded and labelled by
331
+ account, and the tab strip shows the service and account name together. The active tab is
332
+ highlighted; slept tabs are dimmed.
333
+
334
+ ---
335
+
245
336
  ## Development
246
337
 
247
338
  ```bash
@@ -249,10 +340,11 @@ npm start # run the app
249
340
  npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
250
341
  npm run smoke:restore # sessions survive a restart (real cookies)
251
342
  npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
252
- npm run smoke:creds # import, per-account recall, encryption at rest (7)
343
+ npm run smoke:creds # import, per-account recall/selection, defaults, encryption at rest (16)
253
344
  npm run smoke:autofill # real page: form detection, fill, capture, origin guard (10)
254
345
  npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
255
346
  npm run smoke:services # every built-in login page loads inside an isolated partition (42)
347
+ npm run smoke:dark # Dark Reader darkens a white page, stays out of the page's own JS, re-injects after navigation (9)
256
348
  npm run capture # screenshot the running shell (--capture-out, --capture-open svc::id, --capture-js)
257
349
  npm run spike # memory benchmark
258
350
  npm run dist # build the Windows installer locally
@@ -261,7 +353,7 @@ npm start -- --print-paths # show the app name, version and data fold
261
353
  npm start -- --open vercel::personal # open an account on launch
262
354
  ```
263
355
 
264
- The suite is 111 checks across seven modes. `MAM_USER_DATA=<dir>` points any command at an isolated
356
+ The suite is 115 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
265
357
  profile.
266
358
 
267
359
  Releases are cut by pushing a `v*` tag: GitHub Actions checks the tag against `package.json`, builds
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "my-provider-dash-manager",
3
- "version": "0.7.0",
4
- "description": "Manage many accounts per cloud service (Vercel, Netlify, Supabase, Cloudflare, Railway, Render, GitHub) in one desktop app \u2014 isolated persistent sessions, one tab per account.",
3
+ "version": "0.8.0",
4
+ "description": "CLI to install and update My Provider Dash Manager: a free Windows desktop app that keeps many accounts per service (Vercel, Netlify, Supabase, Cloudflare, AWS, Google, Microsoft, GitHub, Gmail) signed in at once, each in a fully isolated session. Also available as mpdm.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
7
7
  "start": "electron .",
@@ -17,6 +17,7 @@
17
17
  "internal:log": "git --git-dir=.internal-git --work-tree=. log --oneline",
18
18
  "smoke:updates": "electron . --smoke-updates",
19
19
  "smoke:services": "electron . --smoke-services",
20
+ "smoke:dark": "electron . --smoke-dark",
20
21
  "install:latest": "node scripts/install-latest.js"
21
22
  },
22
23
  "devDependencies": {
@@ -31,6 +32,7 @@
31
32
  "productName": "My Provider Dash Manager",
32
33
  "files": [
33
34
  "src/**/*",
35
+ "vendor/**/*",
34
36
  "package.json"
35
37
  ],
36
38
  "win": {
@@ -57,6 +59,7 @@
57
59
  "files": [
58
60
  "scripts/cli.js",
59
61
  "scripts/install-latest.js",
62
+ "docs/screenshots",
60
63
  "README.md",
61
64
  "LICENSE"
62
65
  ],
@@ -72,17 +75,36 @@
72
75
  "keywords": [
73
76
  "multi-account",
74
77
  "account-manager",
78
+ "account-switcher",
79
+ "session-isolation",
80
+ "browser-profiles",
81
+ "isolated-sessions",
82
+ "desktop-app",
83
+ "electron",
84
+ "windows",
85
+ "developer-tools",
86
+ "dashboard",
87
+ "ad-block",
88
+ "adblocker",
89
+ "dark-mode",
90
+ "password-manager",
91
+ "password-importer",
75
92
  "vercel",
76
93
  "netlify",
77
94
  "supabase",
78
95
  "cloudflare",
79
- "railway",
80
- "render",
96
+ "aws",
97
+ "azure",
98
+ "google",
99
+ "microsoft",
100
+ "google-cloud",
81
101
  "github",
82
- "electron",
83
- "dashboard",
84
- "sessions",
85
- "windows"
102
+ "gitlab",
103
+ "npm",
104
+ "stripe",
105
+ "gmail",
106
+ "proton-mail",
107
+ "cli"
86
108
  ],
87
109
  "os": [
88
110
  "win32"
package/src/main.js CHANGED
@@ -5,6 +5,7 @@ const SERVICES = require('./services');
5
5
  const tokens = require('./api-tokens');
6
6
  const { PROVIDERS } = require('./providers');
7
7
  const content = require('./content');
8
+ const dark = require('./darkmode');
8
9
  const creds = require('./credentials');
9
10
  const plugins = require('./plugins');
10
11
  const updater = require('./updater');
@@ -35,9 +36,15 @@ function writeJson(file, data) {
35
36
  fs.writeFileSync(file, JSON.stringify(data, null, 2));
36
37
  }
37
38
 
39
+ // ---------- dark mode: Dark Reader's API build, in each account view's isolated world ----------
40
+ // Deliberately NOT a browser extension. Dark Reader's MV3 extension cannot work here: its
41
+ // background service worker dies on the chrome.* APIs Electron 43 does not expose, and its
42
+ // content script declares world:"MAIN", which mutates page prototypes the account's own
43
+ // JavaScript can observe — a breach of invariant 2. See src/darkmode.js for the measurements.
44
+
38
45
  const store = {
39
46
  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 },
47
+ settings: { warmLimit: 5, groupTabs: false, collapsed: [], stripCollapsed: [], adBlock: true, darkMode: true, saveLogins: true, autofill: true },
41
48
  openTabs: [], // session memory: [{svc, id, url, lastActivated, active}] — survives restarts
42
49
  customServices: [], // Phase 2: user-added services {key,name,dashboardUrl,loginUrl,color,custom:true}
43
50
  save() {
@@ -48,7 +55,12 @@ const store = {
48
55
  saveSession() { writeJson(userDataPath('session.json'), this.openTabs); },
49
56
  load() {
50
57
  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, autoUpdate: true }, readJson(userDataPath('settings.json'), {}));
58
+ const saved = readJson(userDataPath('settings.json'), {});
59
+ // 0.8.0: the force-dark CSS invert is replaced by bundled Dark Reader. A profile that had it
60
+ // on keeps dark mode on, and the dead key is dropped rather than left in the file.
61
+ if (saved.forceDark) saved.darkMode = true;
62
+ delete saved.forceDark;
63
+ this.settings = Object.assign({ warmLimit: 5, groupTabs: false, collapsed: [], stripCollapsed: [], adBlock: true, darkMode: true, saveLogins: true, autofill: true, autoUpdate: true }, saved);
52
64
  this.openTabs = readJson(userDataPath('session.json'), []);
53
65
  this.customServices = readJson(userDataPath('services.json'), []);
54
66
  },
@@ -258,6 +270,8 @@ class ViewManager {
258
270
  this.trackSession(svc, id, tab.url); // tab joins the remembered open set
259
271
  this.window.contentView.addChildView(view);
260
272
  if (show) this.activate(key);
273
+ // Dark mode is enabled per document from did-navigate below (the isolated world cannot exist
274
+ // before its page does), so there is nothing to attach before the first load.
261
275
  view.webContents.loadURL(tab.url).catch(err => console.error(`load failed ${key}:`, err.message));
262
276
  view.webContents.on('page-title-updated', (_e, title) => { tab.title = title; this.emitState(); });
263
277
  // Escape must reach Home even while the dashboard page has focus (the renderer's keydown
@@ -266,7 +280,13 @@ class ViewManager {
266
280
  if (input.type === 'keyDown' && input.key === 'Escape' && !this.modalOpen) this.deactivate();
267
281
  });
268
282
  view.webContents.on('did-finish-load', () => {
269
- content.applyToPage(view.webContents, { adBlock: !!store.settings.adBlock, forceDark: !!store.settings.forceDark });
283
+ content.applyToPage(view.webContents, { adBlock: !!store.settings.adBlock });
284
+ // Enable dark mode once the document's styles exist. did-navigate fires too early: the
285
+ // dynamic engine finds no stylesheets to transform yet, so enabling there leaves the page
286
+ // white. This fires again after every cross-origin navigation, which is exactly what the
287
+ // isolated-world reset requires — the world (and Dark Reader in it) is gone by then and must
288
+ // be re-injected. Same-document (pushState) routing never reaches here, so it never re-injects.
289
+ if (store.settings.darkMode) dark.enable(view.webContents);
270
290
  });
271
291
  const onNav = (_e, url2) => {
272
292
  tab.url = url2;
@@ -280,6 +300,10 @@ class ViewManager {
280
300
  if (a) { a.lastState = tab.lastState; store.save(); }
281
301
  this.emitState();
282
302
  };
303
+ // Dark mode is (re-)enabled from did-finish-load above, not here: that handler runs after the
304
+ // document's styles exist and after every cross-origin navigation, which is exactly when the
305
+ // isolated-world engine needs (re-)injecting. did-navigate-in-page (pushState) keeps the world,
306
+ // so it deliberately does not re-inject.
283
307
  view.webContents.on('did-navigate', onNav);
284
308
  view.webContents.on('did-navigate-in-page', onNav); // dashboards route client-side (pushState) — must track too
285
309
  view.webContents.on('did-start-loading', () => this.emitState());
@@ -366,6 +390,9 @@ class ViewManager {
366
390
  try { this.window.contentView.removeChildView(tab.view); } catch {}
367
391
  try { tab.view.webContents.close(); } catch {} // separate: a dead contentView must not skip closing the renderer
368
392
  this.tabs.delete(key);
393
+ // Nothing to undo for dark mode here: closing the renderer above discards its isolated world
394
+ // and the Dark Reader inside it. darkmode.js tracks state in a WeakMap, so the dead view is
395
+ // collected with it — no per-tab teardown, and reopening re-enables on its first navigation.
369
396
  // NOTE: the session entry is intentionally kept — a slept tab stays in the strip and can be resumed.
370
397
  if (this.activeKey === key) {
371
398
  this.activeKey = null;
@@ -389,6 +416,7 @@ class ViewManager {
389
416
  this.tabs.delete(key);
390
417
  }
391
418
  this.untrackSession(svc, id); // also drop it from the remembered tab set
419
+ try { creds.removeForAccount(svc, id); } catch {} // a deleted account's captured login must not linger for a future account with the same id
392
420
  store.accounts = store.accounts.filter(a => !(a.svc === svc && a.id === id));
393
421
  store.save();
394
422
  try {
@@ -719,6 +747,35 @@ async function runSmokeCreds() {
719
747
  const fb = creds.forAccount('vercel', 'never-signed-in', 'https://vercel.com/login');
720
748
  check('host-fallback-from-import', !!fb && fb.username === 'alice@example.com', fb ? fb.username : 'none');
721
749
 
750
+ // a second username captured for the same account is a SECOND candidate — no silent first-wins
751
+ creds.rememberForAccount('vercel', 'acc9', { url: 'https://vercel.com/login', username: 'second@example.com', password: 'second-pw' });
752
+ const cands = creds.candidatesForAccount('vercel', 'acc9', 'https://vercel.com/login');
753
+ check('candidates-include-both-usernames', cands.length === 3 && cands.filter(c => c.accountKey === 'vercel::acc9').length === 2 && cands.every(c => !!c.id), `${cands.length} candidates (2 own + 1 imported)`);
754
+ check('ambiguous-own-never-guesses', creds.credentialForAccount('vercel', 'acc9', 'https://vercel.com/login') === null);
755
+ const pick = cands.find(c => c.username === 'second@example.com');
756
+ creds.setDefaultForAccount(pick.id, 'vercel', 'acc9', 'https://vercel.com/login');
757
+ check('chosen-default-wins', (creds.credentialForAccount('vercel', 'acc9', 'https://vercel.com/login') || {}).username === 'second@example.com');
758
+ // the chooser projection must carry no password — invariant 3
759
+ const sums = creds.candidateSummariesForAccount('vercel', 'acc9', 'https://vercel.com/login');
760
+ check('chooser-summaries-have-no-passwords', sums.length === 3 && sums.every(c => !('password' in c) && !!c.id && c.isDefault === (c.username === 'second@example.com')), `${sums.length} summaries`);
761
+ check('delete-credential', creds.deleteCredential(pick.id) === true && !creds.listCredentials().some(c => c.id === pick.id) && (creds.credentialForAccount('vercel', 'acc9', 'https://vercel.com/login') || {}).username === 'typed@example.com');
762
+ check('manager-list-has-no-passwords', creds.listCredentials().every(c => !('password' in c) && !!c.id));
763
+
764
+ // two imported logins on one host are ambiguous: autofill asks instead of guessing
765
+ const csv2 = path.join(app.getPath('temp'), `mam-creds2-${process.pid}.csv`);
766
+ fs.writeFileSync(csv2, 'url,username,password\nhttps://vercel.com/login,carol@example.com,carol-pw\n');
767
+ try {
768
+ const r2 = creds.importFromCsv(csv2);
769
+ check('second-import-added', r2.added === 1, JSON.stringify(r2));
770
+ check('ambiguous-import-never-guesses', creds.credentialForAccount('vercel', 'third-account', 'https://vercel.com/login') === null);
771
+ } finally { try { fs.rmSync(csv2, { force: true }); } catch {} }
772
+
773
+ // deleting an account drops its captured logins AND its defaults
774
+ creds.rememberForAccount('vercel', 'acc9', { url: 'https://vercel.com/login', username: 'third@example.com', password: 'third-pw' });
775
+ creds.setDefaultForAccount(creds.candidatesForAccount('vercel', 'acc9', 'https://vercel.com/login').find(c => c.username === 'third@example.com').id, 'vercel', 'acc9', 'https://vercel.com/login');
776
+ const removed = creds.removeForAccount('vercel', 'acc9');
777
+ check('account-delete-drops-captures-and-defaults', removed === 2 && !creds.listCredentials().some(c => (c.accountKey === 'vercel::acc9') || (c.defaultForAccounts || []).includes('vercel::acc9')), `removed=${removed}`);
778
+
722
779
  const raw = fs.readFileSync(path.join(app.getPath('userData'), 'credentials.json'), 'utf8');
723
780
  check('encrypted-at-rest', !raw.includes('hunter2') && !raw.includes('typed-pw'));
724
781
 
@@ -830,6 +887,79 @@ async function runSmokeUpdates() {
830
887
  app.exit(results.checks.every(c => c.ok) ? 0 : 1);
831
888
  }
832
889
 
890
+ // ---------- smoke dark: Dark Reader's API build really darkens, and only inside the isolated world ----------
891
+ // The old check asserted a Dark Reader extension was LOADED, which was vacuous: its MV3 worker
892
+ // crashed on Electron 43 and only the OS-preference fallback CSS ever ran. This one asserts the
893
+ // engine actually changes computed colours AND that the page's own main world is left untouched
894
+ // (invariant 2), which is the whole reason the engine is not shipped as a browser extension.
895
+ async function runSmokeDark() {
896
+ const results = { mode: 'dark', checks: [] };
897
+ const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:dark] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
898
+ const win = new BaseWindow({ width: 900, height: 700, show: false });
899
+ const vm = new ViewManager();
900
+ vm.attach(win);
901
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
902
+
903
+ // The vendored bundle must be the exact self-contained build the app ships.
904
+ const vroot = path.join(__dirname, '..', 'vendor', 'darkreader');
905
+ let bytes = 0; let version = '';
906
+ try { bytes = fs.statSync(path.join(vroot, 'darkreader.js')).size; } catch {}
907
+ try { version = JSON.parse(fs.readFileSync(path.join(vroot, 'package.json'), 'utf8')).version || ''; } catch {}
908
+ check('vendored-bundle-present', bytes > 300000, `${bytes} bytes`);
909
+ check('vendored-version', version === '4.9.133', version || 'unreadable');
910
+ check('vendored-license-mit', (() => { try { return /MIT License/.test(fs.readFileSync(path.join(vroot, 'LICENSE'), 'utf8')); } catch { return false; } })());
911
+
912
+ // A local page whose background is explicitly WHITE. Its baseline colour is known, so "it got
913
+ // dark" can never be vacuous (a real dashboard that already honours prefers-color-scheme would).
914
+ // Served over file://, so this check needs no network.
915
+ const html = '<!doctype html><html><head><style>html,body{background:#ffffff;color:#111111}</style></head><body><h1>probe</h1></body></html>';
916
+ const p1 = path.join(app.getPath('temp'), 'mam-dark-white.html');
917
+ const p2 = path.join(app.getPath('temp'), 'mam-dark-white2.html');
918
+ fs.writeFileSync(p1, html); fs.writeFileSync(p2, html);
919
+ const toUrl = p => require('url').pathToFileURL(p).href;
920
+
921
+ store.settings.darkMode = true; // the create path injects only when this is on
922
+ vm.create('vercel', 'darkprobe', toUrl(p1), { show: false });
923
+ const probe = vm.tabs.get(accountKey('vercel', 'darkprobe'));
924
+ const wc = probe.view.webContents;
925
+ const bgOf = async () => {
926
+ try { return await wc.executeJavaScript(`getComputedStyle(document.documentElement).backgroundColor`); } catch { return ''; }
927
+ };
928
+ const inWorld = async code => {
929
+ try { return await wc.executeJavaScriptInIsolatedWorld(dark.DARK_WORLD_ID, [{ code }]); } catch { return undefined; }
930
+ };
931
+ await new Promise(r => wc.once('did-finish-load', r));
932
+ await sleep(3000); // let the engine compute and apply
933
+
934
+ const bgOn = await bgOf();
935
+ check('page-darkens-with-dark-reader', !!bgOn && bgOn !== '' && !/255,\s*255,\s*255/.test(bgOn), bgOn);
936
+
937
+ // Invariant 2: the page's own main world must be untouched while darkening is active.
938
+ const mainGlobal = await wc.executeJavaScript(`typeof window.DarkReader`).catch(() => '');
939
+ check('main-world-has-no-darkreader-global', mainGlobal === 'undefined', `typeof window.DarkReader = ${mainGlobal}`);
940
+ const sheetsGetter = await wc.executeJavaScript(`String(Object.getOwnPropertyDescriptor(Document.prototype,'styleSheets').get)`).catch(() => '');
941
+ check('main-world-stylesheets-getter-native', /native code/.test(sheetsGetter), sheetsGetter.slice(0, 40));
942
+ // The engine itself lives in the isolated world — exactly where it is supposed to be.
943
+ const worldType = await inWorld('typeof window.DarkReader');
944
+ check('engine-runs-in-isolated-world', worldType === 'object', `isolated world typeof = ${worldType}`);
945
+
946
+ // Toggling OFF restores the page's own colours, in place (no reload).
947
+ await dark.disable(wc);
948
+ await sleep(1500);
949
+ const bgOff = await bgOf();
950
+ check('disable-restores-native-colours', /255,\s*255,\s*255/.test(bgOff), `off=${bgOff}`);
951
+
952
+ // A cross-origin navigation discards the isolated world and the Dark Reader inside it; the
953
+ // did-navigate hook must re-inject and re-darken. A second local file is a different origin.
954
+ await new Promise(r => { wc.once('did-finish-load', r); wc.loadURL(toUrl(p2)).catch(() => {}); });
955
+ await sleep(3000);
956
+ const bgNav = await bgOf();
957
+ check('re-darkens-after-cross-origin-navigation', !!bgNav && !/255,\s*255,\s*255/.test(bgNav), bgNav);
958
+
959
+ fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
960
+ app.exit(results.checks.every(c => c.ok) ? 0 : 1);
961
+ }
962
+
833
963
  // ---------- smoke restore: app restart, verify partitions persisted ----------
834
964
  async function runSmokeRestore() {
835
965
  const win = new BaseWindow({ width: 1280, height: 800, show: false });
@@ -875,7 +1005,6 @@ function runInteractive(captureMode = false) {
875
1005
  const vm = new ViewManager();
876
1006
  vm.attach(win);
877
1007
  reloadPlugins(); // Phase 3: service manifests from <userData>/plugins
878
- content.setNativeDark(!!store.settings.darkMode); // sites with their own dark theme follow it
879
1008
  content.loadFilters().then(src => console.log(`[adblock] filters from ${src}: ${content.filters.hosts.size} hosts, ${content.filters.cosmetic.length} cosmetic`));
880
1009
  win.loadFile(path.join(__dirname, 'ui.html'));
881
1010
  // Update system: version tracking + release info + explicit user-driven download/install.
@@ -974,7 +1103,7 @@ function runInteractive(captureMode = false) {
974
1103
  });
975
1104
  }
976
1105
  win.on('resize', applyBounds);
977
- win.on('close', () => { vm.hibernateAll(); }); // 'close' fires before destruction — contentView is still usable here
1106
+ win.on('close', () => { vm.hibernateAll(); vm.flushSession(); }); // 'close' fires before destruction — contentView is still usable here
978
1107
 
979
1108
  // keyboard accelerators work even when a webview has focus
980
1109
  const menu = Menu.buildFromTemplate([
@@ -1094,6 +1223,14 @@ function runInteractive(captureMode = false) {
1094
1223
  if (!store.settings.saveLogins) return;
1095
1224
  const found = tabForSender(e.sender);
1096
1225
  if (!found || !data || !data.password) return;
1226
+ // A username-less capture (an autofilled form, a stray Enter) must never overwrite a real login
1227
+ // for THIS account. Only this account's own captures count — an imported fallback row belongs to
1228
+ // no account and must not block the user recording their own login.
1229
+ if (!data.username) {
1230
+ const own = creds.candidatesForAccount(found.tab.svc, found.tab.id, data.url)
1231
+ .some(c => c.accountKey === found.key && c.username);
1232
+ if (own) return;
1233
+ }
1097
1234
  // Origin guard: only a login typed on the service's OWN domain may be bound to this account.
1098
1235
  // Without this, a password typed on an OAuth/phishing page reached from the tab would overwrite
1099
1236
  // the account's saved login and later be autofilled into the real service.
@@ -1127,7 +1264,7 @@ function runInteractive(captureMode = false) {
1127
1264
  });
1128
1265
  ipcMain.on('fill-result', () => {});
1129
1266
 
1130
- ipcMain.handle('fill-login', (_e, key) => {
1267
+ ipcMain.handle('fill-login', (_e, key, credentialId = null) => {
1131
1268
  const t = vm.tabs.get(key);
1132
1269
  if (!t) throw new Error('That tab is asleep — open it first');
1133
1270
  const [svc, id] = key.split('::');
@@ -1135,10 +1272,19 @@ function runInteractive(captureMode = false) {
1135
1272
  if (!originAllowed(svc, pageUrl)) {
1136
1273
  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`);
1137
1274
  }
1138
- const cred = creds.forAccount(svc, id, pageUrl);
1139
- if (!cred) throw new Error('No saved login for this account yet — sign in once and it will be remembered');
1275
+ if (!creds.candidatesForAccount(svc, id, pageUrl).length) throw new Error('No saved login for this account yet — sign in once and it will be remembered');
1276
+ const candidates = creds.candidatesForAccount(svc, id, pageUrl);
1277
+ if (!candidates.length) throw new Error('No saved login matches this account and site');
1278
+ // The user may have navigated since the chooser was rendered. Recompute from the LIVE URL;
1279
+ // if the picked id no longer belongs here, return fresh summaries instead of persisting a stale default.
1280
+ if (credentialId && !candidates.some(c => c.id === credentialId)) {
1281
+ return { choose: creds.candidateSummariesForAccount(svc, id, pageUrl) };
1282
+ }
1283
+ const cred = creds.credentialForAccount(svc, id, pageUrl, credentialId || null);
1284
+ if (!cred) return { choose: creds.candidateSummariesForAccount(svc, id, pageUrl) };
1285
+ if (credentialId) creds.setDefaultForAccount(credentialId, svc, id, pageUrl); // remember only a live-host candidate
1140
1286
  t.view.webContents.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
1141
- return true;
1287
+ return { filled: cred.username };
1142
1288
  });
1143
1289
  ipcMain.handle('creds-stats', () => creds.stats());
1144
1290
  ipcMain.handle('creds-browsers', () => creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData })));
@@ -1156,9 +1302,21 @@ function runInteractive(captureMode = false) {
1156
1302
  ipcMain.handle('creds-clear', () => creds.clearAll());
1157
1303
  ipcMain.handle('creds-for-account', (_e, svc, id) => {
1158
1304
  const svcCfg = serviceByKey(svc);
1159
- const c = creds.forAccount(svc, id, svcCfg && svcCfg.loginUrl);
1305
+ const c = creds.credentialForAccount(svc, id, svcCfg && svcCfg.loginUrl);
1160
1306
  return c ? { username: c.username, host: c.host, source: c.source } : null; // never the password
1161
1307
  });
1308
+ // Credential manager: summaries only, never passwords — the renderer must not hold one.
1309
+ // Imported passwords are classified by host so the manager can filter/group them per service.
1310
+ ipcMain.handle('creds-list', () => creds.listCredentials().map(c => {
1311
+ const accountSvc = c.accountKey ? c.accountKey.split('::')[0] : null;
1312
+ const serviceKeys = accountSvc ? [accountSvc] : allServices()
1313
+ .filter(s => serviceHosts(s.key).some(h => creds.hostRelated(c.host, h)))
1314
+ .map(s => s.key);
1315
+ return { ...c, serviceKeys };
1316
+ }));
1317
+ ipcMain.handle('creds-default', (_e, credentialId, svc, id, hostHint) =>
1318
+ creds.setDefaultForAccount(credentialId, svc, id, hostHint));
1319
+ ipcMain.handle('creds-delete', (_e, credentialId) => creds.deleteCredential(credentialId));
1162
1320
  // ---------- Phase 3: service plugins (JSON manifests) ----------
1163
1321
  ipcMain.handle('plugins-list', () => ({
1164
1322
  dir: plugins.pluginsDir(),
@@ -1228,19 +1386,26 @@ function runInteractive(captureMode = false) {
1228
1386
  return true;
1229
1387
  });
1230
1388
  ipcMain.handle('set-content-setting', (_e, name, value) => {
1231
- if (!['adBlock', 'darkMode', 'forceDark', 'saveLogins', 'autofill', 'autoUpdate'].includes(name)) throw new Error(`unknown setting: ${name}`);
1389
+ if (!['adBlock', 'darkMode', 'saveLogins', 'autofill', 'autoUpdate'].includes(name)) throw new Error(`unknown setting: ${name}`);
1232
1390
  store.settings[name] = !!value;
1233
1391
  if (name === 'autoUpdate') { store.save(); updater.setAutoUpdate(!!value); vm.emitState(); return !!value; }
1234
- // Inverting a page that is already dark produces a washed-out light page, so the two modes
1235
- // are mutually exclusive.
1236
- if (name === 'forceDark' && value) store.settings.darkMode = false;
1237
- if (name === 'darkMode' && value) store.settings.forceDark = false;
1238
1392
  store.save();
1239
- content.setNativeDark(!!store.settings.darkMode);
1240
- // re-apply to every live tab so the change is visible immediately
1241
- for (const t of vm.tabs.values()) {
1242
- if ((name === 'forceDark' || name === 'adBlock') && !value) t.view.webContents.reload(); // injected CSS can only be undone by reloading
1243
- else content.applyToPage(t.view.webContents, { adBlock: !!store.settings.adBlock, forceDark: !!store.settings.forceDark });
1393
+ if (name === 'darkMode') {
1394
+ // The engine runs in each view's isolated world, so a flip is one enable/disable call per
1395
+ // live tab — no reload. The previous extension path forced a reload of every open tab, which
1396
+ // threw away scroll position and anything typed into a half-filled form.
1397
+ const on = !!value;
1398
+ for (const t of vm.tabs.values()) {
1399
+ if (on) dark.enable(t.view.webContents);
1400
+ else dark.disable(t.view.webContents);
1401
+ }
1402
+ } else if (name === 'adBlock') {
1403
+ // the network layer flips live through its closure; cosmetic CSS needs a re-apply on, and a
1404
+ // reload off (injected CSS can only be undone by reloading)
1405
+ for (const t of vm.tabs.values()) {
1406
+ if (value) content.applyToPage(t.view.webContents, { adBlock: true });
1407
+ else t.view.webContents.reload();
1408
+ }
1244
1409
  }
1245
1410
  vm.emitState();
1246
1411
  return store.settings[name];
@@ -1342,7 +1507,7 @@ function cycleTab(vm, dir) {
1342
1507
  }
1343
1508
 
1344
1509
  // test modes default to an isolated userData: they can never pollute the real registry/partitions
1345
- const TEST_FLAGS = ['--spike', '--smoke', '--smoke-restore', '--smoke-tokens', '--smoke-creds', '--smoke-autofill', '--smoke-updates', '--smoke-services'];
1510
+ const TEST_FLAGS = ['--spike', '--smoke', '--smoke-restore', '--smoke-tokens', '--smoke-creds', '--smoke-autofill', '--smoke-updates', '--smoke-services', '--smoke-dark'];
1346
1511
  const testFlag = TEST_FLAGS.find(f => process.argv.includes(f));
1347
1512
  if (testFlag && !process.env.MAM_USER_DATA) {
1348
1513
  const dirName = testFlag === '--smoke-restore' ? 'mam-test-smoke' : `mam-test-${testFlag.slice(2)}`;
@@ -1377,6 +1542,7 @@ if (!gotLock) {
1377
1542
  else if (process.argv.includes('--smoke-autofill')) runSmokeAutofill().catch(err => { console.error(err); app.exit(1); });
1378
1543
  else if (process.argv.includes('--smoke-updates')) runSmokeUpdates().catch(err => { console.error(err); app.exit(1); });
1379
1544
  else if (process.argv.includes('--smoke-services')) runSmokeServices().catch(err => { console.error(err); app.exit(1); });
1545
+ else if (process.argv.includes('--smoke-dark')) runSmokeDark().catch(err => { console.error(err); app.exit(1); });
1380
1546
  else if (process.argv.includes('--capture')) runInteractive(true);
1381
1547
  else runInteractive();
1382
1548
  });