my-provider-dash-manager 0.8.0 → 0.10.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 +51 -9
- package/package.json +2 -2
- package/src/main.js +838 -43
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
# My Provider Dash Manager —
|
|
1
|
+
# My Provider Dash Manager — every account. One window.
|
|
2
2
|
|
|
3
3
|
[](https://github.com/FiredMosquito831/my-provider-dash-manager/releases/latest)
|
|
4
|
+
[](https://providers.danubelabs.net)
|
|
4
5
|
[](https://www.npmjs.com/package/my-provider-dash-manager)
|
|
5
6
|
[](https://www.npmjs.com/package/my-provider-dash-manager)
|
|
6
7
|
[](https://github.com/FiredMosquito831/my-provider-dash-manager)
|
|
7
8
|
[](LICENSE)
|
|
9
|
+
[](#requirements)
|
|
8
10
|
|
|
9
11
|
**For freelancers, agencies and developers running client plus personal infrastructure:** one desktop
|
|
10
12
|
app for every account you own. Keep several Google, Microsoft, AWS, Vercel, Netlify, Cloudflare,
|
|
@@ -12,6 +14,10 @@ Supabase, GitHub, npm, Stripe, Gmail, Outlook and Proton Mail accounts (37 servi
|
|
|
12
14
|
site by URL) signed in at the same time, each in a fully isolated session, and switch between them
|
|
13
15
|
instantly — the way a browser handles tabs, but built around *accounts* instead of pages.
|
|
14
16
|
|
|
17
|
+
**Website:** [providers.danubelabs.net](https://providers.danubelabs.net) ·
|
|
18
|
+
**Install:** [`npx my-provider-dash-manager`](#install) or [GitHub Releases](https://github.com/FiredMosquito831/my-provider-dash-manager/releases) ·
|
|
19
|
+
**Docs:** [features](https://providers.danubelabs.net/features.html) · [security](https://providers.danubelabs.net/security.html) · [FAQ](https://providers.danubelabs.net/faq.html)
|
|
20
|
+
|
|
15
21
|
<sub>Windows desktop app · Electron 43 · local-first · no telemetry, no account, no server ·
|
|
16
22
|
source-available licence: free for personal and commercial use</sub>
|
|
17
23
|
|
|
@@ -26,7 +32,11 @@ saved-login controls. Install with `npx my-provider-dash-manager` or straight fr
|
|
|
26
32
|
|
|
27
33
|

|
|
28
34
|
|
|
29
|
-
<sub>Screenshots
|
|
35
|
+
<sub>Screenshots come from a demo profile on v0.6.x. The service-picker capture shows 27 services
|
|
36
|
+
because that was the registry at the time; the current build has 37. Account names are examples.
|
|
37
|
+
Current UI additions — the single Dark mode toggle and Manage logins — are in the
|
|
38
|
+
[changelog](docs/CHANGELOG.md).</sub>
|
|
39
|
+
|
|
30
40
|
|
|
31
41
|
---
|
|
32
42
|
|
|
@@ -110,7 +120,7 @@ sessions, one place.
|
|
|
110
120
|
|
|
111
121
|
---
|
|
112
122
|
|
|
113
|
-
##
|
|
123
|
+
## Quickstart
|
|
114
124
|
|
|
115
125
|
1. **Add an account.** Click **Add account** at the bottom of the rail (or the `+` next to a provider
|
|
116
126
|
you already use), pick the service from the searchable picker, name the account, then choose
|
|
@@ -180,13 +190,32 @@ guard trusts (`npm run smoke:services`). Google sign-in was verified against Goo
|
|
|
180
190
|
works in the app. Anything else can be added by URL or as a plugin.
|
|
181
191
|
|
|
182
192
|
### Password manager with per-service import
|
|
193
|
+
- **A picker for the site you are on.** A key chip beside the address bar counts the saved logins for
|
|
194
|
+
the page's current site; click it for a **searchable** list with **Use** (fills the email and
|
|
195
|
+
password fields and sets that account's default, with Undo), **Copy email** and **Copy password**.
|
|
196
|
+
The chip refreshes as you navigate and disappears when the site has none.
|
|
183
197
|
- **Remembers logins you type** inside an account tab, bound to *that* account, so two accounts on the
|
|
184
198
|
same service refill with different credentials.
|
|
185
|
-
- **
|
|
186
|
-
|
|
187
|
-
|
|
199
|
+
- **Your imported logins show up on every matching site.** The picker lists this account's own saved
|
|
200
|
+
logins *and* every imported login whose site matches the page, labelled with where it came from.
|
|
201
|
+
- **See each account's logins.** Open an account's **Manage** (gear) window and its *Saved logins* list
|
|
202
|
+
shows exactly the logins that belong to it, with **Use** to make one that account's default and fill
|
|
203
|
+
it into the open tab. A small key on the rail row marks every account that has a saved login, even
|
|
204
|
+
while its tab is asleep.
|
|
205
|
+
- **Auto-fills sign-in pages** when it recognises one (toggleable), and now **tells you**: a banner
|
|
206
|
+
above the page says what was filled, offers **Undo**, and asks you to choose when several logins
|
|
207
|
+
match instead of silently doing nothing. Any single account can opt out with **Never auto-fill for
|
|
208
|
+
this account** in its Manage window; the Home toggle stays the master switch.
|
|
209
|
+
- **Manage logins** from Home → Saved logins → *Manage logins…*: every stored login as its own row,
|
|
210
|
+
filterable by service or by account, with set/clear default and delete. No password is ever shown.
|
|
188
211
|
- **Choose which login fills:** when more than one matches a site the app asks instead of guessing,
|
|
189
212
|
and your choice is remembered as that account's default (marked ★ in the manager).
|
|
213
|
+
- **Copy without revealing:** copying an email or password puts it on the Windows clipboard from the
|
|
214
|
+
app's secured process. A password is never displayed and never sent to the window that draws the
|
|
215
|
+
interface, and a copy is refused unless the tab is on that login's own site.
|
|
216
|
+
- **Fills from anywhere** on the service's own site: if the page you are on has no matching login (a
|
|
217
|
+
dashboard, say), the account's login for that service is used. Filling on any other domain is
|
|
218
|
+
refused, and a fill that cannot happen now says why instead of failing quietly.
|
|
190
219
|
- **Re-importing updates changed passwords** instead of silently keeping the old one.
|
|
191
220
|
- **Imports existing passwords** from installed Chromium browsers (Chrome, Edge, Brave, Vivaldi,
|
|
192
221
|
Opera) or from a CSV exported by any browser, including Firefox.
|
|
@@ -340,8 +369,8 @@ npm start # run the app
|
|
|
340
369
|
npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
|
|
341
370
|
npm run smoke:restore # sessions survive a restart (real cookies)
|
|
342
371
|
npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
|
|
343
|
-
npm run smoke:creds # import, per-account
|
|
344
|
-
npm run smoke:autofill # real page: form detection, fill, capture, origin guard (
|
|
372
|
+
npm run smoke:creds # import, per-account/manager/site lists, defaults, copy, fill fallback, auto-fill plan, encryption at rest (50)
|
|
373
|
+
npm run smoke:autofill # real page: form detection, fill + request ids, undo, capture, origin guard (15)
|
|
345
374
|
npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
|
|
346
375
|
npm run smoke:services # every built-in login page loads inside an isolated partition (42)
|
|
347
376
|
npm run smoke:dark # Dark Reader darkens a white page, stays out of the page's own JS, re-injects after navigation (9)
|
|
@@ -353,13 +382,26 @@ npm start -- --print-paths # show the app name, version and data fold
|
|
|
353
382
|
npm start -- --open vercel::personal # open an account on launch
|
|
354
383
|
```
|
|
355
384
|
|
|
356
|
-
The suite is
|
|
385
|
+
The suite is 156 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
|
|
357
386
|
profile.
|
|
358
387
|
|
|
359
388
|
Releases are cut by pushing a `v*` tag: GitHub Actions checks the tag against `package.json`, builds
|
|
360
389
|
the NSIS installer, publishes the GitHub release with the update feed, and publishes the npm package
|
|
361
390
|
through npm trusted publishing (OIDC, with a provenance attestation — no tokens involved).
|
|
362
391
|
|
|
392
|
+
## Links
|
|
393
|
+
|
|
394
|
+
- **Website:** [providers.danubelabs.net](https://providers.danubelabs.net)
|
|
395
|
+
- **Features:** [providers.danubelabs.net/features.html](https://providers.danubelabs.net/features.html)
|
|
396
|
+
- **Install & requirements:** [providers.danubelabs.net/install.html](https://providers.danubelabs.net/install.html)
|
|
397
|
+
- **Security & privacy:** [providers.danubelabs.net/security.html](https://providers.danubelabs.net/security.html)
|
|
398
|
+
- **FAQ:** [providers.danubelabs.net/faq.html](https://providers.danubelabs.net/faq.html)
|
|
399
|
+
- **Changelog:** [providers.danubelabs.net/changelog.html](https://providers.danubelabs.net/changelog.html) · [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
|
400
|
+
- **Source & issues:** [github.com/FiredMosquito831/my-provider-dash-manager](https://github.com/FiredMosquito831/my-provider-dash-manager)
|
|
401
|
+
- **Releases:** [latest installer](https://github.com/FiredMosquito831/my-provider-dash-manager/releases/latest)
|
|
402
|
+
- **npm:** [my-provider-dash-manager](https://www.npmjs.com/package/my-provider-dash-manager)
|
|
403
|
+
- **Design notes:** [DESIGN.md](DESIGN.md)
|
|
404
|
+
|
|
363
405
|
## Licence
|
|
364
406
|
|
|
365
407
|
Source-available, © 2026 FiredMosquito831 — see [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-provider-dash-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": {
|
|
@@ -115,4 +115,4 @@
|
|
|
115
115
|
"engines": {
|
|
116
116
|
"node": ">=18"
|
|
117
117
|
}
|
|
118
|
-
}
|
|
118
|
+
}
|
package/src/main.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
const { app, BaseWindow, BrowserWindow, WebContentsView, session, ipcMain, shell, Menu } = require('electron');
|
|
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 crypto = require('crypto');
|
|
4
5
|
const SERVICES = require('./services');
|
|
5
6
|
const tokens = require('./api-tokens');
|
|
6
7
|
const { PROVIDERS } = require('./providers');
|
|
@@ -14,6 +15,9 @@ const statusCache = new Map(); // accountKey -> { summary?, error?, fetchedAt }
|
|
|
14
15
|
|
|
15
16
|
const TAB_STRIP_H = 48;
|
|
16
17
|
const RAIL_W = 240; // left account rail — account views must never cover it (must match --rail in ui.html)
|
|
18
|
+
const BANNER_H = 52; // auto-fill notice strip above a view (must match .acctnotice height in ui.html)
|
|
19
|
+
const AUTO_FILL_DELAY_MS = 450; // settle time after the form report before auto-filling (SPA forms render late)
|
|
20
|
+
const FILL_TIMEOUT_MS = 6000; // a page that never answers (it navigated mid-fill) must not hang the caller
|
|
17
21
|
const SETTLE_MS = Number(process.env.SPIKE_SETTLE_MS || 8000);
|
|
18
22
|
const COLORS = ['#e5484d', '#f76b15', '#ffc53d', '#46a758', '#00a2c7', '#4f8cff', '#8e4ec6', '#e93d82', '#6e56cf', '#00b0a0'];
|
|
19
23
|
const permissionConfigured = new WeakSet(); // one permission policy per partition session
|
|
@@ -141,6 +145,210 @@ function originAllowed(svcKey, url) {
|
|
|
141
145
|
return serviceHosts(svcKey).some(h => creds.hostRelated(host, h));
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
// A display name for a service key, for honest user-facing messages ("No saved login for this
|
|
149
|
+
// Vercel account yet") without leaking anything.
|
|
150
|
+
function svcOfName(key) {
|
|
151
|
+
const s = serviceByKey(key);
|
|
152
|
+
return (s && s.name) || key;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Summaries for an explicit candidate set (the fill fallback resolves across several service hosts,
|
|
156
|
+
// so creds.candidateSummariesForAccount's single-host lookup cannot be used for the chooser).
|
|
157
|
+
function summariesOf(candidates, svc, id) {
|
|
158
|
+
const key = accountKey(svc, id);
|
|
159
|
+
return candidates.map(c => ({
|
|
160
|
+
id: c.id, accountKey: c.accountKey || null, host: c.host || '',
|
|
161
|
+
username: c.username || '', source: c.source || 'unknown',
|
|
162
|
+
isDefault: (c.defaultForAccounts || []).includes(key),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------- saved-login summaries (0.9.0) ----------
|
|
167
|
+
// Every projection below is summary-only: id / username / host / source / ★. Passwords stay in
|
|
168
|
+
// src/credentials.js and never reach this renderer (invariant 3).
|
|
169
|
+
//
|
|
170
|
+
// NOT cached. loadAll() runs the one-time migration that saves the decrypted list back through
|
|
171
|
+
// safeStorage; on the very first call of a fresh profile that save can throw (the file was created
|
|
172
|
+
// moments earlier), and credentials.js then answers [] for that call. Caching that answer made the
|
|
173
|
+
// manager permanently empty until a restart (found on the seeded demo profile: 10 logins on disk,
|
|
174
|
+
// 0 in the UI, because the first emitState ran during window construction). Reading per call costs
|
|
175
|
+
// one DPAPI decrypt against a small JSON file, which is the honest trade for never showing a stale
|
|
176
|
+
// empty list.
|
|
177
|
+
function credSummaries() { return creds.listCredentials(); }
|
|
178
|
+
|
|
179
|
+
// The logins that belong to ONE account: its own captures plus imported logins whose site is one of
|
|
180
|
+
// the service's own hosts, with ★ marking the account's explicit default. A capture made by a
|
|
181
|
+
// DIFFERENT account of the same service is deliberately excluded — it is that account's login.
|
|
182
|
+
function accountLoginSummaries(svc, id, list) {
|
|
183
|
+
const key = accountKey(svc, id);
|
|
184
|
+
const hosts = serviceHosts(svc);
|
|
185
|
+
return (list || credSummaries())
|
|
186
|
+
.filter(c => (c.accountKey
|
|
187
|
+
? c.accountKey === key
|
|
188
|
+
: !!c.host && hosts.some(h => creds.hostRelated(c.host, h))))
|
|
189
|
+
.map(c => ({
|
|
190
|
+
id: c.id, username: c.username || '', host: c.host || '', source: c.source || 'unknown',
|
|
191
|
+
own: c.accountKey === key, isDefault: (c.defaultForAccounts || []).includes(key),
|
|
192
|
+
}))
|
|
193
|
+
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || Number(b.own) - Number(a.own)
|
|
194
|
+
|| String(a.username).localeCompare(String(b.username)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The manager list: every stored login, classified by service, plus the accounts it can serve and
|
|
198
|
+
// the ★ marker for each. `accounts` is computed in main so the renderer never guesses, and every
|
|
199
|
+
// mutation returns this fresh list — that is what keeps ★ in sync (0.8.0 juggled it client-side).
|
|
200
|
+
function managerRows(list) {
|
|
201
|
+
return (list || credSummaries()).map(c => {
|
|
202
|
+
const accountSvc = c.accountKey ? c.accountKey.split('::')[0] : null;
|
|
203
|
+
const serviceKeys = accountSvc ? [accountSvc] : allServices()
|
|
204
|
+
.filter(s => serviceHosts(s.key).some(h => creds.hostRelated(c.host, h)))
|
|
205
|
+
.map(s => s.key);
|
|
206
|
+
const accounts = (c.accountKey
|
|
207
|
+
? store.accounts.filter(a => accountKey(a.svc, a.id) === c.accountKey)
|
|
208
|
+
: store.accounts.filter(a => serviceKeys.includes(a.svc)))
|
|
209
|
+
.map(a => ({ key: accountKey(a.svc, a.id), svc: a.svc, id: a.id, label: a.label || a.id }));
|
|
210
|
+
return {
|
|
211
|
+
id: c.id, host: c.host || '', username: c.username || '', source: c.source || 'unknown',
|
|
212
|
+
importedAt: c.importedAt || null, accountKey: c.accountKey || null,
|
|
213
|
+
defaultForAccounts: Array.isArray(c.defaultForAccounts) ? c.defaultForAccounts.slice() : [],
|
|
214
|
+
serviceKeys, accounts,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Remember a login as this account's default after a Use/fill. credentials.js validates a default
|
|
220
|
+
// against ONE host hint, and the fill call site may be a dashboard while the login belongs to the
|
|
221
|
+
// service's sign-in host (the case "Use from the manager" exists for). So pick the host of the
|
|
222
|
+
// service whose candidate set actually contains the credential — never an arbitrary host, and never
|
|
223
|
+
// one outside serviceHosts() (the D14 gate).
|
|
224
|
+
function useAsDefault(svc, id, credentialId) {
|
|
225
|
+
const hosts = serviceHosts(svc);
|
|
226
|
+
const host = hosts.find(h => creds.candidatesForAccount(svc, id, `https://${h}/`).some(c => c.id === credentialId)) || hosts[0] || '';
|
|
227
|
+
creds.setDefaultForAccount(credentialId, svc, id, host);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------- per-site picker (0.10.0) ----------
|
|
231
|
+
// What the key chip in the address strip counts, and what its popover lists: everything that could
|
|
232
|
+
// sign you in on the host the tab is ON right now — this account's own captures for that host, plus
|
|
233
|
+
// every SHARED import whose host relates to it (the owner's "all my google passes on any google
|
|
234
|
+
// login field"; imports are not owned by an account, so they are the same list for every account).
|
|
235
|
+
// A capture made by a DIFFERENT account of the same service is deliberately NOT here: invariant 1
|
|
236
|
+
// binds a credential to its account AND origin, and 0.9.0's accountLoginSummaries draws the same
|
|
237
|
+
// boundary ("it is that account's login"). Widening this to siblings would let one account's tab
|
|
238
|
+
// read another account's saved password on a shared host — the account half of D14.
|
|
239
|
+
// Summaries only: id / username / host / source / ★.
|
|
240
|
+
function siteLoginRows(svc, id, liveUrl, list) {
|
|
241
|
+
const host = creds.hostOf(liveUrl || '');
|
|
242
|
+
if (!host) return { host: '', rows: [] };
|
|
243
|
+
const key = accountKey(svc, id);
|
|
244
|
+
const rows = (list || credSummaries())
|
|
245
|
+
.filter(c => c.host && creds.hostRelated(c.host, host))
|
|
246
|
+
.filter(c => !c.accountKey || c.accountKey === key)
|
|
247
|
+
.map(c => {
|
|
248
|
+
// An import has no owning account, so its label is null; if a credential ever carries another
|
|
249
|
+
// account's key it would be named here (the projection stays honest either way).
|
|
250
|
+
const owner = c.accountKey && c.accountKey !== key ? store.accounts.find(a => accountKey(a.svc, a.id) === c.accountKey) : null;
|
|
251
|
+
return {
|
|
252
|
+
id: c.id, username: c.username || '', host: c.host || '', source: c.source || 'unknown',
|
|
253
|
+
own: c.accountKey === key, accountLabel: owner ? (owner.label || owner.id) : null,
|
|
254
|
+
isDefault: (c.defaultForAccounts || []).includes(key),
|
|
255
|
+
};
|
|
256
|
+
})
|
|
257
|
+
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || Number(b.own) - Number(a.own)
|
|
258
|
+
|| String(a.username).localeCompare(String(b.username)));
|
|
259
|
+
return { host, rows };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Copying a saved password is the one operation that touches the secret outside a fill. It stays in
|
|
263
|
+
// the MAIN process: the value goes from credentials.js straight to the OS clipboard, and the caller
|
|
264
|
+
// gets a label, never the value (invariant 3 holds and is not weakened by a reveal path).
|
|
265
|
+
// `svc`/`id`/`liveUrl` are supplied by the IPC wrapper from the ACTIVE TAB (never from the renderer),
|
|
266
|
+
// so a copy can only ever happen on the tab the picker belongs to.
|
|
267
|
+
function copyCredentialField(credentialId, field, svc, id, liveUrl) {
|
|
268
|
+
if (field !== 'username' && field !== 'password') return { ok: false, reason: 'bad-field', message: 'Unknown field.' };
|
|
269
|
+
const liveHost = creds.hostOf(liveUrl || '');
|
|
270
|
+
// The same gate every fill passes (D14): the tab must be ON one of this service's own sites...
|
|
271
|
+
if (!originAllowed(svc, liveUrl)) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false, reason: 'foreign-host',
|
|
274
|
+
message: `This tab is on ${liveHost || 'another site'}, not ${serviceHosts(svc)[0] || 'the service'} — refusing to copy a saved password here.`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const key = accountKey(svc, id);
|
|
278
|
+
// ...and the credential must be one THIS account may use on this page: its own capture for the
|
|
279
|
+
// live site, or a shared import for it. Another account's capture is refused here even though the
|
|
280
|
+
// picker never lists it — the account half of invariant 1 (a capture belongs to its account).
|
|
281
|
+
const cred = creds.loadAll().find(c => c.id === credentialId);
|
|
282
|
+
if (!cred) return { ok: false, reason: 'unknown-id', message: 'That saved login no longer exists.' };
|
|
283
|
+
if (cred.accountKey && cred.accountKey !== key) return { ok: false, reason: 'other-account', message: 'That saved login belongs to a different account.' };
|
|
284
|
+
if (!cred.host || !creds.hostRelated(cred.host, liveHost)) return { ok: false, reason: 'other-site', message: 'That saved login belongs to a different site.' };
|
|
285
|
+
const value = field === 'password' ? cred.password : cred.username;
|
|
286
|
+
if (!value) return { ok: false, reason: 'empty', message: `That saved login has no ${field} stored.` };
|
|
287
|
+
clipboard.writeText(value);
|
|
288
|
+
return { ok: true, field, label: `${field === 'password' ? 'password' : 'email'} for ${cred.username || cred.host || 'this login'}` };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Explicit clear semantics for creds-default (0.8.0 relied on the "null id never matches" side
|
|
292
|
+
// effect). A null id may ONLY clear: it never sets a default anywhere, and it needs the account it
|
|
293
|
+
// applies to. Returns the authoritative manager list either way.
|
|
294
|
+
function setOrClearDefault(credentialId, svc, id, hostHint) {
|
|
295
|
+
if (credentialId == null) {
|
|
296
|
+
if (!svc || !id) throw new Error('Clearing a default needs the account it applies to');
|
|
297
|
+
creds.setDefaultForAccount(null, svc, id, hostHint);
|
|
298
|
+
} else {
|
|
299
|
+
creds.setDefaultForAccount(credentialId, svc, id, hostHint);
|
|
300
|
+
}
|
|
301
|
+
return managerRows();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The D30 resolution ladder over an explicit candidate set (main-process copy, used only when the
|
|
305
|
+
// tab's live host matched nothing and we resolve against the service's hosts). Explicit default
|
|
306
|
+
// first, then this account's single own capture, then a single imported match; anything ambiguous
|
|
307
|
+
// resolves to null so the UI asks instead of guessing. smoke:creds pins this to the identical
|
|
308
|
+
// outcomes of creds.credentialForAccount() on a single host.
|
|
309
|
+
function resolveAmong(candidates, accountKeyValue, credentialId) {
|
|
310
|
+
if (credentialId) return candidates.find(c => c.id === credentialId) || null;
|
|
311
|
+
const preferred = candidates.find(c => (c.defaultForAccounts || []).includes(accountKeyValue));
|
|
312
|
+
if (preferred) return preferred;
|
|
313
|
+
const own = candidates.filter(c => c.accountKey === accountKeyValue);
|
|
314
|
+
if (own.length === 1) return own[0];
|
|
315
|
+
if (own.length > 1) return null;
|
|
316
|
+
const imported = candidates.filter(c => !c.accountKey);
|
|
317
|
+
return imported.length === 1 ? imported[0] : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Candidates for a fill: the tab's LIVE host first (unchanged, D14). When the live page matches no
|
|
321
|
+
// saved login (a dashboard) fall back to this account's logins for the SERVICE's own hosts — still
|
|
322
|
+
// host-gated: every host here comes from serviceHosts(), and the call site refuses a live URL that
|
|
323
|
+
// is not a service host before this runs.
|
|
324
|
+
function fillCandidatesFor(svc, id, pageUrl) {
|
|
325
|
+
const live = creds.candidatesForAccount(svc, id, pageUrl);
|
|
326
|
+
if (live.length) return { candidates: live, source: 'live' };
|
|
327
|
+
const byId = new Map();
|
|
328
|
+
for (const host of serviceHosts(svc)) {
|
|
329
|
+
for (const c of creds.candidatesForAccount(svc, id, `https://${host}/`)) byId.set(c.id, c);
|
|
330
|
+
}
|
|
331
|
+
const candidates = [...byId.values()]
|
|
332
|
+
.sort((a, b) => Number(b.accountKey === accountKey(svc, id)) - Number(a.accountKey === accountKey(svc, id)));
|
|
333
|
+
return { candidates, source: candidates.length ? 'service' : 'none' };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// What the automatic path should do when a sign-in form appears. `skip` reasons are honest: the
|
|
337
|
+
// master toggle is off, this account opted out, the page is not the service's own site, or there is
|
|
338
|
+
// simply nothing saved. `choose` replaces 0.8.0's silent skip when several logins match (D30).
|
|
339
|
+
function autoFillPlan(svc, id, url, opts = {}) {
|
|
340
|
+
const settings = opts.settings || store.settings;
|
|
341
|
+
const account = opts.account !== undefined ? opts.account : findAccount(svc, id);
|
|
342
|
+
if (!settings || !settings.autofill) return { kind: 'skip', reason: 'autofill-off' };
|
|
343
|
+
if (account && account.noAutoFill) return { kind: 'skip', reason: 'account-opt-out' };
|
|
344
|
+
if (!originAllowed(svc, url)) return { kind: 'skip', reason: 'foreign-host' };
|
|
345
|
+
const cred = creds.credentialForAccount(svc, id, url);
|
|
346
|
+
if (cred) return { kind: 'fill', credentialId: cred.id, username: cred.username };
|
|
347
|
+
const choices = creds.candidateSummariesForAccount(svc, id, url);
|
|
348
|
+
if (choices.length > 1) return { kind: 'choose', choices };
|
|
349
|
+
return { kind: 'skip', reason: choices.length ? 'unresolved' : 'no-login' };
|
|
350
|
+
}
|
|
351
|
+
|
|
144
352
|
// ---------- view manager ----------
|
|
145
353
|
class ViewManager {
|
|
146
354
|
constructor() {
|
|
@@ -202,7 +410,52 @@ class ViewManager {
|
|
|
202
410
|
|
|
203
411
|
setBounds(b) {
|
|
204
412
|
this.contentBounds = b;
|
|
205
|
-
for (const t of this.tabs.values()) if (t.visible) t.view.setBounds(
|
|
413
|
+
for (const t of this.tabs.values()) if (t.visible) t.view.setBounds(this.boundsFor(t));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// A tab carrying an auto-fill notice reserves room at the top of its view for the banner. The
|
|
417
|
+
// banner lives in the chrome renderer (the view itself is a native child and cannot hold DOM),
|
|
418
|
+
// so the view starts lower for exactly as long as the notice exists (0.9.0).
|
|
419
|
+
boundsFor(t) {
|
|
420
|
+
const b = this.contentBounds;
|
|
421
|
+
if (!t.notice) return b;
|
|
422
|
+
return { ...b, y: b.y + BANNER_H, height: Math.max(0, b.height - BANNER_H) };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The per-tab notice the renderer paints above the account view. Always a summary: never a
|
|
426
|
+
// password (the username is the only credential-derived value, as in 0.8.0's chooser).
|
|
427
|
+
setNotice(key, notice) {
|
|
428
|
+
const t = this.tabs.get(key);
|
|
429
|
+
if (!t) return;
|
|
430
|
+
t.notice = notice || null;
|
|
431
|
+
if (this.window && this.window.webContents && !this.window.webContents.isDestroyed()) {
|
|
432
|
+
this.window.webContents.send('account-notice', { key, notice: t.notice });
|
|
433
|
+
}
|
|
434
|
+
if (t.visible) t.view.setBounds(this.boundsFor(t));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Hide the banner; the view reclaims the strip immediately.
|
|
438
|
+
clearNotice(key) {
|
|
439
|
+
const t = this.tabs.get(key);
|
|
440
|
+
if (!t || !t.notice) return;
|
|
441
|
+
t.notice = null;
|
|
442
|
+
if (this.window && this.window.webContents && !this.window.webContents.isDestroyed()) {
|
|
443
|
+
this.window.webContents.send('account-notice', { key, notice: null });
|
|
444
|
+
}
|
|
445
|
+
if (t.visible) t.view.setBounds(this.boundsFor(t));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Resolve one in-flight fill request for a tab. The preload reports a requestId so a result can
|
|
449
|
+
// never be attributed to the wrong request (or a page that has since navigated).
|
|
450
|
+
settleFill(key, result) {
|
|
451
|
+
const t = this.tabs.get(key);
|
|
452
|
+
if (!t) return null;
|
|
453
|
+
const entry = t.pendingFills.get(result && result.requestId);
|
|
454
|
+
if (!entry) return null;
|
|
455
|
+
t.pendingFills.delete(result.requestId);
|
|
456
|
+
clearTimeout(entry.timer);
|
|
457
|
+
entry.resolve(result);
|
|
458
|
+
return entry;
|
|
206
459
|
}
|
|
207
460
|
|
|
208
461
|
setModalOpen(open) {
|
|
@@ -212,7 +465,7 @@ class ViewManager {
|
|
|
212
465
|
for (const t of this.tabs.values()) if (t.visible) { t.view.setVisible(false); t.visible = false; }
|
|
213
466
|
} else if (this.activeKey && this.tabs.has(this.activeKey)) {
|
|
214
467
|
const t = this.tabs.get(this.activeKey);
|
|
215
|
-
t.view.setBounds(this.
|
|
468
|
+
t.view.setBounds(this.boundsFor(t));
|
|
216
469
|
t.view.setVisible(true);
|
|
217
470
|
t.visible = true;
|
|
218
471
|
}
|
|
@@ -265,7 +518,7 @@ class ViewManager {
|
|
|
265
518
|
registerAccount(svc, id, label || id);
|
|
266
519
|
const acc = findAccount(svc, id);
|
|
267
520
|
const view = this.makeView(svc, id);
|
|
268
|
-
const tab = { view, svc, id, url: url || service.dashboardUrl, visible: false, title: '', lastState: acc.lastState || 'unknown', lastActivated: Date.now() };
|
|
521
|
+
const tab = { view, svc, id, url: url || service.dashboardUrl, visible: false, title: '', lastState: acc.lastState || 'unknown', lastActivated: Date.now(), notice: null, pendingFills: new Map() };
|
|
269
522
|
this.tabs.set(key, tab);
|
|
270
523
|
this.trackSession(svc, id, tab.url); // tab joins the remembered open set
|
|
271
524
|
this.window.contentView.addChildView(view);
|
|
@@ -287,6 +540,9 @@ class ViewManager {
|
|
|
287
540
|
// isolated-world reset requires — the world (and Dark Reader in it) is gone by then and must
|
|
288
541
|
// be re-injected. Same-document (pushState) routing never reaches here, so it never re-injects.
|
|
289
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):
|
|
544
|
+
// refresh the address strip's key chip from the URL that actually finished loading (0.10.0).
|
|
545
|
+
this.emitState();
|
|
290
546
|
});
|
|
291
547
|
const onNav = (_e, url2) => {
|
|
292
548
|
tab.url = url2;
|
|
@@ -304,7 +560,13 @@ class ViewManager {
|
|
|
304
560
|
// document's styles exist and after every cross-origin navigation, which is exactly when the
|
|
305
561
|
// isolated-world engine needs (re-)injecting. did-navigate-in-page (pushState) keeps the world,
|
|
306
562
|
// so it deliberately does not re-inject.
|
|
307
|
-
view.webContents.on('did-navigate',
|
|
563
|
+
view.webContents.on('did-navigate', (_e, url2) => {
|
|
564
|
+
// A filled-forms notice describes fields that no longer exist after navigating away. Dismiss it
|
|
565
|
+
// so a stale "Undo" can never act on the new page. (Did-navigate-in-page deliberately does not,
|
|
566
|
+
// or a client-side redirect right after fill would immediately drop its own notice.)
|
|
567
|
+
this.clearNotice(key);
|
|
568
|
+
onNav(_e, url2);
|
|
569
|
+
});
|
|
308
570
|
view.webContents.on('did-navigate-in-page', onNav); // dashboards route client-side (pushState) — must track too
|
|
309
571
|
view.webContents.on('did-start-loading', () => this.emitState());
|
|
310
572
|
view.webContents.on('did-stop-loading', () => this.emitState());
|
|
@@ -334,11 +596,14 @@ class ViewManager {
|
|
|
334
596
|
if (!tab) return null;
|
|
335
597
|
for (const [k, t] of this.tabs) {
|
|
336
598
|
const vis = k === key && !this.modalOpen; // while a modal is open, views stay hidden
|
|
337
|
-
if (vis && !t.visible) { t.view.setBounds(this.
|
|
599
|
+
if (vis && !t.visible) { t.view.setBounds(this.boundsFor(t)); t.view.setVisible(true); t.visible = true; }
|
|
338
600
|
else if (!vis && t.visible) { t.view.setVisible(false); t.visible = false; }
|
|
339
601
|
}
|
|
340
602
|
tab.lastActivated = Date.now();
|
|
341
603
|
this.activeKey = key;
|
|
604
|
+
// Any banner left on a tab the user has now left is stale (Undo would target a page they are no
|
|
605
|
+
// longer looking at). Banner lifetime follows attention: leaving the tab dismisses it.
|
|
606
|
+
for (const [k] of this.tabs) if (k !== key) this.clearNotice(k);
|
|
342
607
|
const se = this.sessionEntry(tab.svc, tab.id);
|
|
343
608
|
if (se) { se.lastActivated = tab.lastActivated; se.url = tab.url; }
|
|
344
609
|
this.persistSession();
|
|
@@ -387,6 +652,7 @@ class ViewManager {
|
|
|
387
652
|
hibernate(key) {
|
|
388
653
|
const tab = this.tabs.get(key);
|
|
389
654
|
if (!tab) return;
|
|
655
|
+
for (const entry of tab.pendingFills.values()) { clearTimeout(entry.timer); entry.resolve({ ok: false, reason: 'tab-closed' }); }
|
|
390
656
|
try { this.window.contentView.removeChildView(tab.view); } catch {}
|
|
391
657
|
try { tab.view.webContents.close(); } catch {} // separate: a dead contentView must not skip closing the renderer
|
|
392
658
|
this.tabs.delete(key);
|
|
@@ -412,6 +678,7 @@ class ViewManager {
|
|
|
412
678
|
async deleteAccount(svc, id) {
|
|
413
679
|
const key = accountKey(svc, id);
|
|
414
680
|
if (this.tabs.has(key)) {
|
|
681
|
+
for (const entry of this.tabs.get(key).pendingFills.values()) { clearTimeout(entry.timer); entry.resolve({ ok: false, reason: 'tab-closed' }); }
|
|
415
682
|
try { this.window.contentView.removeChildView(this.tabs.get(key).view); this.tabs.get(key).view.webContents.close(); } catch {}
|
|
416
683
|
this.tabs.delete(key);
|
|
417
684
|
}
|
|
@@ -444,14 +711,28 @@ class ViewManager {
|
|
|
444
711
|
|
|
445
712
|
emitState() {
|
|
446
713
|
if (!this.window || !this.window.webContents) return;
|
|
714
|
+
const loginList = credSummaries(); // one decryption per state emit, not one per account
|
|
715
|
+
// The key chip in the address strip counts saved logins for the ACTIVE tab's live host (0.10.0).
|
|
716
|
+
// Recomputed here, so it is fresh after every navigation and every tab activation, and it is
|
|
717
|
+
// computed for the active tab only — the other tabs refresh when they are activated.
|
|
718
|
+
let siteLogins = null;
|
|
719
|
+
const activeTab = this.activeKey ? this.tabs.get(this.activeKey) : null;
|
|
720
|
+
if (activeTab && !activeTab.view.webContents.isDestroyed()) {
|
|
721
|
+
const liveUrl = activeTab.view.webContents.getURL();
|
|
722
|
+
const { host, rows } = siteLoginRows(activeTab.svc, activeTab.id, liveUrl, loginList);
|
|
723
|
+
siteLogins = { key: this.activeKey, host, count: host ? rows.length : 0 };
|
|
724
|
+
}
|
|
447
725
|
this.window.webContents.send('tabs-state', {
|
|
448
726
|
activeKey: this.activeKey,
|
|
727
|
+
siteLogins,
|
|
449
728
|
warmLimit: store.settings.warmLimit,
|
|
450
729
|
registry: store.accounts.map(a => ({
|
|
451
730
|
...a,
|
|
452
731
|
key: accountKey(a.svc, a.id),
|
|
453
732
|
hasToken: !!tokens.tokenMeta(accountKey(a.svc, a.id)),
|
|
454
733
|
status: statusCache.get(accountKey(a.svc, a.id)) || null,
|
|
734
|
+
loginCount: accountLoginSummaries(a.svc, a.id, loginList).length, // "has saved login" indicator
|
|
735
|
+
noAutoFill: !!a.noAutoFill,
|
|
455
736
|
})), // live registry projection for the home grid (token presence + status summaries; never raw tokens)
|
|
456
737
|
groupTabs: !!store.settings.groupTabs,
|
|
457
738
|
collapsed: store.settings.collapsed || [],
|
|
@@ -779,6 +1060,193 @@ async function runSmokeCreds() {
|
|
|
779
1060
|
const raw = fs.readFileSync(path.join(app.getPath('userData'), 'credentials.json'), 'utf8');
|
|
780
1061
|
check('encrypted-at-rest', !raw.includes('hunter2') && !raw.includes('typed-pw'));
|
|
781
1062
|
|
|
1063
|
+
// ---------- 0.9.0: the per-account projection, the manager projection, clear semantics ----------
|
|
1064
|
+
// Rebuild a clean fixture: one captured login per account + two imports on the same site (the
|
|
1065
|
+
// ambiguity case the chooser exists for).
|
|
1066
|
+
creds.clearAll(); store.accounts = [];
|
|
1067
|
+
registerAccount('vercel', 'team-a', 'Team A');
|
|
1068
|
+
registerAccount('vercel', 'team-b', 'Team B');
|
|
1069
|
+
registerAccount('netlify', 'site', 'Site');
|
|
1070
|
+
creds.rememberForAccount('vercel', 'team-a', { url: 'https://vercel.com/login', username: 'a@example.com', password: 'pw-a' });
|
|
1071
|
+
creds.rememberForAccount('vercel', 'team-b', { url: 'https://vercel.com/login', username: 'b@example.com', password: 'pw-b' });
|
|
1072
|
+
const csv3 = path.join(app.getPath('temp'), `mam-creds3-${process.pid}.csv`);
|
|
1073
|
+
fs.writeFileSync(csv3, 'url,username,password\nhttps://vercel.com/login,shared@example.com,shared-pw\nhttps://vercel.com/login,carol@example.com,carol-pw\n');
|
|
1074
|
+
try {
|
|
1075
|
+
creds.importFromCsv(csv3);
|
|
1076
|
+
const aLogins = accountLoginSummaries('vercel', 'team-a');
|
|
1077
|
+
check('per-account-list-is-its-own-logins',
|
|
1078
|
+
aLogins.some(l => l.username === 'a@example.com') && aLogins.some(l => l.username === 'shared@example.com')
|
|
1079
|
+
&& !aLogins.some(l => l.username === 'b@example.com'),
|
|
1080
|
+
aLogins.map(l => `${l.username}${l.own ? '*' : ''}`).join(','));
|
|
1081
|
+
check('per-account-list-has-no-passwords',
|
|
1082
|
+
aLogins.every(l => !('password' in l))
|
|
1083
|
+
&& !JSON.stringify(aLogins).includes('pw-a') && !JSON.stringify(aLogins).includes('hero-pw'),
|
|
1084
|
+
`${aLogins.length} summaries`);
|
|
1085
|
+
const rows = managerRows();
|
|
1086
|
+
check('manager-list-carries-accounts-and-services',
|
|
1087
|
+
rows.length === 4
|
|
1088
|
+
&& rows.every(r => Array.isArray(r.accounts) && Array.isArray(r.serviceKeys) && !('password' in r))
|
|
1089
|
+
&& rows.find(r => r.username === 'a@example.com').accounts.map(a => a.key).join() === 'vercel::team-a',
|
|
1090
|
+
`${rows.length} rows`);
|
|
1091
|
+
check('manager-rows-have-no-passwords',
|
|
1092
|
+
rows.every(r => !('password' in r) && !JSON.stringify(r).includes('pw-a') && !JSON.stringify(r).includes('shared-pw')));
|
|
1093
|
+
// what emitState puts on a rail row: a count for the badge (and the account's opt-out flag)
|
|
1094
|
+
check('rail-badge-counts-per-account',
|
|
1095
|
+
accountLoginSummaries('vercel', 'team-a').length === 3 && accountLoginSummaries('netlify', 'site').length === 0,
|
|
1096
|
+
`team-a=${accountLoginSummaries('vercel', 'team-a').length} netlify=${accountLoginSummaries('netlify', 'site').length}`);
|
|
1097
|
+
|
|
1098
|
+
// set-default leaves OTHER rows' defaults untouched (the manager used to strip them client-side)
|
|
1099
|
+
const rowA = rows.find(r => r.username === 'a@example.com');
|
|
1100
|
+
const rowShared = rows.find(r => r.username === 'shared@example.com');
|
|
1101
|
+
setOrClearDefault(rowA.id, 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1102
|
+
setOrClearDefault(rowShared.id, 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1103
|
+
let now = managerRows();
|
|
1104
|
+
check('star-markers-come-from-main',
|
|
1105
|
+
now.find(r => r.username === 'a@example.com').defaultForAccounts.length === 0
|
|
1106
|
+
&& now.find(r => r.username === 'shared@example.com').defaultForAccounts.includes('vercel::team-a'),
|
|
1107
|
+
now.map(r => `${r.username}:${r.defaultForAccounts.join('|')}`).join(' '));
|
|
1108
|
+
// row B keeps ITS default while row A is re-pointed: one row's ★ never disturbs another's
|
|
1109
|
+
setOrClearDefault(now.find(r => r.username === 'b@example.com').id, 'vercel', 'team-b', 'https://vercel.com/login');
|
|
1110
|
+
setOrClearDefault(managerRows().find(r => r.username === 'carol@example.com').id, 'vercel', 'team-b', 'https://vercel.com/login');
|
|
1111
|
+
now = managerRows();
|
|
1112
|
+
check('set-default-leaves-other-rows-defaults-intact',
|
|
1113
|
+
now.find(r => r.username === 'b@example.com').defaultForAccounts.length === 0
|
|
1114
|
+
&& now.find(r => r.username === 'carol@example.com').defaultForAccounts.join() === 'vercel::team-b');
|
|
1115
|
+
|
|
1116
|
+
// null id = explicit CLEAR: clears that account's default and cannot set one anywhere
|
|
1117
|
+
setOrClearDefault(null, 'vercel', 'team-b', 'https://vercel.com/login');
|
|
1118
|
+
now = managerRows();
|
|
1119
|
+
check('clear-default-with-null-id-clears', now.every(r => !r.defaultForAccounts.includes('vercel::team-b')),
|
|
1120
|
+
now.map(r => `${r.username}:${r.defaultForAccounts.join('|')}`).join(' '));
|
|
1121
|
+
let threw = false;
|
|
1122
|
+
try { setOrClearDefault(null, '', '', ''); } catch { threw = true; }
|
|
1123
|
+
check('clear-default-needs-an-account', threw);
|
|
1124
|
+
// ...and the OTHER account's ★ survived that clear: clearing is per account, never global
|
|
1125
|
+
check('clear-default-is-scoped-to-one-account',
|
|
1126
|
+
managerRows().find(r => r.username === 'shared@example.com').defaultForAccounts.join() === 'vercel::team-a');
|
|
1127
|
+
setOrClearDefault(null, 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1128
|
+
check('clear-default-cannot-invent-a-default', managerRows().every(r => r.defaultForAccounts.length === 0));
|
|
1129
|
+
|
|
1130
|
+
// fill fallback: a service whose dashboard and sign-in sit on DIFFERENT hosts (Heroku:
|
|
1131
|
+
// dashboard.heroku.com vs id.heroku.com). A login saved for the sign-in host has no live-host
|
|
1132
|
+
// candidate on the dashboard — the fallback resolves it through the service's own hosts...
|
|
1133
|
+
creds.rememberForAccount('heroku', 'main', { url: 'https://id.heroku.com/login', username: 'hero@example.com', password: 'hero-pw' });
|
|
1134
|
+
const atDashboard = fillCandidatesFor('heroku', 'main', 'https://dashboard.heroku.com/apps');
|
|
1135
|
+
check('fill-fallback-resolves-same-service-login',
|
|
1136
|
+
atDashboard.source === 'service' && atDashboard.candidates.length === 1
|
|
1137
|
+
&& atDashboard.candidates[0].username === 'hero@example.com',
|
|
1138
|
+
`${atDashboard.source}:${atDashboard.candidates.map(c => c.username).join(',')}`);
|
|
1139
|
+
const atLogin = fillCandidatesFor('heroku', 'main', 'https://id.heroku.com/login');
|
|
1140
|
+
check('fill-live-host-preferred', atLogin.source === 'live' && atLogin.candidates.every(c => creds.hostRelated(c.host, 'id.heroku.com')));
|
|
1141
|
+
// ...but a FOREIGN live host is refused outright (D14): originAllowed is the gate every fill
|
|
1142
|
+
// passes first, and the fallback only ever draws from serviceHosts().
|
|
1143
|
+
check('fill-fallback-refuses-foreign-host',
|
|
1144
|
+
originAllowed('heroku', 'https://accounts.google.com/signin') === false
|
|
1145
|
+
&& originAllowed('heroku', 'https://dashboard.heroku.com.evil.example/') === false
|
|
1146
|
+
&& fillCandidatesFor('heroku', 'main', 'https://dashboard.heroku.com/apps').candidates
|
|
1147
|
+
.every(c => serviceHosts('heroku').some(h => creds.hostRelated(c.host, h))));
|
|
1148
|
+
// "Use from the manager" on a DASHBOARD: the login belongs to the sign-in host, so a default set
|
|
1149
|
+
// with the page's own live host would be refused by credentials.js. useAsDefault must pick the
|
|
1150
|
+
// service host that really holds the candidate.
|
|
1151
|
+
const heroId = fillCandidatesFor('heroku', 'main', 'https://dashboard.heroku.com/apps').candidates[0].id;
|
|
1152
|
+
let useOk = true; let useErr = '';
|
|
1153
|
+
try { useAsDefault('heroku', 'main', heroId); } catch (err) { useOk = false; useErr = err.message; }
|
|
1154
|
+
check('use-from-dashboard-sets-the-default', useOk
|
|
1155
|
+
&& managerRows().find(r => r.id === heroId).defaultForAccounts.includes('heroku::main'), useErr);
|
|
1156
|
+
// the fallback ladder must agree with the frozen credentials.js ladder on one host
|
|
1157
|
+
check('fallback-ladder-matches-frozen-ladder', ['team-a', 'team-b', 'nobody', 'team-c'].every(id => {
|
|
1158
|
+
const live = creds.credentialForAccount('vercel', id, 'https://vercel.com/login');
|
|
1159
|
+
const viaFallback = resolveAmong(fillCandidatesFor('vercel', id, 'https://vercel.com/login').candidates, accountKey('vercel', id), null);
|
|
1160
|
+
return (live && live.id) === (viaFallback && viaFallback.id);
|
|
1161
|
+
}));
|
|
1162
|
+
|
|
1163
|
+
// ---------- auto-fill plan: opt-out, ambiguity, masters ----------
|
|
1164
|
+
const teamA = findAccount('vercel', 'team-a');
|
|
1165
|
+
check('autofill-plan-fills-unambiguous', (() => {
|
|
1166
|
+
const plan = autoFillPlan('vercel', 'team-b', 'https://vercel.com/login');
|
|
1167
|
+
return plan.kind === 'fill' && plan.username === 'b@example.com';
|
|
1168
|
+
})(), JSON.stringify(autoFillPlan('vercel', 'team-b', 'https://vercel.com/login')));
|
|
1169
|
+
check('autofill-plan-honours-per-account-opt-out', (() => {
|
|
1170
|
+
const plan = autoFillPlan('vercel', 'team-a', 'https://vercel.com/login', { account: { noAutoFill: true } });
|
|
1171
|
+
return plan.kind === 'skip' && plan.reason === 'account-opt-out';
|
|
1172
|
+
})());
|
|
1173
|
+
check('autofill-plan-honours-global-master', (() => {
|
|
1174
|
+
const plan = autoFillPlan('vercel', 'team-a', 'https://vercel.com/login', { account: teamA, settings: { autofill: false } });
|
|
1175
|
+
return plan.kind === 'skip' && plan.reason === 'autofill-off';
|
|
1176
|
+
})());
|
|
1177
|
+
check('autofill-plan-refuses-foreign-host',
|
|
1178
|
+
autoFillPlan('vercel', 'team-a', 'https://accounts.google.com/signin', { account: teamA }).reason === 'foreign-host');
|
|
1179
|
+
check('autofill-plan-offers-chooser-on-ambiguity', (() => {
|
|
1180
|
+
const plan = autoFillPlan('vercel', 'team-c', 'https://vercel.com/login');
|
|
1181
|
+
return plan.kind === 'choose' && plan.choices.length === 2 && plan.choices.every(c => !('password' in c));
|
|
1182
|
+
})(), JSON.stringify(autoFillPlan('vercel', 'team-c', 'https://vercel.com/login')).slice(0, 140));
|
|
1183
|
+
check('autofill-plan-is-silent-with-nothing-saved',
|
|
1184
|
+
autoFillPlan('netlify', 'site', 'https://app.netlify.com/login').reason === 'no-login');
|
|
1185
|
+
check('choose-summaries-carry-no-passwords', (() => {
|
|
1186
|
+
const plan = autoFillPlan('vercel', 'team-c', 'https://vercel.com/login');
|
|
1187
|
+
return plan.kind === 'choose' && !JSON.stringify(plan.choices).includes('shared-pw') && !JSON.stringify(plan.choices).includes('carol-pw');
|
|
1188
|
+
})());
|
|
1189
|
+
|
|
1190
|
+
// ---------- 0.10.0: the per-site picker ----------
|
|
1191
|
+
// The fixture above: team-a holds a@example.com plus the two shared imports, team-b owns
|
|
1192
|
+
// b@example.com, netlify's account owns nothing. A picker on vercel.com for team-a shows the
|
|
1193
|
+
// account's own capture AND every shared import on the live host — never a sibling's capture
|
|
1194
|
+
// (invariant 1: a capture belongs to the account that made it).
|
|
1195
|
+
const siteA = siteLoginRows('vercel', 'team-a', 'https://vercel.com/login');
|
|
1196
|
+
check('site-list-includes-shared-imports-from-another-account',
|
|
1197
|
+
siteA.rows.map(r => r.username).sort().join() === ['a@example.com', 'carol@example.com', 'shared@example.com'].join()
|
|
1198
|
+
&& !siteA.rows.some(r => r.username === 'b@example.com'),
|
|
1199
|
+
`${siteA.host}: ${siteA.rows.map(r => `${r.username}${r.accountLabel ? `[${r.accountLabel}]` : r.own ? '[own]' : '[import]'}`).join(',')}`);
|
|
1200
|
+
check('site-list-rows-have-no-password-key',
|
|
1201
|
+
siteA.rows.every(r => !('password' in r) && !JSON.stringify(r).includes('shared-pw') && !JSON.stringify(r).includes('pw-a')),
|
|
1202
|
+
`${siteA.rows.length} rows: ${JSON.stringify(Object.keys(siteA.rows[0] || {}))}`);
|
|
1203
|
+
// An import is shared (no owning account); a capture made by another account is that account's
|
|
1204
|
+
// login and is neither listed nor copyable from here (invariant 1, the 0.9.0 boundary).
|
|
1205
|
+
check('site-list-scope-is-own-captures-plus-shared-imports',
|
|
1206
|
+
siteA.rows.filter(r => r.accountLabel === null).length === 3
|
|
1207
|
+
&& siteA.rows.find(r => r.username === 'a@example.com').own === true
|
|
1208
|
+
&& siteA.rows.filter(r => r.username === 'shared@example.com' || r.username === 'carol@example.com').every(r => r.own === false && r.accountLabel === null),
|
|
1209
|
+
siteA.rows.map(r => `${r.username}:${r.own ? 'own' : 'shared'}`).join(' '));
|
|
1210
|
+
// D14: a copy is refused when the tab is not on the service's own site ...
|
|
1211
|
+
const offSite = copyCredentialField(siteA.rows[0].id, 'password', 'vercel', 'team-a', 'https://accounts.google.com/signin');
|
|
1212
|
+
check('copy-refused-off-site', offSite.ok === false && offSite.reason === 'foreign-host', `reason=${offSite.reason}`);
|
|
1213
|
+
// ... and an unknown id never yields a secret
|
|
1214
|
+
const unknown = copyCredentialField('no-such-credential', 'password', 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1215
|
+
check('copy-refused-unknown-id', unknown.ok === false && unknown.reason === 'unknown-id', `reason=${unknown.reason}`);
|
|
1216
|
+
// a sibling account's capture is never available here, even on its own site (invariant 1)
|
|
1217
|
+
const otherAcct = creds.loadAll().find(c => c.accountKey === 'vercel::team-b');
|
|
1218
|
+
const sibling = copyCredentialField(otherAcct.id, 'password', 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1219
|
+
check('copy-refused-another-accounts-capture', sibling.ok === false && sibling.reason === 'other-account', `reason=${sibling.reason}`);
|
|
1220
|
+
clipboard.clear();
|
|
1221
|
+
// the real copy path: main writes the clipboard, and the answer is a label — never the secret
|
|
1222
|
+
clipboard.writeText('sentinel-before-copy');
|
|
1223
|
+
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');
|
|
1225
|
+
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}"`);
|
|
1231
|
+
const badField = copyCredentialField(sharedRow.id, 'reveal', 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1232
|
+
check('copy-refuses-an-unknown-field', badField.ok === false && badField.reason === 'bad-field', `reason=${badField.reason}`);
|
|
1233
|
+
clipboard.clear();
|
|
1234
|
+
// the chip counts exactly the rows the picker would list (main computes both)
|
|
1235
|
+
check('site-list-count-matches-the-rows',
|
|
1236
|
+
siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.length === 3
|
|
1237
|
+
&& siteLoginRows('vercel', 'team-c', 'https://example.com/').rows.length === 0,
|
|
1238
|
+
`team-a=${siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.length}`);
|
|
1239
|
+
// Use from the picker is the SAME 0.9.0 path: fill-login(key, id) sets the default, then fills.
|
|
1240
|
+
// Main-side we can prove the default half; the fill half is the existing Use contract.
|
|
1241
|
+
const pickRow = siteA.rows.find(r => r.username === 'carol@example.com');
|
|
1242
|
+
useAsDefault('vercel', 'team-a', pickRow.id);
|
|
1243
|
+
check('use-from-picker-sets-the-account-default',
|
|
1244
|
+
siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.find(r => r.id === pickRow.id).isDefault === true
|
|
1245
|
+
&& siteLoginRows('vercel', 'team-b', 'https://vercel.com/login').rows.find(r => r.id === pickRow.id).isDefault === false,
|
|
1246
|
+
managerRows().find(r => r.id === pickRow.id).defaultForAccounts.join() || 'none');
|
|
1247
|
+
setOrClearDefault(null, 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1248
|
+
} finally { try { fs.rmSync(csv3, { force: true }); } catch {} }
|
|
1249
|
+
|
|
782
1250
|
const st = creds.stats();
|
|
783
1251
|
check('stats', st.total >= 3, JSON.stringify(st.bySource));
|
|
784
1252
|
const browsers = creds.listChromiumProfiles();
|
|
@@ -834,12 +1302,44 @@ async function runSmokeAutofill() {
|
|
|
834
1302
|
// fill: main pushes a stored credential into the page
|
|
835
1303
|
creds.rememberForAccount('vercel', 'autofilltest', { url: 'https://vercel.com/login', username: 'me@example.com', password: 'p@ss-fill' });
|
|
836
1304
|
const cred = creds.forAccount('vercel', 'autofilltest', 'https://vercel.com/login');
|
|
837
|
-
|
|
1305
|
+
// 0.9.0: the answer is correlated by requestId, and main now WAITS for it — a fill is only
|
|
1306
|
+
// reported as done when the page really took the values.
|
|
1307
|
+
const fills = [];
|
|
1308
|
+
ipcMain.on('fill-result', (_e, r) => fills.push(r));
|
|
1309
|
+
const requestId = 'smoke-fill-1';
|
|
1310
|
+
tab.view.webContents.send('fill-credential', { requestId, username: cred.username, password: cred.password, submit: false });
|
|
838
1311
|
await sleep(800);
|
|
839
1312
|
const filled = await tab.view.webContents.executeJavaScript(
|
|
840
1313
|
`({ email: document.querySelector('[name=email]').value, pw: document.querySelector('[name=pw]').value })`);
|
|
841
1314
|
check('fill-username', filled.email === 'me@example.com', filled.email);
|
|
842
1315
|
check('fill-password', filled.pw === 'p@ss-fill', filled.pw ? '(set)' : '(empty)');
|
|
1316
|
+
check('fill-result-carries-request-id', fills.some(r => r.requestId === requestId && r.ok === true && r.user === true && r.password === true),
|
|
1317
|
+
JSON.stringify(fills[fills.length - 1] || null));
|
|
1318
|
+
|
|
1319
|
+
// undo: clear exactly what the fill put in, and nothing else (the user's own typing survives)
|
|
1320
|
+
await tab.view.webContents.executeJavaScript(`document.querySelector('[name=email]').value = 'me@example.com'`);
|
|
1321
|
+
const undoId = 'smoke-undo-1';
|
|
1322
|
+
tab.view.webContents.send('clear-filled', { requestId: undoId, fillId: requestId });
|
|
1323
|
+
await sleep(600);
|
|
1324
|
+
const afterUndo = await tab.view.webContents.executeJavaScript(
|
|
1325
|
+
`({ email: document.querySelector('[name=email]').value, pw: document.querySelector('[name=pw]').value })`);
|
|
1326
|
+
check('undo-clears-filled-fields', afterUndo.pw === '' && afterUndo.email === '', JSON.stringify(afterUndo));
|
|
1327
|
+
check('undo-reports-what-it-cleared', fills.some(r => r.requestId === undoId && r.ok === true && r.cleared >= 1),
|
|
1328
|
+
JSON.stringify(fills[fills.length - 1] || null));
|
|
1329
|
+
// a second undo of the same fill must be refused: the field references are gone, not "still filled"
|
|
1330
|
+
const undoAgain = 'smoke-undo-2';
|
|
1331
|
+
tab.view.webContents.send('clear-filled', { requestId: undoAgain, fillId: requestId });
|
|
1332
|
+
await sleep(400);
|
|
1333
|
+
check('undo-twice-is-refused', fills.some(r => r.requestId === undoAgain && r.ok === false && r.reason === 'nothing-filled'));
|
|
1334
|
+
// an undo naming a DIFFERENT fill must not clear this document's fields either
|
|
1335
|
+
const undoWrong = 'smoke-undo-3';
|
|
1336
|
+
tab.view.webContents.send('fill-credential', { requestId: 'smoke-fill-2', username: cred.username, password: cred.password, submit: false });
|
|
1337
|
+
await sleep(500);
|
|
1338
|
+
tab.view.webContents.send('clear-filled', { requestId: undoWrong, fillId: 'some-other-fill' });
|
|
1339
|
+
await sleep(400);
|
|
1340
|
+
const stillFilled = await tab.view.webContents.executeJavaScript(`document.querySelector('[name=pw]').value`);
|
|
1341
|
+
check('undo-refuses-a-stale-fill-id', stillFilled === 'p@ss-fill' && fills.some(r => r.requestId === undoWrong && r.ok === false),
|
|
1342
|
+
stillFilled ? '(untouched)' : '(cleared — wrong)');
|
|
843
1343
|
|
|
844
1344
|
// capture: submitting the form must hand the credentials back to main
|
|
845
1345
|
await tab.view.webContents.executeJavaScript(`(() => {
|
|
@@ -991,6 +1491,54 @@ async function runSmokeRestore() {
|
|
|
991
1491
|
app.exit(results.checks.every(c => c.ok) ? 0 : 1);
|
|
992
1492
|
}
|
|
993
1493
|
|
|
1494
|
+
// ---------- demo seed: placeholder accounts + logins for screenshots ----------
|
|
1495
|
+
// ONLY for capture runs against a scratch profile (MAM_USER_DATA). The names are invented; no real
|
|
1496
|
+
// account name or password is ever used or written (CLAUDE.md §5). Idempotent, so a capture can be
|
|
1497
|
+
// re-run against the same scratch dir.
|
|
1498
|
+
function seedDemoLogins() {
|
|
1499
|
+
const placements = [
|
|
1500
|
+
['vercel', 'work', 'demo.user.one@example.com'],
|
|
1501
|
+
['vercel', 'personal', 'demo.user.two@example.com'],
|
|
1502
|
+
['netlify', 'agency', 'demo.user.three@example.com'],
|
|
1503
|
+
['supabase', 'staging', 'demo.user.four@example.com'],
|
|
1504
|
+
['github', 'open-source', 'demo.user.five@example.com'],
|
|
1505
|
+
['cloudflare', 'domains', 'demo.user.six@example.com'],
|
|
1506
|
+
['railway', 'side-project', 'demo.user.seven@example.com'],
|
|
1507
|
+
['stripe', 'payments', 'demo.user.eight@example.com'],
|
|
1508
|
+
];
|
|
1509
|
+
for (const [svc, id, label] of placements) registerAccount(svc, id, label);
|
|
1510
|
+
// Seed the logins as well: on this machine safeStorage can encrypt in one process but not decrypt
|
|
1511
|
+
// what another process wrote (proved with a bare two-process round trip that never touches this
|
|
1512
|
+
// app, 2026-11-03), so a capture that only reads a previously seeded file would show 0 rows. The
|
|
1513
|
+
// seed runs FIRST, in the same process as the capture, so the screenshots show the real managers
|
|
1514
|
+
// with their real rows. Nothing outside the scratch profile is touched.
|
|
1515
|
+
// Imported-style logins (no accountKey) — the "all my logins imported" case the owner reported.
|
|
1516
|
+
// Written through the REAL CSV importer (the only import entry point credentials.js exports), so
|
|
1517
|
+
// this exercises the same merge rules a user's import does. Placeholder passwords only.
|
|
1518
|
+
const imports = [
|
|
1519
|
+
['vercel.com', 'demo.user.one@example.com'],
|
|
1520
|
+
['vercel.com', 'demo.user.two@example.com'],
|
|
1521
|
+
['app.netlify.com', 'demo.user.three@example.com'],
|
|
1522
|
+
['supabase.com', 'demo.user.four@example.com'],
|
|
1523
|
+
['github.com', 'demo.user.five@example.com'],
|
|
1524
|
+
['dash.cloudflare.com', 'demo.user.six@example.com'],
|
|
1525
|
+
['railway.com', 'demo.user.seven@example.com'],
|
|
1526
|
+
['dashboard.stripe.com', 'demo.user.eight@example.com'],
|
|
1527
|
+
];
|
|
1528
|
+
const csv = path.join(app.getPath('temp'), `mam-demo-${process.pid}.csv`);
|
|
1529
|
+
fs.writeFileSync(csv, 'url,username,password\n' + imports.map(([h, u]) => `https://${h}/login,${u},demo-placeholder-${u.length}\n`).join(''));
|
|
1530
|
+
try { creds.importFromCsv(csv); } finally { try { fs.rmSync(csv, { force: true }); } catch {} }
|
|
1531
|
+
// every account keeps its OWN captured login (so "saved for this account" is exercised too)
|
|
1532
|
+
for (const [svc, id, who] of [['vercel', 'work', 'demo.user.one@example.com'], ['github', 'open-source', 'demo.user.five@example.com']]) {
|
|
1533
|
+
creds.rememberForAccount(svc, id, { url: serviceByKey(svc).loginUrl, username: who, password: `demo-placeholder-${who.length}` });
|
|
1534
|
+
}
|
|
1535
|
+
const rows = creds.listCredentials();
|
|
1536
|
+
// one ★ so the manager shows the "Default for …" line, the way a real profile would
|
|
1537
|
+
const first = rows.find(c => c.host === 'vercel.com' && c.username === 'demo.user.two@example.com');
|
|
1538
|
+
if (first) creds.setDefaultForAccount(first.id, 'vercel', 'personal', 'https://vercel.com/login');
|
|
1539
|
+
console.log(`[demo] seeded ${store.accounts.length} accounts, ${creds.stats().total} logins`);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
994
1542
|
// ---------- interactive mode ----------
|
|
995
1543
|
function runInteractive(captureMode = false) {
|
|
996
1544
|
// BrowserWindow, not BaseWindow: the chrome page needs loadFile/webContents/ready-to-show, which
|
|
@@ -1025,7 +1573,16 @@ function runInteractive(captureMode = false) {
|
|
|
1025
1573
|
// --capture-scroll <px> scroll the Home grid down first
|
|
1026
1574
|
// --capture-wait <ms> extra settle time
|
|
1027
1575
|
// --capture-js "<expr>" run an expression in the chrome page (e.g. open a modal)
|
|
1028
|
-
|
|
1576
|
+
// Both "--flag value" and "--flag=value" are accepted. The "=" form matters on this machine:
|
|
1577
|
+
// an account key contains "::" and the Git Bash/MSYS layer mangles that in a separate argv
|
|
1578
|
+
// token (the process exits 127 before it starts), while "--capture-open=vercel::work" passes
|
|
1579
|
+
// through intact. Same value, same code path.
|
|
1580
|
+
const argVal = name => {
|
|
1581
|
+
const viaEq = process.argv.find(a => a.startsWith(`${name}=`));
|
|
1582
|
+
if (viaEq) return viaEq.slice(name.length + 1);
|
|
1583
|
+
const i = process.argv.indexOf(name);
|
|
1584
|
+
return i > -1 ? process.argv[i + 1] : null;
|
|
1585
|
+
};
|
|
1029
1586
|
const outFile = argVal('--capture-out') || 'ui-screenshot.png';
|
|
1030
1587
|
const openKey = argVal('--capture-open');
|
|
1031
1588
|
const scrollPx = Number(argVal('--capture-scroll') || 0);
|
|
@@ -1043,6 +1600,62 @@ function runInteractive(captureMode = false) {
|
|
|
1043
1600
|
}
|
|
1044
1601
|
if (scrollPx) await win.webContents.executeJavaScript(`document.getElementById('home').scrollTop = ${scrollPx}`).catch(() => {});
|
|
1045
1602
|
if (runJs) await win.webContents.executeJavaScript(runJs).catch(err => console.error('[capture] js failed:', err.message));
|
|
1603
|
+
// --demo-notice: drive the auto-fill banner exactly the way main does (through
|
|
1604
|
+
// autoFillPlan + vm.setNotice), so a screenshot shows the real banner path and not
|
|
1605
|
+
// hand-written DOM. Verification aid for a scratch demo profile.
|
|
1606
|
+
if (process.argv.includes('--demo-notice') && openKey) {
|
|
1607
|
+
const [svc, id] = openKey.split('::');
|
|
1608
|
+
const plan = autoFillPlan(svc, id, serviceByKey(svc).loginUrl, { account: findAccount(svc, id) });
|
|
1609
|
+
// On a demo profile the logins may be unreadable (DPAPI is per-launch here), so the
|
|
1610
|
+
// screenshot's banner uses the same shapes the real paths raise, with placeholder text.
|
|
1611
|
+
vm.setNotice(openKey, plan.kind === 'fill'
|
|
1612
|
+
? { kind: 'filled', username: plan.username, credentialId: plan.credentialId, fillId: 'demo' }
|
|
1613
|
+
: plan.kind === 'choose' ? { kind: 'choose', choices: plan.choices }
|
|
1614
|
+
: { kind: 'filled', username: 'demo.user.two@example.com', credentialId: 'demo-cred', fillId: 'demo' });
|
|
1615
|
+
console.log('[capture] demo notice:', plan.kind, (plan.choices || []).length);
|
|
1616
|
+
await new Promise(r => setTimeout(r, 700));
|
|
1617
|
+
}
|
|
1618
|
+
// --demo-picker: open the address-strip key chip's picker from MAIN (state → emitState → the
|
|
1619
|
+
// renderer's own click handler), so a screenshot shows the real popover and not hand-written
|
|
1620
|
+
// DOM. The chip is drawn from state.siteLogins, so one emitState is enough. Verification aid
|
|
1621
|
+
// for a scratch demo profile, like --demo-notice (0.9.0); it changes no shipped behaviour.
|
|
1622
|
+
if (process.argv.includes('--demo-picker') && openKey) {
|
|
1623
|
+
const key = openKey;
|
|
1624
|
+
if (vm.tabs.has(key)) vm.activate(key); // the chip only exists for the ACTIVE tab
|
|
1625
|
+
await new Promise(r => setTimeout(r, 800));
|
|
1626
|
+
vm.emitState();
|
|
1627
|
+
await new Promise(r => setTimeout(r, 600));
|
|
1628
|
+
if (win.webContents && !win.webContents.isDestroyed()) {
|
|
1629
|
+
await win.webContents.executeJavaScript('document.getElementById("keysite").click()').catch(err => console.error('[capture] picker click failed:', err.message));
|
|
1630
|
+
}
|
|
1631
|
+
const info = await win.webContents.executeJavaScript(`JSON.stringify({
|
|
1632
|
+
chip: document.getElementById('keysite').hidden ? 'hidden' : document.getElementById('keysite').innerText.trim(),
|
|
1633
|
+
open: !document.getElementById('keypicker').hidden,
|
|
1634
|
+
rows: document.querySelectorAll('#keypicker [data-kp-id]').length,
|
|
1635
|
+
search: !!document.getElementById('kp-q'),
|
|
1636
|
+
rowsMeta: [...document.querySelectorAll('#keypicker .kp-meta')].map(m => m.innerText.trim()).slice(0, 4),
|
|
1637
|
+
})`).catch(err => 'probe failed: ' + err.message);
|
|
1638
|
+
console.log('[capture] demo picker:', info);
|
|
1639
|
+
// --demo-picker-copied: type in the search box and press Copy password through the real
|
|
1640
|
+
// renderer → preload → main chain, then read the clipboard back from MAIN (the secret must
|
|
1641
|
+
// never appear in the renderer). Photographs the searched + "Copied ✓" state.
|
|
1642
|
+
if (process.argv.includes('--demo-picker-copied') && win.webContents && !win.webContents.isDestroyed()) {
|
|
1643
|
+
const wired = await win.webContents.executeJavaScript(`(async () => {
|
|
1644
|
+
const q = document.getElementById('kp-q');
|
|
1645
|
+
q.value = 'demo.user.two'; q.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1646
|
+
const visible = [...document.querySelectorAll('#keypicker [data-kp-id]')].filter(r => !r.hidden).length;
|
|
1647
|
+
const btn = [...document.querySelectorAll('#keypicker [data-kp-copy][data-kp-field="password"]')]
|
|
1648
|
+
.find(b => !b.closest('[data-kp-id]').hidden);
|
|
1649
|
+
if (!btn) return JSON.stringify({ filterVisible: visible, btnFound: false });
|
|
1650
|
+
btn.click();
|
|
1651
|
+
await new Promise(r => setTimeout(r, 400));
|
|
1652
|
+
return JSON.stringify({ filterVisible: visible, btnFound: true, btnText: btn.innerText.trim() });
|
|
1653
|
+
})()`).catch(err => 'wired probe failed: ' + err.message);
|
|
1654
|
+
console.log('[capture] demo picker wired:', wired, 'clipboard-is-a-demo-password:', clipboard.readText().startsWith('demo-placeholder-'));
|
|
1655
|
+
clipboard.clear();
|
|
1656
|
+
}
|
|
1657
|
+
await new Promise(r => setTimeout(r, 250));
|
|
1658
|
+
}
|
|
1046
1659
|
if (extraWait) await new Promise(r => setTimeout(r, extraWait));
|
|
1047
1660
|
// UnknownVizError / empty frames clear once the compositor produces a frame — retry
|
|
1048
1661
|
let img = null; let lastErr = null;
|
|
@@ -1250,73 +1863,229 @@ function runInteractive(captureMode = false) {
|
|
|
1250
1863
|
const found = tabForSender(e.sender);
|
|
1251
1864
|
if (!found) return;
|
|
1252
1865
|
found.tab.loginForm = !!(info && info.present);
|
|
1253
|
-
|
|
1254
|
-
if (found.tab.loginForm && store.settings.autofill) {
|
|
1866
|
+
if (found.tab.loginForm) {
|
|
1255
1867
|
const pageUrl = (info && info.url) || found.tab.view.webContents.getURL();
|
|
1256
|
-
|
|
1257
|
-
vm.emitState();
|
|
1258
|
-
return; // never auto-type a password into a page that is not the service's own site
|
|
1259
|
-
}
|
|
1260
|
-
const cred = creds.forAccount(found.tab.svc, found.tab.id, pageUrl);
|
|
1261
|
-
if (cred) e.sender.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
|
|
1868
|
+
scheduleAutoFill(found.key, found.tab, pageUrl);
|
|
1262
1869
|
}
|
|
1263
1870
|
vm.emitState();
|
|
1264
1871
|
});
|
|
1265
|
-
|
|
1872
|
+
// Fill outcomes from the account preload (0.9.0: the result was ignored in 0.8.0, so a manual fill
|
|
1873
|
+
// failed silently and an automatic one was invisible). Resolve the awaiting request, and for an
|
|
1874
|
+
// automatic fill raise the banner — "Filled … · Undo" or the honest reason it did not fill.
|
|
1875
|
+
ipcMain.on('fill-result', (e, result) => {
|
|
1876
|
+
const found = tabForSender(e.sender);
|
|
1877
|
+
if (!found) return;
|
|
1878
|
+
const entry = vm.settleFill(found.key, result || {});
|
|
1879
|
+
if (entry && entry.auto) noticeFromAutoFill(found.key, found.tab, entry.credentialId, result || {});
|
|
1880
|
+
});
|
|
1266
1881
|
|
|
1267
|
-
|
|
1882
|
+
// ---- auto-fill: plan -> fill -> announce (0.9.0 C) ----
|
|
1883
|
+
// The plan decides everything before anything is typed. It never guesses (D30) and it never fires
|
|
1884
|
+
// for an account whose "never auto-fill" switch is on. Runs a touch after the form report so a
|
|
1885
|
+
// script-rendered SPA form has settled.
|
|
1886
|
+
function scheduleAutoFill(key, tab, pageUrl) {
|
|
1887
|
+
const token = (tab.autoFillToken || 0) + 1;
|
|
1888
|
+
tab.autoFillToken = token;
|
|
1889
|
+
tab.loginPending = { url: pageUrl, at: Date.now() };
|
|
1890
|
+
setTimeout(() => {
|
|
1891
|
+
if (vm.tabs.get(key) !== tab || tab.autoFillToken !== token) return; // slept, recreated, or a newer form report
|
|
1892
|
+
autoFillNow(key, tab, pageUrl).catch(err => console.error('[logins] auto-fill failed:', err.message));
|
|
1893
|
+
}, AUTO_FILL_DELAY_MS);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
async function autoFillNow(key, tab, pageUrl) {
|
|
1897
|
+
const plan = autoFillPlan(tab.svc, tab.id, pageUrl);
|
|
1898
|
+
if (plan.kind === 'skip') {
|
|
1899
|
+
// The 0.8.0 defect: an ambiguous page did nothing at all, so the user could not tell a missing
|
|
1900
|
+
// login from a broken feature. Ambiguity now raises the chooser banner instead.
|
|
1901
|
+
const existing = tab.notice;
|
|
1902
|
+
if (!existing || existing.kind !== 'choose') vm.setNotice(key, null);
|
|
1903
|
+
return plan;
|
|
1904
|
+
}
|
|
1905
|
+
if (plan.kind === 'choose') {
|
|
1906
|
+
vm.setNotice(key, { kind: 'choose', choices: plan.choices, url: pageUrl });
|
|
1907
|
+
return plan;
|
|
1908
|
+
}
|
|
1909
|
+
const picked = creds.candidatesForAccount(tab.svc, tab.id, pageUrl).find(c => c.id === plan.credentialId);
|
|
1910
|
+
if (!picked) return { kind: 'skip', reason: 'unresolved' };
|
|
1911
|
+
const result = await pushFill(tab, picked, { auto: true });
|
|
1912
|
+
noticeFromAutoFill(key, tab, picked.id, result);
|
|
1913
|
+
return { kind: 'fill', requestId: result.requestId, ok: result.ok, reason: result.reason };
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function noticeFromAutoFill(key, tab, credentialId, result) {
|
|
1917
|
+
const username = (creds.loadAll().find(c => c.id === credentialId) || {}).username || '';
|
|
1918
|
+
if (result.ok) {
|
|
1919
|
+
vm.setNotice(key, { kind: 'filled', username, credentialId, fillId: result.requestId, fields: { user: !!result.user, password: !!result.password } });
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
const reasons = {
|
|
1923
|
+
'no-form': 'this page shows no sign-in form yet',
|
|
1924
|
+
'no-login': 'nothing is saved for this site yet',
|
|
1925
|
+
'tab-closed': 'the tab closed before the form answered',
|
|
1926
|
+
timeout: 'the page did not answer',
|
|
1927
|
+
};
|
|
1928
|
+
vm.setNotice(key, { kind: 'failed', username, reason: reasons[result.reason] || 'the page had no visible password field' });
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// Push one credential into a tab and WAIT for the preload's answer, so "filled" is a fact and not
|
|
1932
|
+
// an assumption. The timeout exists because a page can navigate mid-fill.
|
|
1933
|
+
function pushFill(tab, cred, opts = {}) {
|
|
1934
|
+
const requestId = crypto.randomUUID();
|
|
1935
|
+
const p = new Promise(resolve => {
|
|
1936
|
+
const timer = setTimeout(() => {
|
|
1937
|
+
tab.pendingFills.delete(requestId);
|
|
1938
|
+
resolve({ ok: false, reason: 'timeout' });
|
|
1939
|
+
}, FILL_TIMEOUT_MS);
|
|
1940
|
+
tab.pendingFills.set(requestId, { resolve, timer, auto: !!opts.auto, credentialId: cred.id });
|
|
1941
|
+
});
|
|
1942
|
+
try {
|
|
1943
|
+
tab.view.webContents.send('fill-credential', {
|
|
1944
|
+
requestId, username: cred.username, password: cred.password, submit: !!opts.submit,
|
|
1945
|
+
});
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
tab.pendingFills.delete(requestId);
|
|
1948
|
+
return Promise.resolve({ ok: false, reason: 'send-failed' });
|
|
1949
|
+
}
|
|
1950
|
+
return p;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
ipcMain.handle('fill-login', async (_e, key, credentialId = null) => {
|
|
1268
1954
|
const t = vm.tabs.get(key);
|
|
1269
|
-
if (!t)
|
|
1955
|
+
if (!t) return { ok: false, reason: 'asleep', message: 'That tab is asleep — open it first.' };
|
|
1270
1956
|
const [svc, id] = key.split('::');
|
|
1271
1957
|
const pageUrl = t.view.webContents.getURL();
|
|
1272
1958
|
if (!originAllowed(svc, pageUrl)) {
|
|
1273
|
-
|
|
1959
|
+
return {
|
|
1960
|
+
ok: false, reason: 'foreign-host',
|
|
1961
|
+
message: `This tab is on ${creds.hostOf(pageUrl) || 'another site'}, not ${serviceHosts(svc)[0] || 'the service'} — refusing to fill a saved password here.`,
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
const { candidates } = fillCandidatesFor(svc, id, pageUrl);
|
|
1965
|
+
if (!candidates.length) {
|
|
1966
|
+
return { ok: false, reason: 'no-login', message: `No saved login for this ${svcOfName(svc)} account yet — sign in once, or import your passwords.` };
|
|
1274
1967
|
}
|
|
1275
|
-
if (!creds.candidatesForAccount(svc, id, pageUrl).length) throw new Error('No saved login for this account yet — sign in once and it will be remembered');
|
|
1276
|
-
const candidates = creds.candidatesForAccount(svc, id, pageUrl);
|
|
1277
|
-
if (!candidates.length) throw new Error('No saved login matches this account and site');
|
|
1278
1968
|
// The user may have navigated since the chooser was rendered. Recompute from the LIVE URL;
|
|
1279
1969
|
// if the picked id no longer belongs here, return fresh summaries instead of persisting a stale default.
|
|
1280
1970
|
if (credentialId && !candidates.some(c => c.id === credentialId)) {
|
|
1281
|
-
return { choose:
|
|
1971
|
+
return { choose: summariesOf(candidates, svc, id) };
|
|
1972
|
+
}
|
|
1973
|
+
const cred = resolveAmong(candidates, accountKey(svc, id), credentialId || null);
|
|
1974
|
+
if (!cred) return { choose: summariesOf(candidates, svc, id) };
|
|
1975
|
+
if (credentialId) { useAsDefault(svc, id, credentialId); vm.emitState(); } // only a candidate of this account + this service
|
|
1976
|
+
const result = await pushFill(t, cred, { submit: false });
|
|
1977
|
+
if (result.ok) {
|
|
1978
|
+
vm.setNotice(key, { kind: 'filled', username: cred.username, credentialId: cred.id, fillId: result.requestId, fields: { user: !!result.user, password: !!result.password } });
|
|
1979
|
+
return { ok: true, filled: cred.username, requestId: result.requestId };
|
|
1282
1980
|
}
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1981
|
+
const messages = {
|
|
1982
|
+
'no-form': 'No sign-in form is visible on this page. Sign in once here and it will be filled next time.',
|
|
1983
|
+
'tab-closed': 'The tab closed before the form answered.',
|
|
1984
|
+
timeout: 'The page did not answer — it may have navigated while filling.',
|
|
1985
|
+
};
|
|
1986
|
+
return { ok: false, reason: result.reason, message: messages[result.reason] || 'This page has no visible password field to fill.' };
|
|
1288
1987
|
});
|
|
1289
1988
|
ipcMain.handle('creds-stats', () => creds.stats());
|
|
1290
1989
|
ipcMain.handle('creds-browsers', () => creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData })));
|
|
1291
1990
|
ipcMain.handle('creds-import-browser', async (_e, id) => {
|
|
1292
1991
|
const p = creds.listChromiumProfiles().find(x => x.loginData === id);
|
|
1293
1992
|
if (!p) throw new Error('Browser profile not found');
|
|
1294
|
-
|
|
1993
|
+
const r = await creds.importFromChromium(p);
|
|
1994
|
+
vm.emitState();
|
|
1995
|
+
return r;
|
|
1295
1996
|
});
|
|
1296
1997
|
ipcMain.handle('creds-import-csv', async () => {
|
|
1297
1998
|
const { dialog } = require('electron');
|
|
1298
1999
|
const r = await dialog.showOpenDialog({ title: 'Import passwords from CSV', filters: [{ name: 'CSV', extensions: ['csv'] }], properties: ['openFile'] });
|
|
1299
2000
|
if (r.canceled || !r.filePaths[0]) return null;
|
|
1300
|
-
|
|
2001
|
+
const res = creds.importFromCsv(r.filePaths[0]);
|
|
2002
|
+
vm.emitState();
|
|
2003
|
+
return res;
|
|
1301
2004
|
});
|
|
1302
|
-
ipcMain.handle('creds-clear', () => creds.clearAll());
|
|
2005
|
+
ipcMain.handle('creds-clear', () => { creds.clearAll(); vm.emitState(); return true; });
|
|
1303
2006
|
ipcMain.handle('creds-for-account', (_e, svc, id) => {
|
|
1304
2007
|
const svcCfg = serviceByKey(svc);
|
|
1305
2008
|
const c = creds.credentialForAccount(svc, id, svcCfg && svcCfg.loginUrl);
|
|
1306
2009
|
return c ? { username: c.username, host: c.host, source: c.source } : null; // never the password
|
|
1307
2010
|
});
|
|
1308
|
-
//
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
const serviceKeys = accountSvc ? [accountSvc] : allServices()
|
|
1313
|
-
.filter(s => serviceHosts(s.key).some(h => creds.hostRelated(c.host, h)))
|
|
1314
|
-
.map(s => s.key);
|
|
1315
|
-
return { ...c, serviceKeys };
|
|
2011
|
+
// Per-account "Logins" list for the gear modal and the rail badge: summaries only, never passwords.
|
|
2012
|
+
ipcMain.handle('creds-account-list', (_e, svc, id) => ({
|
|
2013
|
+
logins: accountLoginSummaries(svc, id),
|
|
2014
|
+
signInUrl: (serviceByKey(svc) || {}).loginUrl || null,
|
|
1316
2015
|
}));
|
|
2016
|
+
// Per-SITE picker (0.10.0): the logins that can sign you in on the ACTIVE tab's live host. The
|
|
2017
|
+
// renderer names the tab; main reads that tab's LIVE url, so the list can never describe a page the
|
|
2018
|
+
// user has navigated away from. Summaries only (no `password` key, ever).
|
|
2019
|
+
ipcMain.handle('creds-site-list', (_e, key) => {
|
|
2020
|
+
const t = key ? vm.tabs.get(String(key)) : null;
|
|
2021
|
+
if (!t) return { host: '', logins: [], count: 0, live: false };
|
|
2022
|
+
const liveUrl = t.view.webContents.isDestroyed() ? '' : t.view.webContents.getURL();
|
|
2023
|
+
const { host, rows } = siteLoginRows(t.svc, t.id, liveUrl);
|
|
2024
|
+
return { host, logins: rows, count: rows.length, live: true };
|
|
2025
|
+
});
|
|
2026
|
+
// Copy a saved email/password to the OS clipboard without ever showing it (main-process clipboard).
|
|
2027
|
+
// The account and the site gate come from the ACTIVE tab in main, not from the caller.
|
|
2028
|
+
ipcMain.handle('creds-copy', (_e, key, credentialId, field) => {
|
|
2029
|
+
const t = key ? vm.tabs.get(String(key)) : null;
|
|
2030
|
+
if (!t) return { ok: false, reason: 'asleep', message: 'That tab is asleep — open it first.' };
|
|
2031
|
+
const liveUrl = t.view.webContents.isDestroyed() ? '' : t.view.webContents.getURL();
|
|
2032
|
+
return copyCredentialField(String(credentialId || ''), String(field || ''), t.svc, t.id, liveUrl);
|
|
2033
|
+
});
|
|
2034
|
+
// Credential manager: summaries only, never passwords — the renderer must not hold one.
|
|
2035
|
+
// Imported passwords are classified by host, with the accounts each row can serve, so the manager
|
|
2036
|
+
// filters and the ★ markers both come from the main process (the only authority on them).
|
|
2037
|
+
ipcMain.handle('creds-list', () => managerRows());
|
|
1317
2038
|
ipcMain.handle('creds-default', (_e, credentialId, svc, id, hostHint) =>
|
|
1318
|
-
|
|
1319
|
-
ipcMain.handle('creds-delete', (_e, credentialId) =>
|
|
2039
|
+
setOrClearDefault(credentialId, svc, id, hostHint));
|
|
2040
|
+
ipcMain.handle('creds-delete', (_e, credentialId) => {
|
|
2041
|
+
const ok = creds.deleteCredential(credentialId);
|
|
2042
|
+
vm.emitState();
|
|
2043
|
+
return { ok, rows: managerRows() };
|
|
2044
|
+
});
|
|
2045
|
+
// Per-account "never auto-fill" switch (0.9.0 C). Stored on the account, default off — the global
|
|
2046
|
+
// Auto-fill toggle stays the master and this can only subtract from it, never add.
|
|
2047
|
+
ipcMain.handle('account-no-autofill', (_e, svc, id, value) => {
|
|
2048
|
+
const acc = findAccount(svc, id);
|
|
2049
|
+
if (!acc) throw new Error('unknown account');
|
|
2050
|
+
acc.noAutoFill = !!value;
|
|
2051
|
+
store.save();
|
|
2052
|
+
vm.emitState();
|
|
2053
|
+
return acc.noAutoFill;
|
|
2054
|
+
});
|
|
2055
|
+
// Dismiss the per-tab notice (banner X).
|
|
2056
|
+
ipcMain.handle('dismiss-notice', (_e, key) => { vm.setNotice(key, null); return true; });
|
|
2057
|
+
// Undo an auto-fill: the preload clears exactly what it filled and answers with what it cleared.
|
|
2058
|
+
// The undo has its own requestId for routing; `fillId` names the fill it is undoing, so a stale
|
|
2059
|
+
// Undo (or one aimed at a different fill) is refused by the page instead of clearing live fields.
|
|
2060
|
+
ipcMain.handle('undo-fill', async (_e, key) => {
|
|
2061
|
+
const t = vm.tabs.get(key);
|
|
2062
|
+
if (!t) return { ok: false, reason: 'asleep' };
|
|
2063
|
+
const notice = t.notice;
|
|
2064
|
+
if (!notice || notice.kind !== 'filled' || !notice.fillId) return { ok: false, reason: 'no-notice' };
|
|
2065
|
+
const requestId = crypto.randomUUID();
|
|
2066
|
+
const result = await new Promise(resolve => {
|
|
2067
|
+
const timer = setTimeout(() => { t.pendingFills.delete(requestId); resolve({ ok: false, reason: 'timeout' }); }, FILL_TIMEOUT_MS);
|
|
2068
|
+
t.pendingFills.set(requestId, { resolve, timer, auto: false });
|
|
2069
|
+
t.view.webContents.send('clear-filled', { requestId, fillId: notice.fillId });
|
|
2070
|
+
});
|
|
2071
|
+
if (result.ok) vm.setNotice(key, null);
|
|
2072
|
+
return result;
|
|
2073
|
+
});
|
|
2074
|
+
// The chooser a notice offers: same resolution path as a manual fill, then dismiss the banner.
|
|
2075
|
+
ipcMain.handle('notice-choose', async (_e, key, credentialId) => {
|
|
2076
|
+
const t = vm.tabs.get(key);
|
|
2077
|
+
if (!t) return { ok: false, reason: 'asleep' };
|
|
2078
|
+
const [svc, id] = key.split('::');
|
|
2079
|
+
const pageUrl = t.view.webContents.getURL();
|
|
2080
|
+
const { candidates } = fillCandidatesFor(svc, id, pageUrl);
|
|
2081
|
+
const picked = candidates.find(c => c.id === credentialId);
|
|
2082
|
+
if (!picked) return { ok: false, reason: 'stale' };
|
|
2083
|
+
useAsDefault(svc, id, picked.id);
|
|
2084
|
+
vm.emitState();
|
|
2085
|
+
const result = await pushFill(t, picked, { submit: false });
|
|
2086
|
+
if (result.ok) vm.setNotice(key, { kind: 'filled', username: picked.username, credentialId: picked.id, fillId: result.requestId, fields: { user: !!result.user, password: !!result.password } });
|
|
2087
|
+
return result.ok ? { ok: true, filled: picked.username } : { ok: false, reason: result.reason };
|
|
2088
|
+
});
|
|
1320
2089
|
// ---------- Phase 3: service plugins (JSON manifests) ----------
|
|
1321
2090
|
ipcMain.handle('plugins-list', () => ({
|
|
1322
2091
|
dir: plugins.pluginsDir(),
|
|
@@ -1498,6 +2267,14 @@ function runInteractive(captureMode = false) {
|
|
|
1498
2267
|
});
|
|
1499
2268
|
}
|
|
1500
2269
|
|
|
2270
|
+
// Test-only exports (used by the smoke modes). These are the exact functions the IPC handlers above
|
|
2271
|
+
// call — smoke proves the real projection, not a copy of it.
|
|
2272
|
+
module.exports = {
|
|
2273
|
+
accountLoginSummaries, managerRows, setOrClearDefault, resolveAmong, fillCandidatesFor, autoFillPlan,
|
|
2274
|
+
originAllowed, accountKey,
|
|
2275
|
+
_creds: creds,
|
|
2276
|
+
};
|
|
2277
|
+
|
|
1501
2278
|
function cycleTab(vm, dir) {
|
|
1502
2279
|
const keys = [...vm.tabs.keys()];
|
|
1503
2280
|
if (!keys.length) return;
|
|
@@ -1522,7 +2299,7 @@ if (!gotLock) {
|
|
|
1522
2299
|
const [w] = BaseWindow.getAllWindows();
|
|
1523
2300
|
if (w) { if (w.isMinimized()) w.restore(); w.focus(); }
|
|
1524
2301
|
});
|
|
1525
|
-
app.whenReady().then(() => {
|
|
2302
|
+
app.whenReady().then(async () => {
|
|
1526
2303
|
// --print-paths: where does this build keep its data? (support + rename verification)
|
|
1527
2304
|
if (process.argv.includes('--print-paths')) {
|
|
1528
2305
|
console.log(JSON.stringify({
|
|
@@ -1534,6 +2311,24 @@ if (!gotLock) {
|
|
|
1534
2311
|
return app.exit(0);
|
|
1535
2312
|
}
|
|
1536
2313
|
store.load();
|
|
2314
|
+
// --seed-demo-logins: placeholder accounts + logins, for screenshots of a scratch profile only.
|
|
2315
|
+
// Requires MAM_USER_DATA (a scratch dir): it must never touch the real profile.
|
|
2316
|
+
if (process.argv.includes('--seed-demo-logins')) {
|
|
2317
|
+
if (!process.env.MAM_USER_DATA) {
|
|
2318
|
+
console.error('[demo] --seed-demo-logins refuses to run without MAM_USER_DATA — point it at a scratch profile');
|
|
2319
|
+
return app.exit(2);
|
|
2320
|
+
}
|
|
2321
|
+
// Wait for safeStorage to become ready before touching credentials.js. On this machine the
|
|
2322
|
+
// OS key is not available during app.whenReady() yet (credentials.js checks
|
|
2323
|
+
// isEncryptionAvailable()); a listCredentials() call before that answers [] and caching that
|
|
2324
|
+
// empty answer is exactly how the manager could show "0 stored" for a full profile.
|
|
2325
|
+
const { safeStorage } = require('electron');
|
|
2326
|
+
const deadline = Date.now() + 8000;
|
|
2327
|
+
while (!safeStorage.isEncryptionAvailable() && Date.now() < deadline) await new Promise(r => setTimeout(r, 100));
|
|
2328
|
+
console.log(`[demo] safeStorage available: ${safeStorage.isEncryptionAvailable()}`);
|
|
2329
|
+
seedDemoLogins();
|
|
2330
|
+
if (!process.argv.includes('--capture')) return app.exit(0);
|
|
2331
|
+
}
|
|
1537
2332
|
if (process.argv.includes('--spike')) runSpike().catch(err => { console.error(err); app.exit(1); });
|
|
1538
2333
|
else if (process.argv.includes('--smoke')) runSmoke().catch(err => { console.error(err); app.exit(1); });
|
|
1539
2334
|
else if (process.argv.includes('--smoke-restore')) runSmokeRestore().catch(err => { console.error(err); app.exit(1); });
|