my-provider-dash-manager 0.10.0 → 0.11.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 +10 -2
  2. package/package.json +1 -1
  3. package/src/main.js +455 -41
package/README.md CHANGED
@@ -251,6 +251,14 @@ encrypted with DPAPI, revocable in one click, and never leave the main process.
251
251
  session, costing roughly 2 MB per open account. Dark Reader's browser extension cannot run in
252
252
  Electron (its background worker needs APIs Electron does not expose, and its content script
253
253
  would touch the page's own JavaScript), so the engine is bundled and driven directly.
254
+ - **Per-account overrides (new in 0.11.0).** Open an account's gear window and each control has a
255
+ three-way choice: *Follow the app-wide setting* (the default), *Always on for this account*, or
256
+ *Always off for this account*. An override works **in both directions** — an account can keep
257
+ blocking ads or stay dark while the app-wide toggle is off, and vice versa — and applies at once to
258
+ that account's open tab. Only turning ad-block **off** reloads that one tab, because the injected
259
+ hide-ads style can't be retracted any other way; dark mode and switching ad-block on never reload,
260
+ and no other account's tab is ever reloaded. Accounts that override anything are marked
261
+ **content: custom** on their Home card, and an account with no override behaves exactly as before.
254
262
 
255
263
  ### Extensibility
256
264
  - **Add any service by URL** — it gets its own rail row and isolated per-account sessions.
@@ -373,7 +381,7 @@ npm run smoke:creds # import, per-account/manager/site lists, defaults, copy,
373
381
  npm run smoke:autofill # real page: form detection, fill + request ids, undo, capture, origin guard (15)
374
382
  npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
375
383
  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)
384
+ 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
385
  npm run capture # screenshot the running shell (--capture-out, --capture-open svc::id, --capture-js)
378
386
  npm run spike # memory benchmark
379
387
  npm run dist # build the Windows installer locally
@@ -382,7 +390,7 @@ npm start -- --print-paths # show the app name, version and data fold
382
390
  npm start -- --open vercel::personal # open an account on launch
383
391
  ```
384
392
 
385
- The suite is 156 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
393
+ The suite is 173 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
386
394
  profile.
387
395
 
388
396
  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.0",
3
+ "version": "0.11.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 || [],
@@ -1218,16 +1339,35 @@ async function runSmokeCreds() {
1218
1339
  const sibling = copyCredentialField(otherAcct.id, 'password', 'vercel', 'team-a', 'https://vercel.com/login');
1219
1340
  check('copy-refused-another-accounts-capture', sibling.ok === false && sibling.reason === 'other-account', `reason=${sibling.reason}`);
1220
1341
  clipboard.clear();
1221
- // the real copy path: main writes the clipboard, and the answer is a label — never the secret
1342
+ // WARM-UP WRITE (required): on this machine the first writeText after clear() in a fresh
1343
+ // process does not yet round-trip through readText — this sentinel is what makes the very
1344
+ // first read-back reliable at all (proved 2026-11-03: removing it made every copy check fail).
1345
+ // A cold machine (the day's first run) can still be slow even with the warm-up, hence the poll.
1222
1346
  clipboard.writeText('sentinel-before-copy');
1347
+ // the real copy path: main writes the clipboard, and the answer is a label — never the secret.
1348
+ // A cold machine can take a moment before the Win32 clipboard returns what was just written
1349
+ // (observed 2026-11-03: the day's first suite run read back stale values; every rerun passed),
1350
+ // so poll the read-back and retry the write once — a genuine write regression still fails hard.
1351
+ const clipboardSettles = async (doWrite, expected) => {
1352
+ let last = null;
1353
+ for (let attempt = 0; attempt < 2; attempt++) {
1354
+ last = doWrite();
1355
+ const deadline = Date.now() + 2500;
1356
+ while (Date.now() < deadline) {
1357
+ if (clipboard.readText() === expected) return { last, text: clipboard.readText() };
1358
+ await new Promise(r => setTimeout(r, 50));
1359
+ }
1360
+ }
1361
+ return { last, text: clipboard.readText() };
1362
+ };
1223
1363
  const sharedRow = siteA.rows.find(r => r.username === 'shared@example.com');
1224
- const copied = copyCredentialField(sharedRow.id, 'password', 'vercel', 'team-a', 'https://vercel.com/login');
1364
+ const pw = await clipboardSettles(() => copyCredentialField(sharedRow.id, 'password', 'vercel', 'team-a', 'https://vercel.com/login'), 'shared-pw');
1225
1365
  check('copy-writes-the-right-password-to-the-clipboard',
1226
- copied.ok === true && clipboard.readText() === 'shared-pw' && !JSON.stringify(copied).includes('shared-pw'),
1227
- `ok=${copied.ok} label="${copied.label}" clipboard-match=${clipboard.readText() === 'shared-pw'}`);
1228
- const copiedUser = copyCredentialField(sharedRow.id, 'username', 'vercel', 'team-a', 'https://vercel.com/login');
1229
- check('copy-email-writes-the-username', copiedUser.ok === true && clipboard.readText() === 'shared@example.com',
1230
- `label="${copiedUser.label}"`);
1366
+ pw.last.ok === true && pw.text === 'shared-pw' && !JSON.stringify(pw.last).includes('shared-pw'),
1367
+ `ok=${pw.last.ok} label="${pw.last.label}" clipboard-match=${pw.text === 'shared-pw'}`);
1368
+ const mail = await clipboardSettles(() => copyCredentialField(sharedRow.id, 'username', 'vercel', 'team-a', 'https://vercel.com/login'), 'shared@example.com');
1369
+ check('copy-email-writes-the-username', mail.last.ok === true && mail.text === 'shared@example.com',
1370
+ `label="${mail.last.label}" clipboard-match=${mail.text === 'shared@example.com'}`);
1231
1371
  const badField = copyCredentialField(sharedRow.id, 'reveal', 'vercel', 'team-a', 'https://vercel.com/login');
1232
1372
  check('copy-refuses-an-unknown-field', badField.ok === false && badField.reason === 'bad-field', `reason=${badField.reason}`);
1233
1373
  clipboard.clear();
@@ -1456,6 +1596,245 @@ async function runSmokeDark() {
1456
1596
  const bgNav = await bgOf();
1457
1597
  check('re-darkens-after-cross-origin-navigation', !!bgNav && !/255,\s*255,\s*255/.test(bgNav), bgNav);
1458
1598
 
1599
+ // ================= 0.11.0: PER-ACCOUNT CONTENT SETTINGS =================
1600
+ // The nine checks above are the pre-0.11.0 goldens and stay untouched (no assertion edited). This
1601
+ // section is additive: dark overrides (5), ad-block (6 — the first ad-block coverage this suite
1602
+ // has ever had), then the data/API contracts (4).
1603
+ //
1604
+ // Every check runs offline: the dark checks use two local white pages, and the ad-block checks use
1605
+ // a seed-listed ad host that CANNOT resolve. With the blocker on the request dies as
1606
+ // net::ERR_BLOCKED_BY_CLIENT (-20); with it off the same request dies as net::ERR_NAME_NOT_RESOLVED
1607
+ // (-105) — measured on this machine against the repo's real src/content.js before this was written.
1608
+ // The two codes are therefore a real, network-free witness of the network-layer decision.
1609
+ const isDark = bg => !!bg && bg !== '' && !/255,\s*255,\s*255/.test(bg);
1610
+ const isWhite = bg => /255,\s*255,\s*255/.test(bg || '');
1611
+
1612
+ // A fresh account + a fresh white document, loaded through the REAL create path so the real
1613
+ // did-finish-load apply (and the real per-request closure in makeView) is what gets measured.
1614
+ const mk = async (id, url) => {
1615
+ vm.create('vercel', id, url, { show: false });
1616
+ const t = vm.tabs.get(accountKey('vercel', id));
1617
+ // create() calls loadURL without waiting, so either the load is still in flight or it already
1618
+ // finished (a cached file:// page) — never wait for an event that has already fired.
1619
+ if (t.view.webContents.isLoading()) await new Promise(r => t.view.webContents.once('did-finish-load', r));
1620
+ return t;
1621
+ };
1622
+ const bgOfTab = async t => {
1623
+ try { return await t.view.webContents.executeJavaScript(`getComputedStyle(document.documentElement).backgroundColor`); } catch { return ''; }
1624
+ };
1625
+ const engineOf = async t => {
1626
+ try { return await t.view.webContents.executeJavaScriptInIsolatedWorld(dark.DARK_WORLD_ID, [{ code: 'typeof window.DarkReader' }]); } catch { return undefined; }
1627
+ };
1628
+
1629
+ // Reset the registry and the global settings this section depends on, then build a control view
1630
+ // with NO overrides at all (the equality baseline every per-account assertion compares against).
1631
+ store.accounts = store.accounts.filter(a => a.svc !== 'vercel' || !/^x-/.test(a.id));
1632
+ store.settings.darkMode = true; store.settings.adBlock = true;
1633
+ const control = await mk('x-control', toUrl(p1));
1634
+ await sleep(3200);
1635
+ const controlDark = await bgOfTab(control);
1636
+ const controlEngine = await engineOf(control);
1637
+
1638
+ // 1. global ON, A override off, B follows -> A white, B dark.
1639
+ registerAccount('vercel', 'x-a');
1640
+ updateContentOverride(findAccount('vercel', 'x-a'), 'darkMode', 'off');
1641
+ const aOff = await mk('x-a', toUrl(p1));
1642
+ const bFollowOn = await mk('x-b', toUrl(p1));
1643
+ await sleep(3200);
1644
+ const aOffBg = await bgOfTab(aOff); const bOnBg = await bgOfTab(bFollowOn);
1645
+ check('per-account-dark-override-off-while-global-on', isWhite(aOffBg) && isDark(bOnBg), `A=${aOffBg} B=${bOnBg}`);
1646
+
1647
+ // 2. global OFF, A override on, B follows -> A dark, B white (Q2: true override, both directions).
1648
+ store.settings.darkMode = false;
1649
+ const cOff = await mk('x-c', toUrl(p1));
1650
+ registerAccount('vercel', 'x-d');
1651
+ updateContentOverride(findAccount('vercel', 'x-d'), 'darkMode', 'on');
1652
+ const dOn = await mk('x-d', toUrl(p1));
1653
+ await sleep(3200);
1654
+ const cOffBg = await bgOfTab(cOff); const dOnBg = await bgOfTab(dOn);
1655
+ check('per-account-dark-override-on-while-global-off', isDark(dOnBg) && isWhite(cOffBg), `C(global-off)=${cOffBg} D(override-on)=${dOnBg}`);
1656
+
1657
+ // 3. follow-global is byte-for-byte the control view: same computed colour AND same engine presence.
1658
+ store.settings.darkMode = true;
1659
+ const follow = await mk('x-e', toUrl(p1));
1660
+ await sleep(3200);
1661
+ const followBg = await bgOfTab(follow); const followEngine = await engineOf(follow);
1662
+ check('per-account-dark-follow-global',
1663
+ followBg === controlDark && followEngine === controlEngine && followEngine === 'object',
1664
+ `follow=${followBg}/${followEngine} control=${controlDark}/${controlEngine}`);
1665
+
1666
+ // 4. override change applies to the OPEN document with no reload: the h1 keeps an in-page marker
1667
+ // (a reload would drop it), while the colour returns to white. B (untouched) keeps its colour.
1668
+ await follow.view.webContents.executeJavaScript(`document.querySelector('h1').setAttribute('data-marker','kept')`).catch(() => {});
1669
+ const bBefore4 = await bgOfTab(bFollowOn);
1670
+ setAccountContentOverride(vm, 'vercel', 'x-e', 'darkMode', 'off');
1671
+ await sleep(1800);
1672
+ const marker = await follow.view.webContents.executeJavaScript(`document.querySelector('h1') && document.querySelector('h1').getAttribute('data-marker')`).catch(() => null);
1673
+ const followBg4 = await bgOfTab(follow); const bAfter4 = await bgOfTab(bFollowOn);
1674
+ check('per-account-override-change-applies-without-reload',
1675
+ followBg4 !== '' && isWhite(followBg4) && marker === 'kept' && bAfter4 === bBefore4,
1676
+ `marker=${marker} bg=${followBg4} B before/after=${bBefore4}/${bAfter4}`);
1677
+
1678
+ // 5. an untouched account is unchanged by a sibling's override: compare a following tab with the
1679
+ // control view created before ANY override existed.
1680
+ const untouched = await mk('x-f', toUrl(p1));
1681
+ await sleep(3200);
1682
+ const untouchedBg = await bgOfTab(untouched); const untouchedEngine = await engineOf(untouched);
1683
+ check('per-account-dark-untouched-account-unchanged',
1684
+ untouchedBg === controlDark && untouchedEngine === controlEngine,
1685
+ `untouched=${untouchedBg}/${untouchedEngine} control=${controlDark}/${controlEngine}`);
1686
+
1687
+ // ---------- ad-block: the offline ERR_BLOCKED_BY_CLIENT / ERR_NAME_NOT_RESOLVED discriminator ----------
1688
+ // A seed-listed ad host whose SUBDOMAIN cannot resolve. hostBlocked() walks parent domains, so it
1689
+ // is blocked by the predicate, while DNS guarantees the "not blocked" answer is a different code.
1690
+ const ADHOST = 'mam011-smoke-no-such-host.doubleclick.net';
1691
+ const frameCode = async t => {
1692
+ const wc = t.view.webContents;
1693
+ const got = await new Promise(resolve => {
1694
+ const seen = [];
1695
+ const onFail = (_e, code, desc, _vurl, isMainFrame) => { if (!isMainFrame) seen.push(desc); };
1696
+ wc.on('did-fail-load', onFail);
1697
+ wc.executeJavaScript(`(() => { const old = document.getElementById('mam-ad-probe'); if (old) old.remove();
1698
+ const f = document.createElement('iframe'); f.id = 'mam-ad-probe';
1699
+ f.src = 'https://${ADHOST}/ad.html'; document.body.appendChild(f); return f.src; })()`).catch(() => {});
1700
+ setTimeout(() => { wc.removeListener('did-fail-load', onFail); resolve(seen); }, 6000);
1701
+ });
1702
+ return got[0] || 'none';
1703
+ };
1704
+ // A fresh document is what makes the check independent of earlier iframes: reload once, then probe.
1705
+ const reloadAndProbe = async t => {
1706
+ const wc = t.view.webContents;
1707
+ await new Promise(r => { wc.once('did-finish-load', r); wc.reload(); });
1708
+ await sleep(600);
1709
+ return frameCode(t);
1710
+ };
1711
+
1712
+ // 6. global ON, A override off, B follows -> A is NOT blocked, B IS blocked.
1713
+ store.settings.adBlock = true;
1714
+ registerAccount('vercel', 'x-g');
1715
+ updateContentOverride(findAccount('vercel', 'x-g'), 'adBlock', 'off');
1716
+ const gOff = await mk('x-g', toUrl(p1));
1717
+ await sleep(800);
1718
+ const gCode = await reloadAndProbe(gOff);
1719
+ const bCode = await reloadAndProbe(bFollowOn);
1720
+ check('per-account-adblock-override-off-while-global-on',
1721
+ gCode === 'ERR_NAME_NOT_RESOLVED' && bCode === 'ERR_BLOCKED_BY_CLIENT', `G(override-off)=${gCode} B(follow)=${bCode}`);
1722
+
1723
+ // 7. global OFF, D override on (D already exists with a dark override), E follows -> D blocked, C not.
1724
+ store.settings.adBlock = false;
1725
+ updateContentOverride(findAccount('vercel', 'x-d'), 'adBlock', 'on');
1726
+ const dCode = await reloadAndProbe(dOn);
1727
+ const cCode2 = await reloadAndProbe(cOff);
1728
+ check('per-account-adblock-override-on-while-global-off',
1729
+ dCode === 'ERR_BLOCKED_BY_CLIENT' && cCode2 === 'ERR_NAME_NOT_RESOLVED', `D(override-on)=${dCode} C(global-off)=${cCode2}`);
1730
+
1731
+ // 8. follow-global: an un-overridden account produces exactly the code the global dictates. With
1732
+ // the global back on, that is BLOCKED — the same answer the control view would give.
1733
+ store.settings.adBlock = true;
1734
+ const fCode = await reloadAndProbe(follow); // x-e has only a darkMode override: adBlock follows
1735
+ check('per-account-adblock-follow-global', fCode === 'ERR_BLOCKED_BY_CLIENT', `E(follow, global on)=${fCode}`);
1736
+
1737
+ // 9. a LIVE flip changes the next request's outcome with no reload: x-g is overridden off; clearing
1738
+ // the override (it follows the on global) blocks the SAME host, in the SAME document. A page
1739
+ // marker proves the document survived — a URL comparison alone would NOT, because a reload keeps
1740
+ // the URL (mutation 4 caught exactly that: it reloaded on the on-flip and still passed).
1741
+ await gOff.view.webContents.executeJavaScript(`window.__mamFlipMark = 'alive'`).catch(() => {});
1742
+ const urlBefore9 = gOff.view.webContents.getURL();
1743
+ setAccountContentOverride(vm, 'vercel', 'x-g', 'adBlock', 'follow');
1744
+ await sleep(400);
1745
+ const gCodeLive = await frameCode(gOff);
1746
+ const urlAfter9 = gOff.view.webContents.getURL();
1747
+ const flipMark = await gOff.view.webContents.executeJavaScript(`window.__mamFlipMark || 'gone'`).catch(() => 'gone');
1748
+ check('adblock-live-flip-no-reload',
1749
+ gCodeLive === 'ERR_BLOCKED_BY_CLIENT' && urlBefore9 === urlAfter9 && flipMark === 'alive',
1750
+ `after=${gCodeLive} url ${urlBefore9 === urlAfter9 ? 'unchanged' : 'CHANGED'} document=${flipMark}`);
1751
+
1752
+ // 10. cosmetic per account: applyToPage hides the ad slot for an on-account and leaves it for an
1753
+ // off-account. The seed list carries no generic cosmetics (they come from a fetched EasyList),
1754
+ // so the check installs ONE known selector into the live filter set for the duration of the
1755
+ // assertion and restores it in a finally — the module's bytes are untouched (it is frozen), and
1756
+ // the check then measures the real did-finish-load apply for two accounts that resolve
1757
+ // differently. `display:none` vs `display:block` is the observable.
1758
+ const cosFile = path.join(app.getPath('temp'), 'mam-content-cosmetic.html');
1759
+ 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>');
1760
+ const savedCosmetic = content.filters.cosmetic;
1761
+ content.filters.cosmetic = ['#ad-slot'];
1762
+ let cosOn = ''; let cosOff = ''; let cosOffTab = null;
1763
+ try {
1764
+ const onT = await mk('x-h', toUrl(cosFile));
1765
+ await sleep(900);
1766
+ cosOn = await onT.view.webContents.executeJavaScript(`getComputedStyle(document.getElementById('ad-slot')).display`).catch(() => '');
1767
+ registerAccount('vercel', 'x-i');
1768
+ updateContentOverride(findAccount('vercel', 'x-i'), 'adBlock', 'off');
1769
+ const offT = await mk('x-i', toUrl(cosFile));
1770
+ await sleep(900);
1771
+ cosOff = await offT.view.webContents.executeJavaScript(`getComputedStyle(document.getElementById('ad-slot')).display`).catch(() => '');
1772
+ cosOffTab = offT; // used by check 11 below
1773
+ } finally { content.filters.cosmetic = savedCosmetic; }
1774
+ check('adblock-cosmetic-per-account', cosOn === 'none' && cosOff === 'block',
1775
+ `following(adBlock on)=${cosOn} overridden-off=${cosOff}`);
1776
+
1777
+ // 11. a reload caused by ANOTHER account's ad-block change never reaches this account's document.
1778
+ // Toggling the GLOBAL off and back on makes every FOLLOWING account's resolved value move
1779
+ // (off, then on: the "on" half re-applies cosmetics, the "off" half reloads), while x-i is
1780
+ // overridden off and resolves `false` at both points — so it must not reload. A page-global
1781
+ // marker survives a non-reload and dies on a reload.
1782
+ const offTab = cosOffTab;
1783
+ await gOff.view.webContents.executeJavaScript(`window.__mamMark = 'alive'`).catch(() => {});
1784
+ await offTab.view.webContents.executeJavaScript(`window.__mamMark = 'alive'`).catch(() => {});
1785
+ setGlobalContentSetting(vm, 'adBlock', false);
1786
+ await sleep(500);
1787
+ setGlobalContentSetting(vm, 'adBlock', true);
1788
+ await sleep(2000);
1789
+ const gMark = await gOff.view.webContents.executeJavaScript(`window.__mamMark || 'gone'`).catch(() => 'gone');
1790
+ const iMark = await offTab.view.webContents.executeJavaScript(`window.__mamMark || 'gone'`).catch(() => 'gone');
1791
+ check('adblock-off-account-unaffected-by-other-accounts-reload', gMark === 'gone' && iMark === 'alive',
1792
+ `following(${gMark}) overridden-off(${iMark})`);
1793
+
1794
+ // ---------- data / API contracts ----------
1795
+ // 12. sparse storage: setting one key never creates the other, and clearing deletes the key.
1796
+ registerAccount('vercel', 'x-j');
1797
+ setAccountContentOverride(vm, 'vercel', 'x-j', 'adBlock', 'off');
1798
+ const jAcc = findAccount('vercel', 'x-j');
1799
+ check('content-override-stored-sparse',
1800
+ JSON.stringify(jAcc.contentOverride) === JSON.stringify({ adBlock: 'off' }),
1801
+ JSON.stringify(jAcc.contentOverride));
1802
+
1803
+ // 13. the owner's bar, mechanically: set then clear leaves accounts.json byte-identical.
1804
+ const accFile = path.join(app.getPath('userData'), 'accounts.json');
1805
+ registerAccount('vercel', 'x-k');
1806
+ const before13 = fs.readFileSync(accFile);
1807
+ setAccountContentOverride(vm, 'vercel', 'x-k', 'darkMode', 'on');
1808
+ setAccountContentOverride(vm, 'vercel', 'x-k', 'darkMode', 'follow');
1809
+ const after13 = fs.readFileSync(accFile);
1810
+ check('content-override-clear-is-byte-identical', before13.equals(after13),
1811
+ `${before13.length} vs ${after13.length} bytes; key present after clear: ${'contentOverride' in findAccount('vercel', 'x-k')}`);
1812
+
1813
+ // 14. the IPC rejects an unknown name and an unknown account (mirrors account-no-autofill).
1814
+ const throws = fn => { try { fn(); return false; } catch { return true; } };
1815
+ check('content-override-rejects-unknown-name', throws(() => setAccountContentOverride(vm, 'vercel', 'x-j', 'network', 'on')));
1816
+ check('content-override-rejects-unknown-account', throws(() => setAccountContentOverride(vm, 'vercel', 'nobody', 'adBlock', 'on')));
1817
+ // 15. only on / off / follow are accepted — and nothing is written when the value is refused.
1818
+ const jBefore = JSON.stringify(findAccount('vercel', 'x-j').contentOverride);
1819
+ check('content-override-invalid-value-rejected',
1820
+ throws(() => setAccountContentOverride(vm, 'vercel', 'x-j', 'adBlock', 'yes'))
1821
+ && JSON.stringify(findAccount('vercel', 'x-j').contentOverride) === jBefore,
1822
+ `stored=${jBefore}`);
1823
+
1824
+ // ---------- the resolver contract (MUTATION-PLUS) ----------
1825
+ // The follow path must be derived from the GLOBAL, never a hardcoded literal. The spec's own
1826
+ // formula is `!!settings[name]`, so the returned value is the global's truthiness — not the raw
1827
+ // value (a string fake therefore reads `true`, and asserting raw identity would be asserting
1828
+ // against the contract). What a literal default cannot do is FOLLOW the global in both directions:
1829
+ // a resolver hardcoded to true fails the first half, one hardcoded to false fails the second.
1830
+ const followOn = resolveContent({}, 'adBlock', { adBlock: true });
1831
+ const followOff = resolveContent({ contentOverride: {} }, 'adBlock', { adBlock: false });
1832
+ const followUnset = resolveContent({}, 'darkMode', {});
1833
+ const followOverridden = resolveContent({ contentOverride: { adBlock: 'on' } }, 'adBlock', { adBlock: false });
1834
+ check('resolver-follow-tracks-the-global-not-a-literal',
1835
+ followOn === true && followOff === false && followUnset === false && followOverridden === true,
1836
+ `global true->${followOn}, global false->${followOff}, unset->${followUnset}, override-on-over-global-off->${followOverridden}`);
1837
+
1459
1838
  fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
1460
1839
  app.exit(results.checks.every(c => c.ok) ? 0 : 1);
1461
1840
  }
@@ -1615,6 +1994,55 @@ function runInteractive(captureMode = false) {
1615
1994
  console.log('[capture] demo notice:', plan.kind, (plan.choices || []).length);
1616
1995
  await new Promise(r => setTimeout(r, 700));
1617
1996
  }
1997
+ // --demo-home-marker: give two demo accounts one override each through the REAL path, so the
1998
+ // Home grid shows the "content: custom" marker the way a user would see it (0.11.0).
1999
+ if (process.argv.includes('--demo-home-marker')) {
2000
+ try {
2001
+ if (findAccount('vercel', 'work')) setAccountContentOverride(vm, 'vercel', 'work', 'darkMode', 'off');
2002
+ if (findAccount('netlify', 'agency')) setAccountContentOverride(vm, 'netlify', 'agency', 'adBlock', 'on');
2003
+ } catch (err) { console.error('[demo] home marker failed:', err.message); }
2004
+ vm.emitState();
2005
+ await new Promise(r => setTimeout(r, 800));
2006
+ const info = await win.webContents.executeJavaScript(`JSON.stringify([...document.querySelectorAll('.card .contentcustom')].map(m => {
2007
+ const r = m.getBoundingClientRect();
2008
+ return m.closest('.card').querySelector('.label').innerText + ' :: ' + m.innerText + ' @' + Math.round(r.x) + ',' + Math.round(r.y);
2009
+ }))`).catch(err => 'probe failed: ' + err.message);
2010
+ console.log('[capture] home markers:', info);
2011
+ await new Promise(r => setTimeout(r, 300));
2012
+ }
2013
+ // --demo-content: give the account in --capture-open one override through the REAL path, then
2014
+ // open its gear window from the RENDERER, so the screenshot shows the real three-way selects,
2015
+ // the worded resolved state and the honest note (0.11.0). Drives state from main, never from
2016
+ // --capture-js (which intermittently exits 127 on this machine). Scratch demo profile only.
2017
+ if (process.argv.includes('--demo-content') && openKey) {
2018
+ const [dsvc, did] = openKey.split('::');
2019
+ try { setAccountContentOverride(vm, dsvc, did, 'darkMode', 'off'); } catch (err) { console.error('[demo] content override failed:', err.message); }
2020
+ vm.emitState();
2021
+ await new Promise(r => setTimeout(r, 500));
2022
+ if (win.webContents && !win.webContents.isDestroyed()) {
2023
+ 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));
2024
+ }
2025
+ await new Promise(r => setTimeout(r, 900));
2026
+ const info = await win.webContents.executeJavaScript(`JSON.stringify({
2027
+ modalOpen: !document.getElementById('modal-back').hidden,
2028
+ selects: [...document.querySelectorAll('#modal select')].map(s => ({ id: s.id, value: s.value, options: s.options.length })),
2029
+ states: [...document.querySelectorAll('#modal .contentctl .note')].map(n => n.textContent.trim()),
2030
+ 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) : ''; })(),
2031
+ })`).catch(err => 'probe failed: ' + err.message);
2032
+ console.log('[capture] demo content:', info);
2033
+ // drive one select through the REAL renderer → preload → main chain and read the store back
2034
+ const realPath = await win.webContents.executeJavaScript(`(async () => {
2035
+ const sel = document.getElementById('m-content-adBlock');
2036
+ const before = sel.value;
2037
+ sel.value = 'off'; sel.dispatchEvent(new Event('change', { bubbles: true }));
2038
+ await new Promise(r => setTimeout(r, 900));
2039
+ return JSON.stringify({ before, after: sel.value, stateLine: document.getElementById('m-content-adBlock-state').textContent.trim() });
2040
+ })()`).catch(err => 'real-path probe failed: ' + err.message);
2041
+ console.log('[capture] demo content real path:', realPath, 'stored:', JSON.stringify((findAccount(openKey.split('::')[0], openKey.split('::')[1]) || {}).contentOverride));
2042
+ // scroll the modal to its end so the honest note under the selects is in frame
2043
+ await win.webContents.executeJavaScript(`(() => { const m = document.getElementById('modal'); if (m) m.scrollTop = m.scrollHeight; return m ? m.scrollTop : -1; })()`).catch(() => {});
2044
+ await new Promise(r => setTimeout(r, 400));
2045
+ }
1618
2046
  // --demo-picker: open the address-strip key chip's picker from MAIN (state → emitState → the
1619
2047
  // renderer's own click handler), so a screenshot shows the real popover and not hand-written
1620
2048
  // DOM. The chip is drawn from state.siteLogins, so one emitState is enough. Verification aid
@@ -2052,6 +2480,14 @@ function runInteractive(captureMode = false) {
2052
2480
  vm.emitState();
2053
2481
  return acc.noAutoFill;
2054
2482
  });
2483
+ // Per-account content override (0.11.0): 'follow' | 'on' | 'off' per control, stored SPARSELY on
2484
+ // the account record (see updateContentOverride). 'follow' deletes the key, so clearing restores
2485
+ // the account's stored bytes. An override is a TRUE override in both directions (owner decision
2486
+ // Q2): it may turn a control on while the app-wide toggle is off, and vice versa. The change is
2487
+ // applied to that account's live tab immediately; every other tab is re-resolved and acted on only
2488
+ // if ITS value actually moved (an untouched account is never reloaded or re-engaged).
2489
+ ipcMain.handle('account-content-override', (_e, svc, id, name, value) =>
2490
+ setAccountContentOverride(vm, svc, id, name, value));
2055
2491
  // Dismiss the per-tab notice (banner X).
2056
2492
  ipcMain.handle('dismiss-notice', (_e, key) => { vm.setNotice(key, null); return true; });
2057
2493
  // Undo an auto-fill: the preload clears exactly what it filled and answers with what it cleared.
@@ -2154,31 +2590,7 @@ function runInteractive(captureMode = false) {
2154
2590
  vm.emitState();
2155
2591
  return true;
2156
2592
  });
2157
- ipcMain.handle('set-content-setting', (_e, name, value) => {
2158
- if (!['adBlock', 'darkMode', 'saveLogins', 'autofill', 'autoUpdate'].includes(name)) throw new Error(`unknown setting: ${name}`);
2159
- store.settings[name] = !!value;
2160
- if (name === 'autoUpdate') { store.save(); updater.setAutoUpdate(!!value); vm.emitState(); return !!value; }
2161
- store.save();
2162
- if (name === 'darkMode') {
2163
- // The engine runs in each view's isolated world, so a flip is one enable/disable call per
2164
- // live tab — no reload. The previous extension path forced a reload of every open tab, which
2165
- // threw away scroll position and anything typed into a half-filled form.
2166
- const on = !!value;
2167
- for (const t of vm.tabs.values()) {
2168
- if (on) dark.enable(t.view.webContents);
2169
- else dark.disable(t.view.webContents);
2170
- }
2171
- } else if (name === 'adBlock') {
2172
- // the network layer flips live through its closure; cosmetic CSS needs a re-apply on, and a
2173
- // reload off (injected CSS can only be undone by reloading)
2174
- for (const t of vm.tabs.values()) {
2175
- if (value) content.applyToPage(t.view.webContents, { adBlock: true });
2176
- else t.view.webContents.reload();
2177
- }
2178
- }
2179
- vm.emitState();
2180
- return store.settings[name];
2181
- });
2593
+ ipcMain.handle('set-content-setting', (_e, name, value) => setGlobalContentSetting(vm, name, value));
2182
2594
  ipcMain.handle('content-stats', () => ({
2183
2595
  blocked: content.stats.blocked,
2184
2596
  listSource: content.filters.loadedFrom,
@@ -2272,6 +2684,8 @@ function runInteractive(captureMode = false) {
2272
2684
  module.exports = {
2273
2685
  accountLoginSummaries, managerRows, setOrClearDefault, resolveAmong, fillCandidatesFor, autoFillPlan,
2274
2686
  originAllowed, accountKey,
2687
+ resolveContent, updateContentOverride, contentOverrideFor,
2688
+ setAccountContentOverride, setGlobalContentSetting, applyContentToFreshDocument, reactToContentChange, contentSnapshot,
2275
2689
  _creds: creds,
2276
2690
  };
2277
2691