my-provider-dash-manager 0.10.1 → 0.12.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.
Files changed (3) hide show
  1. package/README.md +16 -4
  2. package/package.json +1 -1
  3. package/src/main.js +587 -35
package/README.md CHANGED
@@ -218,7 +218,11 @@ works in the app. Anything else can be added by URL or as a plugin.
218
218
  refused, and a fill that cannot happen now says why instead of failing quietly.
219
219
  - **Re-importing updates changed passwords** instead of silently keeping the old one.
220
220
  - **Imports existing passwords** from installed Chromium browsers (Chrome, Edge, Brave, Vivaldi,
221
- Opera) or from a CSV exported by any browser, including Firefox.
221
+ Opera) **and from Firefox profiles directly** — no CSV export first. The *Import from browser*
222
+ window lists every Firefox profile that has saved logins alongside the Chromium ones. A Firefox
223
+ profile with a **master password** set, or one from Firefox 62 or earlier, is refused with a
224
+ specific message instead of importing nothing quietly; both cases point you at the CSV route. A
225
+ CSV exported by any browser still imports too.
222
226
  - Encrypted at rest with Windows DPAPI. Passwords never reach the app's own UI process.
223
227
  - **Origin-bound**: a login is tied to the account *and* the service's own domains. A password typed on
224
228
  another site reached from that tab (an OAuth hand-off, a look-alike page) is never captured as that
@@ -251,6 +255,14 @@ encrypted with DPAPI, revocable in one click, and never leave the main process.
251
255
  session, costing roughly 2 MB per open account. Dark Reader's browser extension cannot run in
252
256
  Electron (its background worker needs APIs Electron does not expose, and its content script
253
257
  would touch the page's own JavaScript), so the engine is bundled and driven directly.
258
+ - **Per-account overrides (new in 0.11.0).** Open an account's gear window and each control has a
259
+ three-way choice: *Follow the app-wide setting* (the default), *Always on for this account*, or
260
+ *Always off for this account*. An override works **in both directions** — an account can keep
261
+ blocking ads or stay dark while the app-wide toggle is off, and vice versa — and applies at once to
262
+ that account's open tab. Only turning ad-block **off** reloads that one tab, because the injected
263
+ hide-ads style can't be retracted any other way; dark mode and switching ad-block on never reload,
264
+ and no other account's tab is ever reloaded. Accounts that override anything are marked
265
+ **content: custom** on their Home card, and an account with no override behaves exactly as before.
254
266
 
255
267
  ### Extensibility
256
268
  - **Add any service by URL** — it gets its own rail row and isolated per-account sessions.
@@ -369,11 +381,11 @@ npm start # run the app
369
381
  npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
370
382
  npm run smoke:restore # sessions survive a restart (real cookies)
371
383
  npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
372
- npm run smoke:creds # import, per-account/manager/site lists, defaults, copy, fill fallback, auto-fill plan, encryption at rest (50)
384
+ npm run smoke:creds # import (CSV + direct Firefox), per-account/manager/site lists, defaults, copy, fill fallback, auto-fill plan, encryption at rest (56)
373
385
  npm run smoke:autofill # real page: form detection, fill + request ids, undo, capture, origin guard (15)
374
386
  npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
375
387
  npm run smoke:services # every built-in login page loads inside an isolated partition (42)
376
- npm run smoke:dark # Dark Reader darkens a white page, stays out of the page's own JS, re-injects after navigation (9)
388
+ npm run smoke:dark # Dark Reader darkens a white page, stays out of the page's own JS, re-injects after navigation; per-account ad-block/dark overrides (26)
377
389
  npm run capture # screenshot the running shell (--capture-out, --capture-open svc::id, --capture-js)
378
390
  npm run spike # memory benchmark
379
391
  npm run dist # build the Windows installer locally
@@ -382,7 +394,7 @@ npm start -- --print-paths # show the app name, version and data fold
382
394
  npm start -- --open vercel::personal # open an account on launch
383
395
  ```
384
396
 
385
- The suite is 156 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
397
+ The suite is 179 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
386
398
  profile.
387
399
 
388
400
  Releases are cut by pushing a `v*` tag: GitHub Actions checks the tag against `package.json`, builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-provider-dash-manager",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
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": {
package/src/main.js CHANGED
@@ -349,6 +349,123 @@ function autoFillPlan(svc, id, url, opts = {}) {
349
349
  return { kind: 'skip', reason: choices.length ? 'unresolved' : 'no-login' };
350
350
  }
351
351
 
352
+ // ---------- per-account content settings (0.11.0) ----------
353
+ // Ad-block and dark mode have ONE app-wide toggle each (Home) and were read from store.settings at
354
+ // every apply site. An account may now carry a sparse `contentOverride: { adBlock?: 'on'|'off',
355
+ // darkMode?: 'on'|'off' }`: ONLY the keys the user changed exist, so an untouched account's stored
356
+ // record is byte-identical to before (measured: the sparse shape is the only one that round-trips
357
+ // byte-identically; `{}` and null-filled shapes leave bytes behind). 'follow' is encoded as the
358
+ // ABSENT key, which is what updateContentOverride() enforces.
359
+ const CONTENT_NAMES = ['adBlock', 'darkMode'];
360
+ const CONTENT_VALUES = ['on', 'off', 'follow'];
361
+
362
+ // Modelled on autoFillPlan: pure, dependency-injected via `settings`, exported for smoke. The
363
+ // follow branch is derived from settings[name] — never a hardcoded default — so a future change to
364
+ // the app-wide default flows through every untouched account with no code change here.
365
+ function resolveContent(acc, name, settings) {
366
+ const v = acc && acc.contentOverride ? acc.contentOverride[name] : undefined;
367
+ if (v === 'on') return true;
368
+ if (v === 'off') return false;
369
+ return !!(settings || store.settings)[name];
370
+ }
371
+ // Sparse write: set the key when overridden, DELETE it on 'follow', and delete the whole object when
372
+ // it becomes empty. Nothing is written when the value is unchanged (so a no-op save touches no bytes).
373
+ function updateContentOverride(acc, name, value) {
374
+ if (!CONTENT_NAMES.includes(name)) throw new Error(`unknown content setting: ${name}`);
375
+ if (!CONTENT_VALUES.includes(value)) throw new Error(`invalid content value: ${value}`);
376
+ if (value === 'follow') {
377
+ if (acc.contentOverride) { delete acc.contentOverride[name]; if (!Object.keys(acc.contentOverride).length) delete acc.contentOverride; }
378
+ return;
379
+ }
380
+ if (!acc.contentOverride) acc.contentOverride = {};
381
+ acc.contentOverride[name] = value;
382
+ }
383
+ function contentOverrideFor(acc) {
384
+ return { adBlock: !!(acc && acc.contentOverride && acc.contentOverride.adBlock), darkMode: !!(acc && acc.contentOverride && acc.contentOverride.darkMode) };
385
+ }
386
+
387
+ // The two per-view react sites, in ONE place so the global toggle and a per-account override change
388
+ // cannot drift apart. `before`/`after` are resolved pairs; only a control whose value MOVED is acted
389
+ // on, so a tab that resolves the same is untouched (the equality bar for un-overridden accounts):
390
+ // - dark: enable/disable in that view's isolated world, no reload (measured: it restores exactly).
391
+ // - adBlock -> on: the network predicate is already live through its per-request closure, so only
392
+ // the cosmetic CSS needs re-applying.
393
+ // - adBlock -> off: injected CSS cannot be retracted (src/content.js is frozen and applyToPage
394
+ // discards its insertCSS key), so that ONE view reloads. Never any other view.
395
+ function applyContentChange(t, before, after) {
396
+ const wc = t.view.webContents;
397
+ if (!wc || wc.isDestroyed() || !before) return;
398
+ if (before.darkMode !== after.darkMode) { if (after.darkMode) dark.enable(wc); else dark.disable(wc); }
399
+ if (before.adBlock !== after.adBlock) {
400
+ if (after.adBlock) content.applyToPage(wc, { adBlock: true });
401
+ else wc.reload();
402
+ }
403
+ }
404
+
405
+ // The did-finish-load apply for ONE document. A fresh document has no dark engine yet, so "off"
406
+ // needs no disable call; the cosmetic layer is applied only when resolved on (applyToPage itself
407
+ // also skips managed dashboards, invariant 5 — unchanged).
408
+ function applyContentToFreshDocument(wc, acc) {
409
+ content.applyToPage(wc, { adBlock: resolveContent(acc, 'adBlock', store.settings) });
410
+ if (resolveContent(acc, 'darkMode', store.settings)) dark.enable(wc);
411
+ }
412
+ // Re-resolve every live tab and act only where the value moved. `snapshot` maps tab key -> resolved
413
+ // pair captured BEFORE the change; a tab with no snapshot is left alone (it was created inside this
414
+ // same change, and its own did-finish-load path already used the new values).
415
+ function reactToContentChange(vm, snapshot) {
416
+ for (const t of vm.tabs.values()) {
417
+ const acc = findAccount(t.svc, t.id);
418
+ const after = { adBlock: resolveContent(acc, 'adBlock', store.settings), darkMode: resolveContent(acc, 'darkMode', store.settings) };
419
+ applyContentChange(t, snapshot.get(accountKey(t.svc, t.id)), after);
420
+ }
421
+ }
422
+ function contentSnapshot(vm) {
423
+ const snap = new Map();
424
+ for (const [key, t] of vm.tabs) {
425
+ const acc = findAccount(t.svc, t.id);
426
+ snap.set(key, { adBlock: resolveContent(acc, 'adBlock', store.settings), darkMode: resolveContent(acc, 'darkMode', store.settings) });
427
+ }
428
+ return snap;
429
+ }
430
+
431
+ // The body of the account-content-override IPC, exported so smoke drives the REAL path (the
432
+ // setOrClearDefault precedent). Validates, writes sparse, applies live, emits once.
433
+ function setAccountContentOverride(vm, svc, id, name, value) {
434
+ const acc = findAccount(svc, id);
435
+ if (!acc) throw new Error('unknown account');
436
+ if (!CONTENT_NAMES.includes(name)) throw new Error(`unknown content setting: ${name}`);
437
+ if (!CONTENT_VALUES.includes(value)) throw new Error(`invalid content value: ${value}`);
438
+ const snapshot = contentSnapshot(vm);
439
+ updateContentOverride(acc, name, value);
440
+ store.save();
441
+ reactToContentChange(vm, snapshot);
442
+ vm.emitState();
443
+ return {
444
+ value: (acc.contentOverride && acc.contentOverride[name]) || 'follow',
445
+ override: contentOverrideFor(acc),
446
+ resolved: { adBlock: resolveContent(acc, 'adBlock', store.settings), darkMode: resolveContent(acc, 'darkMode', store.settings) },
447
+ };
448
+ }
449
+
450
+ // The app-wide toggle's body, exported for smoke the same way (set-content-setting delegates here).
451
+ // 0.11.0: capture what every live tab resolves to BEFORE the change, so the reaction can skip any
452
+ // tab whose account overrides the control (its resolved value does not move) — for an account with
453
+ // no override this is exactly today's behaviour, tab for tab.
454
+ function setGlobalContentSetting(vm, name, value) {
455
+ if (!['adBlock', 'darkMode', 'saveLogins', 'autofill', 'autoUpdate'].includes(name)) throw new Error(`unknown setting: ${name}`);
456
+ const snapshot = (name === 'darkMode' || name === 'adBlock') ? contentSnapshot(vm) : null;
457
+ store.settings[name] = !!value;
458
+ if (name === 'autoUpdate') { store.save(); updater.setAutoUpdate(!!value); vm.emitState(); return !!value; }
459
+ store.save();
460
+ if (snapshot) {
461
+ // dark: one enable/disable call per affected view — no reload. ad-block: cosmetics re-apply on,
462
+ // and only a view whose RESOLVED value fell to off reloads (injected CSS has no other undo).
463
+ reactToContentChange(vm, snapshot);
464
+ }
465
+ vm.emitState();
466
+ return store.settings[name];
467
+ }
468
+
352
469
  // ---------- view manager ----------
353
470
  class ViewManager {
354
471
  constructor() {
@@ -494,7 +611,7 @@ class ViewManager {
494
611
  ses.setPermissionRequestHandler((_wc, permission, cb) => cb(ALLOWED.has(permission)));
495
612
  ses.setPermissionCheckHandler((_wc, permission, _origin) => ALLOWED.has(permission));
496
613
  }
497
- content.attachAdBlock(ses, () => !!store.settings.adBlock); // network-level ad/tracker blocking per partition
614
+ content.attachAdBlock(ses, () => resolveContent(acc, 'adBlock', store.settings)); // per-partition network blocking, resolved PER REQUEST (the account is captured, never scanned — 0.11.0)
498
615
  if (acc && acc.proxy) this.applyProxy(view.webContents.session, acc.proxy);
499
616
  return view;
500
617
  }
@@ -533,14 +650,14 @@ class ViewManager {
533
650
  if (input.type === 'keyDown' && input.key === 'Escape' && !this.modalOpen) this.deactivate();
534
651
  });
535
652
  view.webContents.on('did-finish-load', () => {
536
- content.applyToPage(view.webContents, { adBlock: !!store.settings.adBlock });
537
- // Enable dark mode once the document's styles exist. did-navigate fires too early: the
538
- // dynamic engine finds no stylesheets to transform yet, so enabling there leaves the page
539
- // white. This fires again after every cross-origin navigation, which is exactly what the
540
- // isolated-world reset requires — the world (and Dark Reader in it) is gone by then and must
541
- // be re-injected. Same-document (pushState) routing never reaches here, so it never re-injects.
542
- if (store.settings.darkMode) dark.enable(view.webContents);
543
- // A full load can land on a different host than did-navigate reported (client-side redirects):
653
+ // Ad-block cosmetics + dark mode, resolved for THIS account (0.11.0). Enable dark mode once the
654
+ // document's styles exist: did-navigate fires too early — the dynamic engine finds no
655
+ // stylesheets to transform yet, so enabling there leaves the page white. This fires again after
656
+ // every cross-origin navigation, which is exactly what the isolated-world reset requires — the
657
+ // world (and Dark Reader in it) is gone by then and must be re-injected. Same-document
658
+ // (pushState) routing never reaches here, so it never re-injects. A fresh document never has an
659
+ // engine, so "resolved off" needs no disable call.
660
+ applyContentToFreshDocument(view.webContents, acc);
544
661
  // refresh the address strip's key chip from the URL that actually finished loading (0.10.0).
545
662
  this.emitState();
546
663
  });
@@ -733,6 +850,10 @@ class ViewManager {
733
850
  status: statusCache.get(accountKey(a.svc, a.id)) || null,
734
851
  loginCount: accountLoginSummaries(a.svc, a.id, loginList).length, // "has saved login" indicator
735
852
  noAutoFill: !!a.noAutoFill,
853
+ // 0.11.0: what each control ACTUALLY resolves to for this account, and whether the account
854
+ // overrides it — the gear window's worded state and the Home card's marker read these.
855
+ contentResolved: { adBlock: resolveContent(a, 'adBlock', store.settings), darkMode: resolveContent(a, 'darkMode', store.settings) },
856
+ contentOverridden: contentOverrideFor(a),
736
857
  })), // live registry projection for the home grid (token presence + status summaries; never raw tokens)
737
858
  groupTabs: !!store.settings.groupTabs,
738
859
  collapsed: store.settings.collapsed || [],
@@ -1277,6 +1398,130 @@ async function runSmokeCreds() {
1277
1398
  } finally {
1278
1399
  try { fs.rmSync(csv, { force: true }); } catch {}
1279
1400
  }
1401
+
1402
+ // ---------- 0.12.0: direct Firefox import ----------
1403
+ // Every fixture here is built from published test constants by scripts/firefox-fixture.js. The
1404
+ // import is NEVER pointed at the real %APPDATA%\Mozilla\Firefox profile: the profile LIST is
1405
+ // redirected with MAM_FIREFOX_ROOT and every read uses an explicit synthetic folder.
1406
+ try {
1407
+ const { build: buildFixture, KNOWN_WEB } = require('../scripts/firefox-fixture.js');
1408
+ // The suite owns the whole temp dir and asserts it holds nothing but fixture INPUTS afterwards:
1409
+ // a written decrypt is the one way this path could hand plaintext to disk.
1410
+ const ffTmp = path.join(app.getPath('temp'), `mam-ff-${process.pid}`);
1411
+ fs.rmSync(ffTmp, { recursive: true, force: true });
1412
+ fs.mkdirSync(ffTmp, { recursive: true });
1413
+ try {
1414
+ const profilesRoot = path.join(ffTmp, 'ffroot');
1415
+ const profileDir = path.join(profilesRoot, 'Profiles', 'synthetic.default-release');
1416
+ buildFixture(profileDir);
1417
+ // A second profile with only key4.db, and the UTF-16LE-BOM profiles.ini Firefox really writes.
1418
+ const key4OnlyDir = path.join(profilesRoot, 'Profiles', 'empty.default');
1419
+ fs.mkdirSync(key4OnlyDir, { recursive: true });
1420
+ fs.copyFileSync(path.join(profileDir, 'key4.db'), path.join(key4OnlyDir, 'key4.db'));
1421
+ const iniText = '[General]\r\nStartWithLastProfile=1\r\nVersion=2\r\n\r\n'
1422
+ + '[Profile0]\r\nName=synthetic\r\nIsRelative=1\r\nPath=Profiles/synthetic.default-release\r\nDefault=1\r\n\r\n'
1423
+ + '[Profile1]\r\nName=empty\r\nIsRelative=1\r\nPath=Profiles/empty.default\r\n';
1424
+ fs.writeFileSync(path.join(profilesRoot, 'profiles.ini'), Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(iniText, 'utf16le')]));
1425
+
1426
+ process.env.MAM_FIREFOX_ROOT = profilesRoot;
1427
+ let listed = [];
1428
+ try { listed = creds.listFirefoxProfiles(); } finally { delete process.env.MAM_FIREFOX_ROOT; }
1429
+
1430
+ // (1) the profile list and the synthetic round trip in one check: the UTF-16LE profiles.ini is
1431
+ // read correctly, a key4.db-only profile does NOT appear as importable, and the import recovers
1432
+ // exactly the known rows with Unicode and embedded commas intact. The blank-username row and the
1433
+ // chrome:// row are both skipped and both counted.
1434
+ const profile = { browser: 'Firefox', profile: 'synthetic.default-release', profileDir };
1435
+ const r1 = creds.importFromFirefox(profile);
1436
+ const all = creds.loadAll().filter(c => String(c.source).startsWith('Firefox:'));
1437
+ const want = new Map(KNOWN_WEB.map(k => [creds.hostOf(k.hostname), k]));
1438
+ check('firefox-synthetic-import',
1439
+ listed.length === 1 && listed[0].browser === 'Firefox'
1440
+ && listed[0].profile === 'synthetic.default-release' && listed[0].profileDir === path.resolve(profileDir)
1441
+ && r1.refused == null && r1.added === 4 && r1.updated === 0 && r1.scanned === 6
1442
+ && r1.skippedBlank === 1 && r1.skippedNonWeb === 1
1443
+ && all.length === 4
1444
+ && all.every(c => { const w = want.get(c.host); return w && w.password === c.password && w.username === c.username; })
1445
+ && all.some(c => c.password === 'p@ss"word,with,commas')
1446
+ && all.some(c => c.password === 'パスワード–✓' && c.username === 'üser@example.test')
1447
+ && all.every(c => c.source === 'Firefox: synthetic.default-release' && !!c.importedAt),
1448
+ `listed=${listed.map(p => p.profile).join(',') || 'none'} added=${r1.added} scanned=${r1.scanned} blank=${r1.skippedBlank} nonWeb=${r1.skippedNonWeb}`);
1449
+
1450
+ // (2) the frozen merge contract: a re-import with ONE changed password updates in place, keeps
1451
+ // the id, and does not add a row.
1452
+ const before = creds.loadAll().filter(c => String(c.source).startsWith('Firefox:'));
1453
+ const targetRow = before.find(c => c.username === 'alice@example.test');
1454
+ const targetId = targetRow.id;
1455
+ // Rebuild the fixture, changing only alice's password, then re-import the same profile label.
1456
+ buildFixture(profileDir, { passwordOverrides: { 'alice@example.test': 'S3cret-Beta2!' } });
1457
+ const r2 = creds.importFromFirefox(profile);
1458
+ const afterRows = creds.loadAll().filter(c => String(c.source).startsWith('Firefox:'));
1459
+ const aliceAfter = afterRows.find(c => c.username === 'alice@example.test');
1460
+ check('firefox-in-place-merge',
1461
+ r2.updated === 1 && r2.added === 0 && aliceAfter && aliceAfter.id === targetId
1462
+ && aliceAfter.password === 'S3cret-Beta2!' && afterRows.length === 4,
1463
+ `added=${r2.added} updated=${r2.updated} id-same=${!!aliceAfter && aliceAfter.id === targetId}`);
1464
+
1465
+ // (3) at rest: the raw store must not contain any fixture password (it is DPAPI-encrypted).
1466
+ const rawStore = fs.readFileSync(path.join(app.getPath('userData'), 'credentials.json'), 'utf8');
1467
+ const secrets = [...KNOWN_WEB.map(k => k.password), 'S3cret-Beta2!', 'only-password', 'not-a-web-login'];
1468
+ check('firefox-encrypted-at-rest',
1469
+ secrets.every(s => !rawStore.includes(s)) && rawStore.includes('"ct"'),
1470
+ `${secrets.length} fixture secrets absent; store is ${rawStore.length} B of ciphertext`);
1471
+
1472
+ // (4) refusal 1: an empty folder — honest error, zero rows added.
1473
+ const emptyDir = path.join(ffTmp, 'empty-profile');
1474
+ fs.mkdirSync(emptyDir, { recursive: true });
1475
+ const countBefore = creds.stats().total;
1476
+ const r3 = creds.importFromFirefox({ browser: 'Firefox', profile: 'empty-profile', profileDir: emptyDir });
1477
+ check('firefox-refused-missing-profile',
1478
+ r3.added === 0 && r3.refused === 'FIREFOX_NO_KEY4' && /key4\.db/.test(r3.note || '')
1479
+ && creds.stats().total === countBefore,
1480
+ `refused=${r3.refused} added=${r3.added} "${String(r3.note).slice(0, 70)}"`);
1481
+
1482
+ // (5) refusal 2: a flipped ciphertext byte in key4.db — honest error, zero rows added.
1483
+ const corruptDir = path.join(ffTmp, 'corrupt-profile');
1484
+ fs.mkdirSync(corruptDir, { recursive: true });
1485
+ buildFixture(corruptDir);
1486
+ {
1487
+ const { DatabaseSync } = require('node:sqlite');
1488
+ const db = new DatabaseSync(path.join(corruptDir, 'key4.db'));
1489
+ const meta = db.prepare("SELECT item2 FROM metaData WHERE id='password'").get();
1490
+ const blob = Buffer.from(meta.item2);
1491
+ blob[blob.length - 1] ^= 0xff; // one byte of the password-check ciphertext
1492
+ db.prepare("UPDATE metaData SET item2=? WHERE id='password'").run(blob);
1493
+ db.close();
1494
+ }
1495
+ const countBefore2 = creds.stats().total;
1496
+ const r4 = creds.importFromFirefox({ browser: 'Firefox', profile: 'corrupt-profile', profileDir: corruptDir });
1497
+ check('firefox-refused-corrupt-fixture',
1498
+ r4.added === 0 && r4.refused === 'FIREFOX_MASTER_PASSWORD' && /master password|damaged/.test(r4.note || '')
1499
+ && creds.stats().total === countBefore2,
1500
+ `refused=${r4.refused} added=${r4.added} "${String(r4.note).slice(0, 70)}"`);
1501
+
1502
+ // (6) non-web hosts are counted, never stored: the store holds no `chrome` host, AND the import
1503
+ // left nothing plaintext in the temp dir — every file there is a fixture input, and its only
1504
+ // output is the DPAPI-encrypted store.
1505
+ const storedRows = creds.loadAll().filter(c => String(c.source).startsWith('Firefox:'));
1506
+ const leftFiles = [];
1507
+ const walk = dir => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1508
+ const f = path.join(dir, e.name);
1509
+ if (e.isDirectory()) walk(f); else leftFiles.push(path.relative(ffTmp, f));
1510
+ } };
1511
+ walk(ffTmp);
1512
+ const nonFixture = leftFiles.filter(f => !/(^|[\\/])(key4\.db|logins\.json|profiles\.ini)$/.test(f));
1513
+ check('firefox-skips-non-web-hosts',
1514
+ r1.skippedNonWeb === 1
1515
+ && !storedRows.some(c => c.host === 'chrome' || c.host === 'chrome://settings')
1516
+ && !creds.listCredentials().some(c => c.host === 'chrome')
1517
+ && storedRows.every(c => /^[a-z0-9.-]+$/.test(c.host) && !c.host.includes(':'))
1518
+ && nonFixture.length === 0,
1519
+ `skippedNonWeb=${r1.skippedNonWeb}; hosts=${storedRows.map(c => c.host).join(',')}; temp=${leftFiles.length} files all fixture inputs${nonFixture.length ? ` (unexpected: ${nonFixture.join(',')})` : ''}`);
1520
+ } finally { fs.rmSync(ffTmp, { recursive: true, force: true }); }
1521
+ } catch (err) {
1522
+ check('exception:firefox', false, err.message);
1523
+ }
1524
+
1280
1525
  fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
1281
1526
  app.exit(results.checks.every(c => c.ok) ? 0 : 1);
1282
1527
  }
@@ -1475,6 +1720,245 @@ async function runSmokeDark() {
1475
1720
  const bgNav = await bgOf();
1476
1721
  check('re-darkens-after-cross-origin-navigation', !!bgNav && !/255,\s*255,\s*255/.test(bgNav), bgNav);
1477
1722
 
1723
+ // ================= 0.11.0: PER-ACCOUNT CONTENT SETTINGS =================
1724
+ // The nine checks above are the pre-0.11.0 goldens and stay untouched (no assertion edited). This
1725
+ // section is additive: dark overrides (5), ad-block (6 — the first ad-block coverage this suite
1726
+ // has ever had), then the data/API contracts (4).
1727
+ //
1728
+ // Every check runs offline: the dark checks use two local white pages, and the ad-block checks use
1729
+ // a seed-listed ad host that CANNOT resolve. With the blocker on the request dies as
1730
+ // net::ERR_BLOCKED_BY_CLIENT (-20); with it off the same request dies as net::ERR_NAME_NOT_RESOLVED
1731
+ // (-105) — measured on this machine against the repo's real src/content.js before this was written.
1732
+ // The two codes are therefore a real, network-free witness of the network-layer decision.
1733
+ const isDark = bg => !!bg && bg !== '' && !/255,\s*255,\s*255/.test(bg);
1734
+ const isWhite = bg => /255,\s*255,\s*255/.test(bg || '');
1735
+
1736
+ // A fresh account + a fresh white document, loaded through the REAL create path so the real
1737
+ // did-finish-load apply (and the real per-request closure in makeView) is what gets measured.
1738
+ const mk = async (id, url) => {
1739
+ vm.create('vercel', id, url, { show: false });
1740
+ const t = vm.tabs.get(accountKey('vercel', id));
1741
+ // create() calls loadURL without waiting, so either the load is still in flight or it already
1742
+ // finished (a cached file:// page) — never wait for an event that has already fired.
1743
+ if (t.view.webContents.isLoading()) await new Promise(r => t.view.webContents.once('did-finish-load', r));
1744
+ return t;
1745
+ };
1746
+ const bgOfTab = async t => {
1747
+ try { return await t.view.webContents.executeJavaScript(`getComputedStyle(document.documentElement).backgroundColor`); } catch { return ''; }
1748
+ };
1749
+ const engineOf = async t => {
1750
+ try { return await t.view.webContents.executeJavaScriptInIsolatedWorld(dark.DARK_WORLD_ID, [{ code: 'typeof window.DarkReader' }]); } catch { return undefined; }
1751
+ };
1752
+
1753
+ // Reset the registry and the global settings this section depends on, then build a control view
1754
+ // with NO overrides at all (the equality baseline every per-account assertion compares against).
1755
+ store.accounts = store.accounts.filter(a => a.svc !== 'vercel' || !/^x-/.test(a.id));
1756
+ store.settings.darkMode = true; store.settings.adBlock = true;
1757
+ const control = await mk('x-control', toUrl(p1));
1758
+ await sleep(3200);
1759
+ const controlDark = await bgOfTab(control);
1760
+ const controlEngine = await engineOf(control);
1761
+
1762
+ // 1. global ON, A override off, B follows -> A white, B dark.
1763
+ registerAccount('vercel', 'x-a');
1764
+ updateContentOverride(findAccount('vercel', 'x-a'), 'darkMode', 'off');
1765
+ const aOff = await mk('x-a', toUrl(p1));
1766
+ const bFollowOn = await mk('x-b', toUrl(p1));
1767
+ await sleep(3200);
1768
+ const aOffBg = await bgOfTab(aOff); const bOnBg = await bgOfTab(bFollowOn);
1769
+ check('per-account-dark-override-off-while-global-on', isWhite(aOffBg) && isDark(bOnBg), `A=${aOffBg} B=${bOnBg}`);
1770
+
1771
+ // 2. global OFF, A override on, B follows -> A dark, B white (Q2: true override, both directions).
1772
+ store.settings.darkMode = false;
1773
+ const cOff = await mk('x-c', toUrl(p1));
1774
+ registerAccount('vercel', 'x-d');
1775
+ updateContentOverride(findAccount('vercel', 'x-d'), 'darkMode', 'on');
1776
+ const dOn = await mk('x-d', toUrl(p1));
1777
+ await sleep(3200);
1778
+ const cOffBg = await bgOfTab(cOff); const dOnBg = await bgOfTab(dOn);
1779
+ check('per-account-dark-override-on-while-global-off', isDark(dOnBg) && isWhite(cOffBg), `C(global-off)=${cOffBg} D(override-on)=${dOnBg}`);
1780
+
1781
+ // 3. follow-global is byte-for-byte the control view: same computed colour AND same engine presence.
1782
+ store.settings.darkMode = true;
1783
+ const follow = await mk('x-e', toUrl(p1));
1784
+ await sleep(3200);
1785
+ const followBg = await bgOfTab(follow); const followEngine = await engineOf(follow);
1786
+ check('per-account-dark-follow-global',
1787
+ followBg === controlDark && followEngine === controlEngine && followEngine === 'object',
1788
+ `follow=${followBg}/${followEngine} control=${controlDark}/${controlEngine}`);
1789
+
1790
+ // 4. override change applies to the OPEN document with no reload: the h1 keeps an in-page marker
1791
+ // (a reload would drop it), while the colour returns to white. B (untouched) keeps its colour.
1792
+ await follow.view.webContents.executeJavaScript(`document.querySelector('h1').setAttribute('data-marker','kept')`).catch(() => {});
1793
+ const bBefore4 = await bgOfTab(bFollowOn);
1794
+ setAccountContentOverride(vm, 'vercel', 'x-e', 'darkMode', 'off');
1795
+ await sleep(1800);
1796
+ const marker = await follow.view.webContents.executeJavaScript(`document.querySelector('h1') && document.querySelector('h1').getAttribute('data-marker')`).catch(() => null);
1797
+ const followBg4 = await bgOfTab(follow); const bAfter4 = await bgOfTab(bFollowOn);
1798
+ check('per-account-override-change-applies-without-reload',
1799
+ followBg4 !== '' && isWhite(followBg4) && marker === 'kept' && bAfter4 === bBefore4,
1800
+ `marker=${marker} bg=${followBg4} B before/after=${bBefore4}/${bAfter4}`);
1801
+
1802
+ // 5. an untouched account is unchanged by a sibling's override: compare a following tab with the
1803
+ // control view created before ANY override existed.
1804
+ const untouched = await mk('x-f', toUrl(p1));
1805
+ await sleep(3200);
1806
+ const untouchedBg = await bgOfTab(untouched); const untouchedEngine = await engineOf(untouched);
1807
+ check('per-account-dark-untouched-account-unchanged',
1808
+ untouchedBg === controlDark && untouchedEngine === controlEngine,
1809
+ `untouched=${untouchedBg}/${untouchedEngine} control=${controlDark}/${controlEngine}`);
1810
+
1811
+ // ---------- ad-block: the offline ERR_BLOCKED_BY_CLIENT / ERR_NAME_NOT_RESOLVED discriminator ----------
1812
+ // A seed-listed ad host whose SUBDOMAIN cannot resolve. hostBlocked() walks parent domains, so it
1813
+ // is blocked by the predicate, while DNS guarantees the "not blocked" answer is a different code.
1814
+ const ADHOST = 'mam011-smoke-no-such-host.doubleclick.net';
1815
+ const frameCode = async t => {
1816
+ const wc = t.view.webContents;
1817
+ const got = await new Promise(resolve => {
1818
+ const seen = [];
1819
+ const onFail = (_e, code, desc, _vurl, isMainFrame) => { if (!isMainFrame) seen.push(desc); };
1820
+ wc.on('did-fail-load', onFail);
1821
+ wc.executeJavaScript(`(() => { const old = document.getElementById('mam-ad-probe'); if (old) old.remove();
1822
+ const f = document.createElement('iframe'); f.id = 'mam-ad-probe';
1823
+ f.src = 'https://${ADHOST}/ad.html'; document.body.appendChild(f); return f.src; })()`).catch(() => {});
1824
+ setTimeout(() => { wc.removeListener('did-fail-load', onFail); resolve(seen); }, 6000);
1825
+ });
1826
+ return got[0] || 'none';
1827
+ };
1828
+ // A fresh document is what makes the check independent of earlier iframes: reload once, then probe.
1829
+ const reloadAndProbe = async t => {
1830
+ const wc = t.view.webContents;
1831
+ await new Promise(r => { wc.once('did-finish-load', r); wc.reload(); });
1832
+ await sleep(600);
1833
+ return frameCode(t);
1834
+ };
1835
+
1836
+ // 6. global ON, A override off, B follows -> A is NOT blocked, B IS blocked.
1837
+ store.settings.adBlock = true;
1838
+ registerAccount('vercel', 'x-g');
1839
+ updateContentOverride(findAccount('vercel', 'x-g'), 'adBlock', 'off');
1840
+ const gOff = await mk('x-g', toUrl(p1));
1841
+ await sleep(800);
1842
+ const gCode = await reloadAndProbe(gOff);
1843
+ const bCode = await reloadAndProbe(bFollowOn);
1844
+ check('per-account-adblock-override-off-while-global-on',
1845
+ gCode === 'ERR_NAME_NOT_RESOLVED' && bCode === 'ERR_BLOCKED_BY_CLIENT', `G(override-off)=${gCode} B(follow)=${bCode}`);
1846
+
1847
+ // 7. global OFF, D override on (D already exists with a dark override), E follows -> D blocked, C not.
1848
+ store.settings.adBlock = false;
1849
+ updateContentOverride(findAccount('vercel', 'x-d'), 'adBlock', 'on');
1850
+ const dCode = await reloadAndProbe(dOn);
1851
+ const cCode2 = await reloadAndProbe(cOff);
1852
+ check('per-account-adblock-override-on-while-global-off',
1853
+ dCode === 'ERR_BLOCKED_BY_CLIENT' && cCode2 === 'ERR_NAME_NOT_RESOLVED', `D(override-on)=${dCode} C(global-off)=${cCode2}`);
1854
+
1855
+ // 8. follow-global: an un-overridden account produces exactly the code the global dictates. With
1856
+ // the global back on, that is BLOCKED — the same answer the control view would give.
1857
+ store.settings.adBlock = true;
1858
+ const fCode = await reloadAndProbe(follow); // x-e has only a darkMode override: adBlock follows
1859
+ check('per-account-adblock-follow-global', fCode === 'ERR_BLOCKED_BY_CLIENT', `E(follow, global on)=${fCode}`);
1860
+
1861
+ // 9. a LIVE flip changes the next request's outcome with no reload: x-g is overridden off; clearing
1862
+ // the override (it follows the on global) blocks the SAME host, in the SAME document. A page
1863
+ // marker proves the document survived — a URL comparison alone would NOT, because a reload keeps
1864
+ // the URL (mutation 4 caught exactly that: it reloaded on the on-flip and still passed).
1865
+ await gOff.view.webContents.executeJavaScript(`window.__mamFlipMark = 'alive'`).catch(() => {});
1866
+ const urlBefore9 = gOff.view.webContents.getURL();
1867
+ setAccountContentOverride(vm, 'vercel', 'x-g', 'adBlock', 'follow');
1868
+ await sleep(400);
1869
+ const gCodeLive = await frameCode(gOff);
1870
+ const urlAfter9 = gOff.view.webContents.getURL();
1871
+ const flipMark = await gOff.view.webContents.executeJavaScript(`window.__mamFlipMark || 'gone'`).catch(() => 'gone');
1872
+ check('adblock-live-flip-no-reload',
1873
+ gCodeLive === 'ERR_BLOCKED_BY_CLIENT' && urlBefore9 === urlAfter9 && flipMark === 'alive',
1874
+ `after=${gCodeLive} url ${urlBefore9 === urlAfter9 ? 'unchanged' : 'CHANGED'} document=${flipMark}`);
1875
+
1876
+ // 10. cosmetic per account: applyToPage hides the ad slot for an on-account and leaves it for an
1877
+ // off-account. The seed list carries no generic cosmetics (they come from a fetched EasyList),
1878
+ // so the check installs ONE known selector into the live filter set for the duration of the
1879
+ // assertion and restores it in a finally — the module's bytes are untouched (it is frozen), and
1880
+ // the check then measures the real did-finish-load apply for two accounts that resolve
1881
+ // differently. `display:none` vs `display:block` is the observable.
1882
+ const cosFile = path.join(app.getPath('temp'), 'mam-content-cosmetic.html');
1883
+ fs.writeFileSync(cosFile, '<!doctype html><html><head><style>#ad-slot{display:block;height:10px;width:10px}</style></head><body><div id="ad-slot"></div><h1>probe</h1></body></html>');
1884
+ const savedCosmetic = content.filters.cosmetic;
1885
+ content.filters.cosmetic = ['#ad-slot'];
1886
+ let cosOn = ''; let cosOff = ''; let cosOffTab = null;
1887
+ try {
1888
+ const onT = await mk('x-h', toUrl(cosFile));
1889
+ await sleep(900);
1890
+ cosOn = await onT.view.webContents.executeJavaScript(`getComputedStyle(document.getElementById('ad-slot')).display`).catch(() => '');
1891
+ registerAccount('vercel', 'x-i');
1892
+ updateContentOverride(findAccount('vercel', 'x-i'), 'adBlock', 'off');
1893
+ const offT = await mk('x-i', toUrl(cosFile));
1894
+ await sleep(900);
1895
+ cosOff = await offT.view.webContents.executeJavaScript(`getComputedStyle(document.getElementById('ad-slot')).display`).catch(() => '');
1896
+ cosOffTab = offT; // used by check 11 below
1897
+ } finally { content.filters.cosmetic = savedCosmetic; }
1898
+ check('adblock-cosmetic-per-account', cosOn === 'none' && cosOff === 'block',
1899
+ `following(adBlock on)=${cosOn} overridden-off=${cosOff}`);
1900
+
1901
+ // 11. a reload caused by ANOTHER account's ad-block change never reaches this account's document.
1902
+ // Toggling the GLOBAL off and back on makes every FOLLOWING account's resolved value move
1903
+ // (off, then on: the "on" half re-applies cosmetics, the "off" half reloads), while x-i is
1904
+ // overridden off and resolves `false` at both points — so it must not reload. A page-global
1905
+ // marker survives a non-reload and dies on a reload.
1906
+ const offTab = cosOffTab;
1907
+ await gOff.view.webContents.executeJavaScript(`window.__mamMark = 'alive'`).catch(() => {});
1908
+ await offTab.view.webContents.executeJavaScript(`window.__mamMark = 'alive'`).catch(() => {});
1909
+ setGlobalContentSetting(vm, 'adBlock', false);
1910
+ await sleep(500);
1911
+ setGlobalContentSetting(vm, 'adBlock', true);
1912
+ await sleep(2000);
1913
+ const gMark = await gOff.view.webContents.executeJavaScript(`window.__mamMark || 'gone'`).catch(() => 'gone');
1914
+ const iMark = await offTab.view.webContents.executeJavaScript(`window.__mamMark || 'gone'`).catch(() => 'gone');
1915
+ check('adblock-off-account-unaffected-by-other-accounts-reload', gMark === 'gone' && iMark === 'alive',
1916
+ `following(${gMark}) overridden-off(${iMark})`);
1917
+
1918
+ // ---------- data / API contracts ----------
1919
+ // 12. sparse storage: setting one key never creates the other, and clearing deletes the key.
1920
+ registerAccount('vercel', 'x-j');
1921
+ setAccountContentOverride(vm, 'vercel', 'x-j', 'adBlock', 'off');
1922
+ const jAcc = findAccount('vercel', 'x-j');
1923
+ check('content-override-stored-sparse',
1924
+ JSON.stringify(jAcc.contentOverride) === JSON.stringify({ adBlock: 'off' }),
1925
+ JSON.stringify(jAcc.contentOverride));
1926
+
1927
+ // 13. the owner's bar, mechanically: set then clear leaves accounts.json byte-identical.
1928
+ const accFile = path.join(app.getPath('userData'), 'accounts.json');
1929
+ registerAccount('vercel', 'x-k');
1930
+ const before13 = fs.readFileSync(accFile);
1931
+ setAccountContentOverride(vm, 'vercel', 'x-k', 'darkMode', 'on');
1932
+ setAccountContentOverride(vm, 'vercel', 'x-k', 'darkMode', 'follow');
1933
+ const after13 = fs.readFileSync(accFile);
1934
+ check('content-override-clear-is-byte-identical', before13.equals(after13),
1935
+ `${before13.length} vs ${after13.length} bytes; key present after clear: ${'contentOverride' in findAccount('vercel', 'x-k')}`);
1936
+
1937
+ // 14. the IPC rejects an unknown name and an unknown account (mirrors account-no-autofill).
1938
+ const throws = fn => { try { fn(); return false; } catch { return true; } };
1939
+ check('content-override-rejects-unknown-name', throws(() => setAccountContentOverride(vm, 'vercel', 'x-j', 'network', 'on')));
1940
+ check('content-override-rejects-unknown-account', throws(() => setAccountContentOverride(vm, 'vercel', 'nobody', 'adBlock', 'on')));
1941
+ // 15. only on / off / follow are accepted — and nothing is written when the value is refused.
1942
+ const jBefore = JSON.stringify(findAccount('vercel', 'x-j').contentOverride);
1943
+ check('content-override-invalid-value-rejected',
1944
+ throws(() => setAccountContentOverride(vm, 'vercel', 'x-j', 'adBlock', 'yes'))
1945
+ && JSON.stringify(findAccount('vercel', 'x-j').contentOverride) === jBefore,
1946
+ `stored=${jBefore}`);
1947
+
1948
+ // ---------- the resolver contract (MUTATION-PLUS) ----------
1949
+ // The follow path must be derived from the GLOBAL, never a hardcoded literal. The spec's own
1950
+ // formula is `!!settings[name]`, so the returned value is the global's truthiness — not the raw
1951
+ // value (a string fake therefore reads `true`, and asserting raw identity would be asserting
1952
+ // against the contract). What a literal default cannot do is FOLLOW the global in both directions:
1953
+ // a resolver hardcoded to true fails the first half, one hardcoded to false fails the second.
1954
+ const followOn = resolveContent({}, 'adBlock', { adBlock: true });
1955
+ const followOff = resolveContent({ contentOverride: {} }, 'adBlock', { adBlock: false });
1956
+ const followUnset = resolveContent({}, 'darkMode', {});
1957
+ const followOverridden = resolveContent({ contentOverride: { adBlock: 'on' } }, 'adBlock', { adBlock: false });
1958
+ check('resolver-follow-tracks-the-global-not-a-literal',
1959
+ followOn === true && followOff === false && followUnset === false && followOverridden === true,
1960
+ `global true->${followOn}, global false->${followOff}, unset->${followUnset}, override-on-over-global-off->${followOverridden}`);
1961
+
1478
1962
  fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
1479
1963
  app.exit(results.checks.every(c => c.ok) ? 0 : 1);
1480
1964
  }
@@ -1634,6 +2118,72 @@ function runInteractive(captureMode = false) {
1634
2118
  console.log('[capture] demo notice:', plan.kind, (plan.choices || []).length);
1635
2119
  await new Promise(r => setTimeout(r, 700));
1636
2120
  }
2121
+ // --demo-home-marker: give two demo accounts one override each through the REAL path, so the
2122
+ // Home grid shows the "content: custom" marker the way a user would see it (0.11.0).
2123
+ if (process.argv.includes('--demo-home-marker')) {
2124
+ try {
2125
+ if (findAccount('vercel', 'work')) setAccountContentOverride(vm, 'vercel', 'work', 'darkMode', 'off');
2126
+ if (findAccount('netlify', 'agency')) setAccountContentOverride(vm, 'netlify', 'agency', 'adBlock', 'on');
2127
+ } catch (err) { console.error('[demo] home marker failed:', err.message); }
2128
+ vm.emitState();
2129
+ await new Promise(r => setTimeout(r, 800));
2130
+ const info = await win.webContents.executeJavaScript(`JSON.stringify([...document.querySelectorAll('.card .contentcustom')].map(m => {
2131
+ const r = m.getBoundingClientRect();
2132
+ return m.closest('.card').querySelector('.label').innerText + ' :: ' + m.innerText + ' @' + Math.round(r.x) + ',' + Math.round(r.y);
2133
+ }))`).catch(err => 'probe failed: ' + err.message);
2134
+ console.log('[capture] home markers:', info);
2135
+ await new Promise(r => setTimeout(r, 300));
2136
+ }
2137
+ // --demo-import: open the Import-from-browser window through the RENDERER's own click
2138
+ // handler, so the screenshot shows the real modal with whatever profile rows exist. Point
2139
+ // MAM_FIREFOX_ROOT at a synthetic root first and the Firefox row is the fixture, never the
2140
+ // owner's live profile. Screenshot aid for a scratch demo profile, like --demo-picker.
2141
+ if (process.argv.includes('--demo-import')) {
2142
+ await new Promise(r => setTimeout(r, 600));
2143
+ if (win.webContents && !win.webContents.isDestroyed()) {
2144
+ await win.webContents.executeJavaScript('document.getElementById("imp-browser").click()').catch(err => console.error('[capture] import click failed:', err.message));
2145
+ }
2146
+ await new Promise(r => setTimeout(r, 1200));
2147
+ const info = await win.webContents.executeJavaScript(`JSON.stringify({
2148
+ modalOpen: !document.getElementById('modal-back').hidden,
2149
+ rows: [...document.querySelectorAll('#modal [data-imp]')].map(b => b.innerText.trim()),
2150
+ note: (() => { const n = [...document.querySelectorAll('#modal .note')].find(x => /master password/.test(x.textContent)); return n ? n.textContent.trim().slice(0, 90) : ''; })(),
2151
+ })`).catch(err => 'probe failed: ' + err.message);
2152
+ console.log('[capture] demo import:', info);
2153
+ }
2154
+ // --demo-content: give the account in --capture-open one override through the REAL path, then
2155
+ // open its gear window from the RENDERER, so the screenshot shows the real three-way selects,
2156
+ // the worded resolved state and the honest note (0.11.0). Drives state from main, never from
2157
+ // --capture-js (which intermittently exits 127 on this machine). Scratch demo profile only.
2158
+ if (process.argv.includes('--demo-content') && openKey) {
2159
+ const [dsvc, did] = openKey.split('::');
2160
+ try { setAccountContentOverride(vm, dsvc, did, 'darkMode', 'off'); } catch (err) { console.error('[demo] content override failed:', err.message); }
2161
+ vm.emitState();
2162
+ await new Promise(r => setTimeout(r, 500));
2163
+ if (win.webContents && !win.webContents.isDestroyed()) {
2164
+ await win.webContents.executeJavaScript(`document.querySelector('[data-gear="${openKey}"]') && document.querySelector('[data-gear="${openKey}"]').click()`).catch(err => console.error('[demo] gear click failed:', err.message));
2165
+ }
2166
+ await new Promise(r => setTimeout(r, 900));
2167
+ const info = await win.webContents.executeJavaScript(`JSON.stringify({
2168
+ modalOpen: !document.getElementById('modal-back').hidden,
2169
+ selects: [...document.querySelectorAll('#modal select')].map(s => ({ id: s.id, value: s.value, options: s.options.length })),
2170
+ states: [...document.querySelectorAll('#modal .contentctl .note')].map(n => n.textContent.trim()),
2171
+ note: (() => { const ns = [...document.querySelectorAll('#modal .note')]; const n = ns.find(x => /both directions/.test(x.textContent)); return n ? n.textContent.trim().slice(0, 120) : ''; })(),
2172
+ })`).catch(err => 'probe failed: ' + err.message);
2173
+ console.log('[capture] demo content:', info);
2174
+ // drive one select through the REAL renderer → preload → main chain and read the store back
2175
+ const realPath = await win.webContents.executeJavaScript(`(async () => {
2176
+ const sel = document.getElementById('m-content-adBlock');
2177
+ const before = sel.value;
2178
+ sel.value = 'off'; sel.dispatchEvent(new Event('change', { bubbles: true }));
2179
+ await new Promise(r => setTimeout(r, 900));
2180
+ return JSON.stringify({ before, after: sel.value, stateLine: document.getElementById('m-content-adBlock-state').textContent.trim() });
2181
+ })()`).catch(err => 'real-path probe failed: ' + err.message);
2182
+ console.log('[capture] demo content real path:', realPath, 'stored:', JSON.stringify((findAccount(openKey.split('::')[0], openKey.split('::')[1]) || {}).contentOverride));
2183
+ // scroll the modal to its end so the honest note under the selects is in frame
2184
+ await win.webContents.executeJavaScript(`(() => { const m = document.getElementById('modal'); if (m) m.scrollTop = m.scrollHeight; return m ? m.scrollTop : -1; })()`).catch(() => {});
2185
+ await new Promise(r => setTimeout(r, 400));
2186
+ }
1637
2187
  // --demo-picker: open the address-strip key chip's picker from MAIN (state → emitState → the
1638
2188
  // renderer's own click handler), so a screenshot shows the real popover and not hand-written
1639
2189
  // DOM. The chip is drawn from state.siteLogins, so one emitState is enough. Verification aid
@@ -2005,7 +2555,13 @@ function runInteractive(captureMode = false) {
2005
2555
  return { ok: false, reason: result.reason, message: messages[result.reason] || 'This page has no visible password field to fill.' };
2006
2556
  });
2007
2557
  ipcMain.handle('creds-stats', () => creds.stats());
2008
- ipcMain.handle('creds-browsers', () => creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData })));
2558
+ // One list, two kinds: a Chromium profile and a Firefox profile are both "a browser profile with
2559
+ // saved logins", so the existing Import-from-browser window renders them side by side with no new
2560
+ // surface. `kind` is what tells the renderer which import handler to call.
2561
+ ipcMain.handle('creds-browsers', () => [
2562
+ ...creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData, kind: 'chromium' })),
2563
+ ...creds.listFirefoxProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.profileDir, kind: 'firefox' })),
2564
+ ]);
2009
2565
  ipcMain.handle('creds-import-browser', async (_e, id) => {
2010
2566
  const p = creds.listChromiumProfiles().find(x => x.loginData === id);
2011
2567
  if (!p) throw new Error('Browser profile not found');
@@ -2013,6 +2569,16 @@ function runInteractive(captureMode = false) {
2013
2569
  vm.emitState();
2014
2570
  return r;
2015
2571
  });
2572
+ // Firefox: refuses a master-password-protected or old-format profile with a specific message and
2573
+ // imports nothing. A refusal is a normal result here, not an exception, so the window can show it.
2574
+ ipcMain.handle('creds-import-firefox', (_e, profileDir) => {
2575
+ const p = creds.listFirefoxProfiles().find(x => x.profileDir === profileDir)
2576
+ || (profileDir ? { browser: 'Firefox', profile: path.basename(String(profileDir)), profileDir } : null);
2577
+ if (!p) throw new Error('Firefox profile not found');
2578
+ const r = creds.importFromFirefox(p);
2579
+ vm.emitState();
2580
+ return r;
2581
+ });
2016
2582
  ipcMain.handle('creds-import-csv', async () => {
2017
2583
  const { dialog } = require('electron');
2018
2584
  const r = await dialog.showOpenDialog({ title: 'Import passwords from CSV', filters: [{ name: 'CSV', extensions: ['csv'] }], properties: ['openFile'] });
@@ -2071,6 +2637,14 @@ function runInteractive(captureMode = false) {
2071
2637
  vm.emitState();
2072
2638
  return acc.noAutoFill;
2073
2639
  });
2640
+ // Per-account content override (0.11.0): 'follow' | 'on' | 'off' per control, stored SPARSELY on
2641
+ // the account record (see updateContentOverride). 'follow' deletes the key, so clearing restores
2642
+ // the account's stored bytes. An override is a TRUE override in both directions (owner decision
2643
+ // Q2): it may turn a control on while the app-wide toggle is off, and vice versa. The change is
2644
+ // applied to that account's live tab immediately; every other tab is re-resolved and acted on only
2645
+ // if ITS value actually moved (an untouched account is never reloaded or re-engaged).
2646
+ ipcMain.handle('account-content-override', (_e, svc, id, name, value) =>
2647
+ setAccountContentOverride(vm, svc, id, name, value));
2074
2648
  // Dismiss the per-tab notice (banner X).
2075
2649
  ipcMain.handle('dismiss-notice', (_e, key) => { vm.setNotice(key, null); return true; });
2076
2650
  // Undo an auto-fill: the preload clears exactly what it filled and answers with what it cleared.
@@ -2173,31 +2747,7 @@ function runInteractive(captureMode = false) {
2173
2747
  vm.emitState();
2174
2748
  return true;
2175
2749
  });
2176
- ipcMain.handle('set-content-setting', (_e, name, value) => {
2177
- if (!['adBlock', 'darkMode', 'saveLogins', 'autofill', 'autoUpdate'].includes(name)) throw new Error(`unknown setting: ${name}`);
2178
- store.settings[name] = !!value;
2179
- if (name === 'autoUpdate') { store.save(); updater.setAutoUpdate(!!value); vm.emitState(); return !!value; }
2180
- store.save();
2181
- if (name === 'darkMode') {
2182
- // The engine runs in each view's isolated world, so a flip is one enable/disable call per
2183
- // live tab — no reload. The previous extension path forced a reload of every open tab, which
2184
- // threw away scroll position and anything typed into a half-filled form.
2185
- const on = !!value;
2186
- for (const t of vm.tabs.values()) {
2187
- if (on) dark.enable(t.view.webContents);
2188
- else dark.disable(t.view.webContents);
2189
- }
2190
- } else if (name === 'adBlock') {
2191
- // the network layer flips live through its closure; cosmetic CSS needs a re-apply on, and a
2192
- // reload off (injected CSS can only be undone by reloading)
2193
- for (const t of vm.tabs.values()) {
2194
- if (value) content.applyToPage(t.view.webContents, { adBlock: true });
2195
- else t.view.webContents.reload();
2196
- }
2197
- }
2198
- vm.emitState();
2199
- return store.settings[name];
2200
- });
2750
+ ipcMain.handle('set-content-setting', (_e, name, value) => setGlobalContentSetting(vm, name, value));
2201
2751
  ipcMain.handle('content-stats', () => ({
2202
2752
  blocked: content.stats.blocked,
2203
2753
  listSource: content.filters.loadedFrom,
@@ -2291,6 +2841,8 @@ function runInteractive(captureMode = false) {
2291
2841
  module.exports = {
2292
2842
  accountLoginSummaries, managerRows, setOrClearDefault, resolveAmong, fillCandidatesFor, autoFillPlan,
2293
2843
  originAllowed, accountKey,
2844
+ resolveContent, updateContentOverride, contentOverrideFor,
2845
+ setAccountContentOverride, setGlobalContentSetting, applyContentToFreshDocument, reactToContentChange, contentSnapshot,
2294
2846
  _creds: creds,
2295
2847
  };
2296
2848