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.
Files changed (3) hide show
  1. package/README.md +51 -9
  2. package/package.json +2 -2
  3. package/src/main.js +838 -43
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
- # My Provider Dash Manager — multi-account manager for 37 cloud services
1
+ # My Provider Dash Manager — every account. One window.
2
2
 
3
3
  [![version](https://img.shields.io/github/v/release/FiredMosquito831/my-provider-dash-manager?label=version)](https://github.com/FiredMosquito831/my-provider-dash-manager/releases/latest)
4
+ [![site](https://img.shields.io/badge/site-providers.danubelabs.net-6aa8ff)](https://providers.danubelabs.net)
4
5
  [![npm](https://img.shields.io/npm/v/my-provider-dash-manager)](https://www.npmjs.com/package/my-provider-dash-manager)
5
6
  [![downloads](https://img.shields.io/npm/dm/my-provider-dash-manager)](https://www.npmjs.com/package/my-provider-dash-manager)
6
7
  [![stars](https://img.shields.io/github/stars/FiredMosquito831/my-provider-dash-manager)](https://github.com/FiredMosquito831/my-provider-dash-manager)
7
8
  [![licence](https://img.shields.io/badge/licence-source--available-green)](LICENSE)
9
+ [![Windows](https://img.shields.io/badge/platform-Windows%2010%20%2F%2011-0078d4)](#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
  ![A Gmail account open in the app, with back / forward / reload / dashboard and the address bar in the strip](https://raw.githubusercontent.com/FiredMosquito831/my-provider-dash-manager/main/docs/screenshots/navigation.png)
28
34
 
29
- <sub>Screenshots use a demo profile with placeholder account names.</sub>
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
- ## Using it
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
- - **Auto-fills sign-in pages** when it recognises one (toggleable), plus a manual fill action.
186
- - **Manage logins** from Home → Saved logins → *Manage logins…*: every stored login grouped by
187
- service, with delete. No password is ever shown.
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 recall/selection, defaults, encryption at rest (16)
344
- npm run smoke:autofill # real page: form detection, fill, capture, origin guard (10)
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 115 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
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.8.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(b);
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.contentBounds);
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', onNav);
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.contentBounds); t.view.setVisible(true); t.visible = true; }
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
- tab.view.webContents.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
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
- const argVal = name => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : null; };
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
- // auto-fill this account's own remembered login when its sign-in page appears
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
- if (!originAllowed(found.tab.svc, pageUrl)) {
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
- ipcMain.on('fill-result', () => {});
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
- ipcMain.handle('fill-login', (_e, key, credentialId = null) => {
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) throw new Error('That tab is asleep — open it first');
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
- throw new Error(`This tab is on ${creds.hostOf(pageUrl) || 'another site'}, not ${serviceHosts(svc)[0] || 'the service'} — refusing to fill a saved password here`);
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: creds.candidateSummariesForAccount(svc, id, pageUrl) };
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 cred = creds.credentialForAccount(svc, id, pageUrl, credentialId || null);
1284
- if (!cred) return { choose: creds.candidateSummariesForAccount(svc, id, pageUrl) };
1285
- if (credentialId) creds.setDefaultForAccount(credentialId, svc, id, pageUrl); // remember only a live-host candidate
1286
- t.view.webContents.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
1287
- return { filled: cred.username };
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
- return creds.importFromChromium(p);
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
- return creds.importFromCsv(r.filePaths[0]);
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
- // Credential manager: summaries only, never passwords — the renderer must not hold one.
1309
- // Imported passwords are classified by host so the manager can filter/group them per service.
1310
- ipcMain.handle('creds-list', () => creds.listCredentials().map(c => {
1311
- const accountSvc = c.accountKey ? c.accountKey.split('::')[0] : null;
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
- creds.setDefaultForAccount(credentialId, svc, id, hostHint));
1319
- ipcMain.handle('creds-delete', (_e, credentialId) => creds.deleteCredential(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); });