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.
- package/README.md +24 -10
- package/package.json +1 -1
- 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
|
-
-
|
|
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
|
|
297
|
-
|
|
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
|
|
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.
|
|
329
|
-
|
|
330
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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); });
|