my-provider-dash-manager 0.12.0 → 0.13.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 +24 -10
  2. package/package.json +1 -1
  3. package/src/main.js +229 -1
package/README.md CHANGED
@@ -223,7 +223,15 @@ works in the app. Anything else can be added by URL or as a plugin.
223
223
  profile with a **master password** set, or one from Firefox 62 or earlier, is refused with a
224
224
  specific message instead of importing nothing quietly; both cases point you at the CSV route. A
225
225
  CSV exported by any browser still imports too.
226
- - Encrypted at rest with Windows DPAPI. Passwords never reach the app's own UI process.
226
+ - **Survives restarts.** Saved logins are encrypted with AES-256-GCM and read back identically after
227
+ you quit and relaunch — on every machine, including ones whose Windows credential encryption only
228
+ works inside the process that wrote it. Passwords never reach the app's own UI process.
229
+ - **Existing installs: re-import once.** A store written by an earlier version on such a machine is
230
+ genuinely unreadable there, so the update cannot recover it. Re-import once (*Import from browser*
231
+ or *Import CSV*) and your logins are written in the new format — from then on they survive every
232
+ restart. On machines where the old store was readable, the migration is automatic and keeps your
233
+ logins, their ids and their ★ defaults. If an old store cannot be read, the Home screen says so
234
+ plainly instead of showing an empty list.
227
235
  - **Origin-bound**: a login is tied to the account *and* the service's own domains. A password typed on
228
236
  another site reached from that tab (an OAuth hand-off, a look-alike page) is never captured as that
229
237
  account's login, and a saved password is never filled into a page outside the service's domains.
@@ -289,12 +297,14 @@ anything to the page.
289
297
 
290
298
  State lives in `%APPDATA%\multi-acc-manager`: `accounts.json` (the registry), `session.json` (open
291
299
  tabs), `settings.json`, `services.json` (custom services), `plugins/`, plus DPAPI-encrypted
292
- `tokens.json` and `credentials.json`.
300
+ `tokens.json` and the AES-256-GCM-encrypted `credentials.json` with its `credkey` key file.
293
301
 
294
302
  ### Security posture, honestly
295
303
 
296
- Cookies, tokens and saved passwords are encrypted at rest with Windows DPAPI, which protects against
297
- another user on the machine, a stolen disk, and casual copying. It does **not** protect against
304
+ Cookies, tokens and saved passwords are encrypted at rest, and protect against another user on the
305
+ machine, a stolen disk, and casual copying. Cookies and API tokens use Windows DPAPI; saved logins
306
+ use AES-256-GCM under a key in the app's own profile folder, so they read back after a restart (DPAPI's
307
+ key on some machines only works inside the process that wrote it). Neither protects against
298
308
  malware running as you — no desktop app can, which is why the app never masks device identity and
299
309
  keeps API tokens scoped and revocable. Saved logins are additionally bound to the service's own
300
310
  origins, so they cannot be captured from, or filled into, an unrelated site. The address bar accepts
@@ -323,11 +333,15 @@ baseline with everything asleep. The app keeps five tabs warm by default and sle
323
333
  tabs stay in the strip and resume on click.
324
334
 
325
335
  **Is it safe to store my passwords?**
326
- Saved logins are encrypted with Windows DPAPI and bound to the account *and* the service's own
336
+ Saved logins are encrypted at rest with AES-256-GCM under a 32-byte key kept in the app's own profile
337
+ folder, beside the sessions and cookies, and are bound to the account *and* the service's own
327
338
  domains — a password typed on an unrelated page reached from a tab is never captured, and a saved
328
- password is never filled into a page outside the service's domains. DPAPI protects against another
329
- user on the machine, a stolen disk and casual copying; it does not protect against malware running as
330
- you, which no desktop app can. Passwords never reach the app's own UI process.
339
+ password is never filled into a page outside the service's domains. A store that fails its integrity
340
+ check is refused rather than used, and writes are atomic. This protects against another user on the
341
+ machine, a stolen disk and casual copying — the key file lives under your Windows user account; it
342
+ does not protect against malware running as you, which no desktop app can. Unlike Windows' own
343
+ per-process credential encryption, the store reads back identically after a restart, which is why
344
+ saved logins survive quitting the app. Passwords never reach the app's own UI process.
331
345
 
332
346
  **Does it work on macOS or Linux?**
333
347
  Not yet — the app and its installer are Windows-only. The architecture is cross-platform, so other
@@ -381,7 +395,7 @@ npm start # run the app
381
395
  npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
382
396
  npm run smoke:restore # sessions survive a restart (real cookies)
383
397
  npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
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)
398
+ npm run smoke:creds # import (CSV + direct Firefox), per-account/manager/site lists, defaults, copy, fill fallback, auto-fill plan, encryption at rest, the store surviving a real two-launch restart (65)
385
399
  npm run smoke:autofill # real page: form detection, fill + request ids, undo, capture, origin guard (15)
386
400
  npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
387
401
  npm run smoke:services # every built-in login page loads inside an isolated partition (42)
@@ -394,7 +408,7 @@ npm start -- --print-paths # show the app name, version and data fold
394
408
  npm start -- --open vercel::personal # open an account on launch
395
409
  ```
396
410
 
397
- The suite is 179 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
411
+ The suite is 188 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
398
412
  profile.
399
413
 
400
414
  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.12.0",
3
+ "version": "0.13.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
@@ -1,6 +1,7 @@
1
1
  const { app, BaseWindow, BrowserWindow, WebContentsView, session, ipcMain, shell, Menu, clipboard } = require('electron');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const { spawn } = require('child_process');
4
5
  const crypto = require('crypto');
5
6
  const SERVICES = require('./services');
6
7
  const tokens = require('./api-tokens');
@@ -1131,6 +1132,40 @@ async function runSmokeServices() {
1131
1132
  }
1132
1133
 
1133
1134
  // ---------- smoke creds: CSV import, per-account remember/recall, encryption at rest ----------
1135
+
1136
+ // The two-launch proof (0.13.0) needs REAL separate processes: the bug was that a store written by
1137
+ // one process could not be read by another. This child mode runs on the SAME scratch profile as its
1138
+ // parent (`--cross-process-*` is deliberately absent from TEST_FLAGS so MAM_USER_DATA is never
1139
+ // rewritten) and either seeds one known login or reports what it can read.
1140
+ //
1141
+ // The answer goes to a RESULT FILE (`<userData>/cross-process-<mode>.json`), not stdout: Electron on
1142
+ // this machine intermittently exits with 0xC0000005 (access violation) from its own teardown AFTER the
1143
+ // work is done, which makes both the exit code and a stdio read-back unreliable evidence. The file is
1144
+ // written from inside the app before exit, so it is proof of the work regardless of how teardown goes.
1145
+ const CROSS_PROCESS_CRED = { host: 'restart.example.test', username: 'restart-check@example.test', password: 'restart-check-pw-0.13.0' };
1146
+ function crossProcessResultFile(mode) { return path.join(app.getPath('userData'), `cross-process-${mode}.json`); }
1147
+ function runCrossProcess({ seed }) {
1148
+ const mode = seed ? 'seed' : 'read';
1149
+ let payload;
1150
+ try {
1151
+ if (seed) {
1152
+ // The whole point of the seed: write through the real store path, then exit. Process B must be
1153
+ // able to read it back with nothing shared between them but the profile directory.
1154
+ creds.rememberForAccount('vercel', 'restartcheck', { url: `https://${CROSS_PROCESS_CRED.host}/login`, ...CROSS_PROCESS_CRED });
1155
+ payload = { pid: process.pid, ok: true, seeded: creds.stats().total, format: creds.storeState().format };
1156
+ } else {
1157
+ const rows = creds.loadAll().filter(c => c.host === CROSS_PROCESS_CRED.host && c.username === CROSS_PROCESS_CRED.username);
1158
+ payload = { pid: process.pid, ok: true, found: rows.length, passwordMatches: rows.length === 1 && rows[0].password === CROSS_PROCESS_CRED.password, total: creds.stats().total };
1159
+ }
1160
+ } catch (err) { payload = { pid: process.pid, ok: false, error: err.message }; }
1161
+ try {
1162
+ fs.mkdirSync(path.dirname(crossProcessResultFile(mode)), { recursive: true });
1163
+ fs.writeFileSync(crossProcessResultFile(mode), JSON.stringify(payload));
1164
+ } catch {}
1165
+ console.log(`[cross-process] ${JSON.stringify(payload)}`);
1166
+ return app.exit(0);
1167
+ }
1168
+
1134
1169
  async function runSmokeCreds() {
1135
1170
  const results = { mode: 'creds', checks: [] };
1136
1171
  const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:creds] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
@@ -1522,10 +1557,169 @@ async function runSmokeCreds() {
1522
1557
  check('exception:firefox', false, err.message);
1523
1558
  }
1524
1559
 
1560
+ // ---------- 0.13.0: the store survives a restart (the owner's Supabase report) ----------
1561
+ // The bug was not in any UI: safeStorage's DPAPI key is per-PROCESS on this machine, so a store
1562
+ // written by one launch could never be read by the next. v2 (AES-256-GCM + a `credkey` sidecar)
1563
+ // has no per-process key. These checks prove the whole chain, and each one is written so it FAILS
1564
+ // on the old code: the two-launch check reads a store a DIFFERENT process wrote, and the damaged-tag
1565
+ // check refuses a blob whose auth tag was flipped (a JSON.parse failure alone would not prove the
1566
+ // auth check exists — a flipped CIPHERTEXT byte invalidates the JSON and would fail either way).
1567
+ try {
1568
+ const { safeStorage } = require('electron');
1569
+ const storeFile = path.join(app.getPath('userData'), 'credentials.json');
1570
+ const keyFile = path.join(app.getPath('userData'), 'credkey');
1571
+ const readStore = () => JSON.parse(fs.readFileSync(storeFile, 'utf8'));
1572
+
1573
+ // (1) v2 at rest: the raw file carries an AES-256-GCM blob, none of the fixture plaintext, and
1574
+ // the key sidecar is a 32-byte (44 base64 char) key — never the plaintext itself.
1575
+ creds.clearAll();
1576
+ const csvV2 = path.join(app.getPath('temp'), `mam-creds-v2-${process.pid}.csv`);
1577
+ fs.writeFileSync(csvV2, 'url,username,password\nhttps://vercel.com/login,v2.user@example.com,v2-plaintext-secret\n');
1578
+ try {
1579
+ creds.importFromCsv(csvV2);
1580
+ const rawV2 = readStore();
1581
+ const keyText = fs.readFileSync(keyFile, 'utf8').trim();
1582
+ const encCheck = fs.readFileSync(storeFile, 'utf8');
1583
+ check('store-v2-encrypted-at-rest',
1584
+ rawV2.v === 2 && rawV2.alg === 'aes-256-gcm' && rawV2.keyFile === 'credkey'
1585
+ && typeof rawV2.iv === 'string' && typeof rawV2.tag === 'string' && typeof rawV2.ct === 'string'
1586
+ && Buffer.from(rawV2.iv, 'base64').length === 12 && Buffer.from(rawV2.tag, 'base64').length === 16
1587
+ && !encCheck.includes('v2-plaintext-secret') && !encCheck.includes('v2.user@example.com')
1588
+ && keyText.length === 44 && Buffer.from(keyText, 'base64').length === 32
1589
+ && keyText !== encCheck && !encCheck.includes(keyText),
1590
+ `v=${rawV2.v} alg=${rawV2.alg} iv=${Buffer.from(rawV2.iv, 'base64').length}B tag=${Buffer.from(rawV2.tag, 'base64').length}B key=${Buffer.from(keyText, 'base64').length}B file=${encCheck.length}B`);
1591
+ } finally { try { fs.rmSync(csvV2, { force: true }); } catch {} }
1592
+
1593
+ // (2) a corrupted AUTH TAG is refused, honestly — never handed back as mangled plaintext and
1594
+ // never silently treated as "no logins". The tag check lives in final(); this is its proof.
1595
+ {
1596
+ const before = readStore();
1597
+ const badTag = Buffer.from(before.tag, 'base64'); badTag[0] ^= 0xff;
1598
+ fs.writeFileSync(storeFile, JSON.stringify({ ...before, tag: badTag.toString('base64') }, null, 2));
1599
+ const damagedRows = creds.loadAll();
1600
+ const damagedState = creds.storeState();
1601
+ check('store-v2-refuses-damaged-blob',
1602
+ damagedRows.length === 0 && !!damagedState.error && damagedState.error.code === 'AUTH'
1603
+ && /integrity/.test(damagedState.error.message || '') && !damagedState.legacyUnreadable,
1604
+ `${damagedRows.length} rows, error=${damagedState.error ? damagedState.error.code : 'none'}`);
1605
+ // put the good store back for the rest of the suite
1606
+ fs.writeFileSync(storeFile, JSON.stringify(before, null, 2));
1607
+ creds.loadAll();
1608
+ check('store-v2-recovers-when-the-file-is-intact', creds.stats().total === 1, `${creds.stats().total} row(s)`);
1609
+ }
1610
+
1611
+ // (3) v1 migration: an old-format store this process CAN read is re-encrypted to v2 on load, and
1612
+ // its ids + default flags survive byte-identically (losing an id would silently un-star a login).
1613
+ {
1614
+ const v1List = [
1615
+ { id: 'keep-id-alpha', host: 'vercel.com', url: 'https://vercel.com/login', username: 'old@example.com', password: 'old-pw', source: 'Chrome (Default)', importedAt: '2026-01-01T00:00:00.000Z', defaultForAccounts: ['vercel::team-a'] },
1616
+ { id: 'keep-id-beta', accountKey: 'netlify::site', host: 'app.netlify.com', url: 'https://app.netlify.com/login', username: 'own@example.com', password: 'own-pw', source: 'saved in app', importedAt: '2026-01-02T00:00:00.000Z', defaultForAccounts: ['netlify::site'] },
1617
+ ];
1618
+ // rebuild the OLD format exactly: one safeStorage blob under `ct`, no `v`, no sidecar
1619
+ fs.rmSync(keyFile, { force: true });
1620
+ fs.writeFileSync(storeFile, JSON.stringify({ ct: safeStorage.encryptString(JSON.stringify(v1List)).toString('base64'), count: 2, savedAt: '2026-01-03T00:00:00.000Z' }, null, 2));
1621
+ const migrated = creds.loadAll();
1622
+ const after = (() => { try { return readStore(); } catch { return {}; } })();
1623
+ const byId = new Map(migrated.map(c => [c.id, c]));
1624
+ check('store-v2-migration',
1625
+ migrated.length === 2 && after.v === 2 && !!after.tag && !!after.iv
1626
+ && fs.existsSync(keyFile)
1627
+ && byId.get('keep-id-alpha') && byId.get('keep-id-alpha').password === 'old-pw'
1628
+ && byId.get('keep-id-alpha').defaultForAccounts.join() === 'vercel::team-a'
1629
+ && byId.get('keep-id-beta') && byId.get('keep-id-beta').accountKey === 'netlify::site'
1630
+ && byId.get('keep-id-beta').defaultForAccounts.join() === 'netlify::site',
1631
+ `rows=${migrated.length} format=v${after.v} ids=${migrated.map(c => c.id).join(',')} defaults=${migrated.map(c => c.defaultForAccounts.join('|')).join(',')}`);
1632
+ // ...and a SECOND read (the next launch's shape) still sees them, now from v2 alone
1633
+ check('store-v2-migration-survives-a-reload',
1634
+ creds.loadAll().length === 2 && creds.stats().total === 2, `${creds.loadAll().length} rows on re-read`);
1635
+ }
1636
+
1637
+ // (4) an UNREADABLE v1 store: the broken case the owner hit. Simulated with a wrong-key v1 blob
1638
+ // (a real safeStorage ciphertext from another key cannot be produced honestly here). The file must
1639
+ // be left byte-identical, the app must show ONE honest notice, and an import must still work and
1640
+ // switch the store to v2 (with the old bytes preserved under their own name).
1641
+ {
1642
+ const wrongKeyBlob = crypto.randomBytes(96).toString('base64'); // not a DPAPI blob at all
1643
+ const v1Unreadable = JSON.stringify({ ct: wrongKeyBlob, count: 7, savedAt: '2026-01-04T00:00:00.000Z' }, null, 2);
1644
+ fs.rmSync(keyFile, { force: true });
1645
+ fs.writeFileSync(storeFile, v1Unreadable);
1646
+ const state = creds.storeState(); // exactly what the renderer's notice asks for
1647
+ const stillV1 = fs.readFileSync(storeFile, 'utf8');
1648
+ check('store-v1-unreadable-shows-the-honest-notice',
1649
+ state.legacyUnreadable === true && !!state.notice
1650
+ && /could not be read on this machine/.test(state.notice)
1651
+ && /re-import once and they will survive every future restart/.test(state.notice)
1652
+ && stillV1 === v1Unreadable,
1653
+ `legacyUnreadable=${state.legacyUnreadable} file-untouched=${stillV1 === v1Unreadable} notice="${String(state.notice).slice(0, 60)}…"`);
1654
+ // a re-import (the owner's one-time action, Q4) seeds v2 and clears the notice by itself
1655
+ const csvRe = path.join(app.getPath('temp'), `mam-creds-reimport-${process.pid}.csv`);
1656
+ fs.writeFileSync(csvRe, 'url,username,password\nhttps://vercel.com/login,reimport@example.com,reimport-pw\n');
1657
+ try { creds.importFromCsv(csvRe); } finally { try { fs.rmSync(csvRe, { force: true }); } catch {} }
1658
+ const afterReimport = creds.storeState();
1659
+ check('store-v1-unreadable-clears-after-a-reimport',
1660
+ afterReimport.legacyUnreadable === false && afterReimport.notice === null
1661
+ && creds.stats().total === 1 && readStore().v === 2
1662
+ && fs.existsSync(path.join(app.getPath('userData'), 'credentials.v1-unreadable.json')),
1663
+ `notice=${afterReimport.notice} rows=${creds.stats().total} format=v${readStore().v} backup=${fs.existsSync(path.join(app.getPath('userData'), 'credentials.v1-unreadable.json'))}`);
1664
+ }
1665
+
1666
+ // (5) THE regression proof: two REAL launches on ONE scratch profile. Launch A seeds a login and
1667
+ // exits; launch B — a different process, with nothing shared but the profile dir — must list it.
1668
+ // With the 0.12.x DPAPI store this is exactly the owner's bug (B reads 0 rows); with v2 it passes.
1669
+ {
1670
+ fs.rmSync(keyFile, { force: true });
1671
+ fs.rmSync(storeFile, { force: true });
1672
+ fs.rmSync(path.join(app.getPath('userData'), 'credentials.v1-unreadable.json'), { force: true });
1673
+ fs.rmSync(path.join(app.getPath('userData'), 'cross-process-seed.json'), { force: true });
1674
+ fs.rmSync(path.join(app.getPath('userData'), 'cross-process-read.json'), { force: true });
1675
+ const run = args => new Promise(resolve => {
1676
+ // The same Electron binary and the same app path as this run (app.getAppPath() = the project
1677
+ // root in a source run); only the profile is shared, on purpose.
1678
+ const child = spawn(process.execPath, [app.getAppPath(), ...args], {
1679
+ env: { ...process.env, MAM_USER_DATA: app.getPath('userData') },
1680
+ windowsHide: true,
1681
+ });
1682
+ let stdout = ''; let stderr = '';
1683
+ child.stdout.on('data', d => { stdout += d; });
1684
+ child.stderr.on('data', d => { stderr += d; });
1685
+ const timer = setTimeout(() => { try { child.kill(); } catch {} resolve({ timedOut: true, stdout, stderr, code: null }); }, 90000);
1686
+ child.on('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
1687
+ });
1688
+ // Read the child's RESULT FILE, not its stdout/exit code: Electron here sometimes exits with
1689
+ // 0xC0000005 from its own teardown after the work finished (measured: the same one-shot child
1690
+ // exits 0 six times in a row on its own, then 0xC0000005 under a parent-only run), so the file
1691
+ // the child wrote is the only honest evidence of what it actually did.
1692
+ const readResult = mode => {
1693
+ try { return JSON.parse(fs.readFileSync(path.join(app.getPath('userData'), `cross-process-${mode}.json`), 'utf8')); } catch { return null; }
1694
+ };
1695
+ // The single-instance lock is scoped to userData (proved on this machine 2026-11-03): while THIS
1696
+ // process holds it, a child on the same profile would exit 3. Release it for the two launches.
1697
+ app.releaseSingleInstanceLock();
1698
+ const seedRun = await run(['--cross-process-seed']);
1699
+ const seeded = readResult('seed');
1700
+ const readRun = await run(['--cross-process-read']);
1701
+ const readBack = readResult('read');
1702
+ check('store-survives-restart',
1703
+ !!seeded && seeded.ok === true && !!readBack && readBack.ok === true
1704
+ && readBack.found === 1 && readBack.passwordMatches === true && readBack.total === 1,
1705
+ `launch1=${JSON.stringify(seeded)} launch2=${JSON.stringify(readBack)}`
1706
+ + ` codes=${seedRun.code}/${readRun.code}`);
1707
+ // Both runs are genuinely separate processes: the child proves it by its own pid, and the
1708
+ // parent's pid is different from both. (The lock probe in the run output is extra evidence.)
1709
+ check('store-survives-restart-that-is-really-two-processes',
1710
+ !!seeded && !!readBack && seeded.pid !== process.pid && readBack.pid !== process.pid
1711
+ && seeded.pid !== readBack.pid
1712
+ && String(readRun.stdout).includes('[cross-process]'),
1713
+ `parent pid ${process.pid}; launch1 pid ${seeded && seeded.pid}; launch2 pid ${readBack && readBack.pid}`
1714
+ + `; exit codes ${seedRun.code}/${readRun.code} (Electron teardown is not the evidence)`);
1715
+ }
1716
+ } catch (err) {
1717
+ check('exception:store', false, err.message);
1718
+ }
1719
+
1525
1720
  fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
1526
1721
  app.exit(results.checks.every(c => c.ok) ? 0 : 1);
1527
1722
  }
1528
-
1529
1723
  // ---------- smoke autofill: real page, real preload — fill AND capture ----------
1530
1724
  async function runSmokeAutofill() {
1531
1725
  const results = { mode: 'autofill', checks: [] };
@@ -2118,6 +2312,32 @@ function runInteractive(captureMode = false) {
2118
2312
  console.log('[capture] demo notice:', plan.kind, (plan.choices || []).length);
2119
2313
  await new Promise(r => setTimeout(r, 700));
2120
2314
  }
2315
+ // --demo-store-notice: prove the 0.13.0 "re-import once" notice reaches the Home screen the
2316
+ // way a user sees it — main's own storeState() → the renderer's GET of it → the banner. Point
2317
+ // MAM_USER_DATA at a scratch profile carrying an unreadable v1 store. Screenshot aid, like
2318
+ // --demo-notice; it changes no shipped behaviour.
2319
+ if (process.argv.includes('--demo-store-notice')) {
2320
+ await refreshCredStoreState();
2321
+ const info = await win.webContents.executeJavaScript(`JSON.stringify({
2322
+ homeVisible: !document.getElementById('home').hidden,
2323
+ banner: (() => { const n = document.getElementById('store-notice'); return n ? n.innerText.trim().slice(0, 200) : null; })(),
2324
+ x: (() => { const n = document.getElementById('store-notice'); if (!n) return null; const r = n.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; })(),
2325
+ })`).catch(err => 'probe failed: ' + err.message);
2326
+ console.log('[capture] store notice:', info);
2327
+ await new Promise(r => setTimeout(r, 500));
2328
+ // --demo-store-notice-dismiss: prove the X hides it for this launch (a second read proves it
2329
+ // stays hidden; a relaunch shows it again because the old store is still unreadable).
2330
+ if (process.argv.includes('--demo-store-notice-dismiss')) {
2331
+ const gone = await win.webContents.executeJavaScript(`(async () => {
2332
+ const x = document.getElementById('store-notice-x');
2333
+ if (x) x.click();
2334
+ await new Promise(r => setTimeout(r, 300));
2335
+ return JSON.stringify({ before: !!x, after: !!document.getElementById('store-notice') });
2336
+ })()`).catch(err => 'probe failed: ' + err.message);
2337
+ console.log('[capture] store notice dismissed:', gone);
2338
+ await new Promise(r => setTimeout(r, 400));
2339
+ }
2340
+ }
2121
2341
  // --demo-home-marker: give two demo accounts one override each through the REAL path, so the
2122
2342
  // Home grid shows the "content: custom" marker the way a user would see it (0.11.0).
2123
2343
  if (process.argv.includes('--demo-home-marker')) {
@@ -2555,6 +2775,9 @@ function runInteractive(captureMode = false) {
2555
2775
  return { ok: false, reason: result.reason, message: messages[result.reason] || 'This page has no visible password field to fill.' };
2556
2776
  });
2557
2777
  ipcMain.handle('creds-stats', () => creds.stats());
2778
+ // 0.13.0: the honest "re-import once" notice. storeState() re-reads the store, so the banner goes
2779
+ // away by itself the moment a v2 store exists (a re-import, or a successful migration).
2780
+ ipcMain.handle('creds-store-state', () => creds.storeState());
2558
2781
  // One list, two kinds: a Chromium profile and a Firefox profile are both "a browser profile with
2559
2782
  // saved logins", so the existing Import-from-browser window renders them side by side with no new
2560
2783
  // surface. `kind` is what tells the renderer which import handler to call.
@@ -2846,6 +3069,9 @@ module.exports = {
2846
3069
  _creds: creds,
2847
3070
  };
2848
3071
 
3072
+ // What the app needs to say about the store, surfaced to the renderer's one honest banner (0.13.0).
3073
+ function refreshCredStoreState() { return creds.storeState(); }
3074
+
2849
3075
  function cycleTab(vm, dir) {
2850
3076
  const keys = [...vm.tabs.keys()];
2851
3077
  if (!keys.length) return;
@@ -2900,6 +3126,8 @@ if (!gotLock) {
2900
3126
  seedDemoLogins();
2901
3127
  if (!process.argv.includes('--capture')) return app.exit(0);
2902
3128
  }
3129
+ if (process.argv.includes('--cross-process-seed')) return runCrossProcess({ seed: true });
3130
+ if (process.argv.includes('--cross-process-read')) return runCrossProcess({ seed: false });
2903
3131
  if (process.argv.includes('--spike')) runSpike().catch(err => { console.error(err); app.exit(1); });
2904
3132
  else if (process.argv.includes('--smoke')) runSmoke().catch(err => { console.error(err); app.exit(1); });
2905
3133
  else if (process.argv.includes('--smoke-restore')) runSmokeRestore().catch(err => { console.error(err); app.exit(1); });