my-provider-dash-manager 0.8.0 → 0.9.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 +42 -9
  2. package/package.json +2 -2
  3. package/src/main.js +634 -41
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
@@ -182,11 +192,21 @@ works in the app. Anything else can be added by URL or as a plugin.
182
192
  ### Password manager with per-service import
183
193
  - **Remembers logins you type** inside an account tab, bound to *that* account, so two accounts on the
184
194
  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.
195
+ - **See each account's logins.** Open an account's **Manage** (gear) window and its *Saved logins* list
196
+ shows exactly the logins that belong to it, with **Use** to make one that account's default and fill
197
+ it into the open tab. A small key on the rail row marks every account that has a saved login, even
198
+ while its tab is asleep.
199
+ - **Auto-fills sign-in pages** when it recognises one (toggleable), and now **tells you**: a banner
200
+ above the page says what was filled, offers **Undo**, and asks you to choose when several logins
201
+ match instead of silently doing nothing. Any single account can opt out with **Never auto-fill for
202
+ this account** in its Manage window; the Home toggle stays the master switch.
203
+ - **Manage logins** from Home → Saved logins → *Manage logins…*: every stored login as its own row,
204
+ filterable by service or by account, with set/clear default and delete. No password is ever shown.
188
205
  - **Choose which login fills:** when more than one matches a site the app asks instead of guessing,
189
206
  and your choice is remembered as that account's default (marked ★ in the manager).
207
+ - **Fills from anywhere** on the service's own site: if the page you are on has no matching login (a
208
+ dashboard, say), the account's login for that service is used. Filling on any other domain is
209
+ refused, and a fill that cannot happen now says why instead of failing quietly.
190
210
  - **Re-importing updates changed passwords** instead of silently keeping the old one.
191
211
  - **Imports existing passwords** from installed Chromium browsers (Chrome, Edge, Brave, Vivaldi,
192
212
  Opera) or from a CSV exported by any browser, including Firefox.
@@ -340,8 +360,8 @@ npm start # run the app
340
360
  npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
341
361
  npm run smoke:restore # sessions survive a restart (real cookies)
342
362
  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)
363
+ npm run smoke:creds # import, per-account/manager lists, defaults, fill fallback, auto-fill plan, encryption at rest (39)
364
+ npm run smoke:autofill # real page: form detection, fill + request ids, undo, capture, origin guard (15)
345
365
  npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
346
366
  npm run smoke:services # every built-in login page loads inside an isolated partition (42)
347
367
  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 +373,26 @@ npm start -- --print-paths # show the app name, version and data fold
353
373
  npm start -- --open vercel::personal # open an account on launch
354
374
  ```
355
375
 
356
- The suite is 115 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
376
+ The suite is 145 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
357
377
  profile.
358
378
 
359
379
  Releases are cut by pushing a `v*` tag: GitHub Actions checks the tag against `package.json`, builds
360
380
  the NSIS installer, publishes the GitHub release with the update feed, and publishes the npm package
361
381
  through npm trusted publishing (OIDC, with a provenance attestation — no tokens involved).
362
382
 
383
+ ## Links
384
+
385
+ - **Website:** [providers.danubelabs.net](https://providers.danubelabs.net)
386
+ - **Features:** [providers.danubelabs.net/features.html](https://providers.danubelabs.net/features.html)
387
+ - **Install & requirements:** [providers.danubelabs.net/install.html](https://providers.danubelabs.net/install.html)
388
+ - **Security & privacy:** [providers.danubelabs.net/security.html](https://providers.danubelabs.net/security.html)
389
+ - **FAQ:** [providers.danubelabs.net/faq.html](https://providers.danubelabs.net/faq.html)
390
+ - **Changelog:** [providers.danubelabs.net/changelog.html](https://providers.danubelabs.net/changelog.html) · [docs/CHANGELOG.md](docs/CHANGELOG.md)
391
+ - **Source & issues:** [github.com/FiredMosquito831/my-provider-dash-manager](https://github.com/FiredMosquito831/my-provider-dash-manager)
392
+ - **Releases:** [latest installer](https://github.com/FiredMosquito831/my-provider-dash-manager/releases/latest)
393
+ - **npm:** [my-provider-dash-manager](https://www.npmjs.com/package/my-provider-dash-manager)
394
+ - **Design notes:** [DESIGN.md](DESIGN.md)
395
+
363
396
  ## Licence
364
397
 
365
398
  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.9.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
1
  const { app, BaseWindow, BrowserWindow, WebContentsView, session, ipcMain, shell, Menu } = 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,149 @@ 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
+ // Explicit clear semantics for creds-default (0.8.0 relied on the "null id never matches" side
231
+ // effect). A null id may ONLY clear: it never sets a default anywhere, and it needs the account it
232
+ // applies to. Returns the authoritative manager list either way.
233
+ function setOrClearDefault(credentialId, svc, id, hostHint) {
234
+ if (credentialId == null) {
235
+ if (!svc || !id) throw new Error('Clearing a default needs the account it applies to');
236
+ creds.setDefaultForAccount(null, svc, id, hostHint);
237
+ } else {
238
+ creds.setDefaultForAccount(credentialId, svc, id, hostHint);
239
+ }
240
+ return managerRows();
241
+ }
242
+
243
+ // The D30 resolution ladder over an explicit candidate set (main-process copy, used only when the
244
+ // tab's live host matched nothing and we resolve against the service's hosts). Explicit default
245
+ // first, then this account's single own capture, then a single imported match; anything ambiguous
246
+ // resolves to null so the UI asks instead of guessing. smoke:creds pins this to the identical
247
+ // outcomes of creds.credentialForAccount() on a single host.
248
+ function resolveAmong(candidates, accountKeyValue, credentialId) {
249
+ if (credentialId) return candidates.find(c => c.id === credentialId) || null;
250
+ const preferred = candidates.find(c => (c.defaultForAccounts || []).includes(accountKeyValue));
251
+ if (preferred) return preferred;
252
+ const own = candidates.filter(c => c.accountKey === accountKeyValue);
253
+ if (own.length === 1) return own[0];
254
+ if (own.length > 1) return null;
255
+ const imported = candidates.filter(c => !c.accountKey);
256
+ return imported.length === 1 ? imported[0] : null;
257
+ }
258
+
259
+ // Candidates for a fill: the tab's LIVE host first (unchanged, D14). When the live page matches no
260
+ // saved login (a dashboard) fall back to this account's logins for the SERVICE's own hosts — still
261
+ // host-gated: every host here comes from serviceHosts(), and the call site refuses a live URL that
262
+ // is not a service host before this runs.
263
+ function fillCandidatesFor(svc, id, pageUrl) {
264
+ const live = creds.candidatesForAccount(svc, id, pageUrl);
265
+ if (live.length) return { candidates: live, source: 'live' };
266
+ const byId = new Map();
267
+ for (const host of serviceHosts(svc)) {
268
+ for (const c of creds.candidatesForAccount(svc, id, `https://${host}/`)) byId.set(c.id, c);
269
+ }
270
+ const candidates = [...byId.values()]
271
+ .sort((a, b) => Number(b.accountKey === accountKey(svc, id)) - Number(a.accountKey === accountKey(svc, id)));
272
+ return { candidates, source: candidates.length ? 'service' : 'none' };
273
+ }
274
+
275
+ // What the automatic path should do when a sign-in form appears. `skip` reasons are honest: the
276
+ // master toggle is off, this account opted out, the page is not the service's own site, or there is
277
+ // simply nothing saved. `choose` replaces 0.8.0's silent skip when several logins match (D30).
278
+ function autoFillPlan(svc, id, url, opts = {}) {
279
+ const settings = opts.settings || store.settings;
280
+ const account = opts.account !== undefined ? opts.account : findAccount(svc, id);
281
+ if (!settings || !settings.autofill) return { kind: 'skip', reason: 'autofill-off' };
282
+ if (account && account.noAutoFill) return { kind: 'skip', reason: 'account-opt-out' };
283
+ if (!originAllowed(svc, url)) return { kind: 'skip', reason: 'foreign-host' };
284
+ const cred = creds.credentialForAccount(svc, id, url);
285
+ if (cred) return { kind: 'fill', credentialId: cred.id, username: cred.username };
286
+ const choices = creds.candidateSummariesForAccount(svc, id, url);
287
+ if (choices.length > 1) return { kind: 'choose', choices };
288
+ return { kind: 'skip', reason: choices.length ? 'unresolved' : 'no-login' };
289
+ }
290
+
144
291
  // ---------- view manager ----------
145
292
  class ViewManager {
146
293
  constructor() {
@@ -202,7 +349,52 @@ class ViewManager {
202
349
 
203
350
  setBounds(b) {
204
351
  this.contentBounds = b;
205
- for (const t of this.tabs.values()) if (t.visible) t.view.setBounds(b);
352
+ for (const t of this.tabs.values()) if (t.visible) t.view.setBounds(this.boundsFor(t));
353
+ }
354
+
355
+ // A tab carrying an auto-fill notice reserves room at the top of its view for the banner. The
356
+ // banner lives in the chrome renderer (the view itself is a native child and cannot hold DOM),
357
+ // so the view starts lower for exactly as long as the notice exists (0.9.0).
358
+ boundsFor(t) {
359
+ const b = this.contentBounds;
360
+ if (!t.notice) return b;
361
+ return { ...b, y: b.y + BANNER_H, height: Math.max(0, b.height - BANNER_H) };
362
+ }
363
+
364
+ // The per-tab notice the renderer paints above the account view. Always a summary: never a
365
+ // password (the username is the only credential-derived value, as in 0.8.0's chooser).
366
+ setNotice(key, notice) {
367
+ const t = this.tabs.get(key);
368
+ if (!t) return;
369
+ t.notice = notice || null;
370
+ if (this.window && this.window.webContents && !this.window.webContents.isDestroyed()) {
371
+ this.window.webContents.send('account-notice', { key, notice: t.notice });
372
+ }
373
+ if (t.visible) t.view.setBounds(this.boundsFor(t));
374
+ }
375
+
376
+ // Hide the banner; the view reclaims the strip immediately.
377
+ clearNotice(key) {
378
+ const t = this.tabs.get(key);
379
+ if (!t || !t.notice) return;
380
+ t.notice = null;
381
+ if (this.window && this.window.webContents && !this.window.webContents.isDestroyed()) {
382
+ this.window.webContents.send('account-notice', { key, notice: null });
383
+ }
384
+ if (t.visible) t.view.setBounds(this.boundsFor(t));
385
+ }
386
+
387
+ // Resolve one in-flight fill request for a tab. The preload reports a requestId so a result can
388
+ // never be attributed to the wrong request (or a page that has since navigated).
389
+ settleFill(key, result) {
390
+ const t = this.tabs.get(key);
391
+ if (!t) return null;
392
+ const entry = t.pendingFills.get(result && result.requestId);
393
+ if (!entry) return null;
394
+ t.pendingFills.delete(result.requestId);
395
+ clearTimeout(entry.timer);
396
+ entry.resolve(result);
397
+ return entry;
206
398
  }
207
399
 
208
400
  setModalOpen(open) {
@@ -212,7 +404,7 @@ class ViewManager {
212
404
  for (const t of this.tabs.values()) if (t.visible) { t.view.setVisible(false); t.visible = false; }
213
405
  } else if (this.activeKey && this.tabs.has(this.activeKey)) {
214
406
  const t = this.tabs.get(this.activeKey);
215
- t.view.setBounds(this.contentBounds);
407
+ t.view.setBounds(this.boundsFor(t));
216
408
  t.view.setVisible(true);
217
409
  t.visible = true;
218
410
  }
@@ -265,7 +457,7 @@ class ViewManager {
265
457
  registerAccount(svc, id, label || id);
266
458
  const acc = findAccount(svc, id);
267
459
  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() };
460
+ 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
461
  this.tabs.set(key, tab);
270
462
  this.trackSession(svc, id, tab.url); // tab joins the remembered open set
271
463
  this.window.contentView.addChildView(view);
@@ -304,7 +496,13 @@ class ViewManager {
304
496
  // document's styles exist and after every cross-origin navigation, which is exactly when the
305
497
  // isolated-world engine needs (re-)injecting. did-navigate-in-page (pushState) keeps the world,
306
498
  // so it deliberately does not re-inject.
307
- view.webContents.on('did-navigate', onNav);
499
+ view.webContents.on('did-navigate', (_e, url2) => {
500
+ // A filled-forms notice describes fields that no longer exist after navigating away. Dismiss it
501
+ // so a stale "Undo" can never act on the new page. (Did-navigate-in-page deliberately does not,
502
+ // or a client-side redirect right after fill would immediately drop its own notice.)
503
+ this.clearNotice(key);
504
+ onNav(_e, url2);
505
+ });
308
506
  view.webContents.on('did-navigate-in-page', onNav); // dashboards route client-side (pushState) — must track too
309
507
  view.webContents.on('did-start-loading', () => this.emitState());
310
508
  view.webContents.on('did-stop-loading', () => this.emitState());
@@ -334,11 +532,14 @@ class ViewManager {
334
532
  if (!tab) return null;
335
533
  for (const [k, t] of this.tabs) {
336
534
  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; }
535
+ if (vis && !t.visible) { t.view.setBounds(this.boundsFor(t)); t.view.setVisible(true); t.visible = true; }
338
536
  else if (!vis && t.visible) { t.view.setVisible(false); t.visible = false; }
339
537
  }
340
538
  tab.lastActivated = Date.now();
341
539
  this.activeKey = key;
540
+ // Any banner left on a tab the user has now left is stale (Undo would target a page they are no
541
+ // longer looking at). Banner lifetime follows attention: leaving the tab dismisses it.
542
+ for (const [k] of this.tabs) if (k !== key) this.clearNotice(k);
342
543
  const se = this.sessionEntry(tab.svc, tab.id);
343
544
  if (se) { se.lastActivated = tab.lastActivated; se.url = tab.url; }
344
545
  this.persistSession();
@@ -387,6 +588,7 @@ class ViewManager {
387
588
  hibernate(key) {
388
589
  const tab = this.tabs.get(key);
389
590
  if (!tab) return;
591
+ for (const entry of tab.pendingFills.values()) { clearTimeout(entry.timer); entry.resolve({ ok: false, reason: 'tab-closed' }); }
390
592
  try { this.window.contentView.removeChildView(tab.view); } catch {}
391
593
  try { tab.view.webContents.close(); } catch {} // separate: a dead contentView must not skip closing the renderer
392
594
  this.tabs.delete(key);
@@ -412,6 +614,7 @@ class ViewManager {
412
614
  async deleteAccount(svc, id) {
413
615
  const key = accountKey(svc, id);
414
616
  if (this.tabs.has(key)) {
617
+ for (const entry of this.tabs.get(key).pendingFills.values()) { clearTimeout(entry.timer); entry.resolve({ ok: false, reason: 'tab-closed' }); }
415
618
  try { this.window.contentView.removeChildView(this.tabs.get(key).view); this.tabs.get(key).view.webContents.close(); } catch {}
416
619
  this.tabs.delete(key);
417
620
  }
@@ -444,6 +647,7 @@ class ViewManager {
444
647
 
445
648
  emitState() {
446
649
  if (!this.window || !this.window.webContents) return;
650
+ const loginList = credSummaries(); // one decryption per state emit, not one per account
447
651
  this.window.webContents.send('tabs-state', {
448
652
  activeKey: this.activeKey,
449
653
  warmLimit: store.settings.warmLimit,
@@ -452,6 +656,8 @@ class ViewManager {
452
656
  key: accountKey(a.svc, a.id),
453
657
  hasToken: !!tokens.tokenMeta(accountKey(a.svc, a.id)),
454
658
  status: statusCache.get(accountKey(a.svc, a.id)) || null,
659
+ loginCount: accountLoginSummaries(a.svc, a.id, loginList).length, // "has saved login" indicator
660
+ noAutoFill: !!a.noAutoFill,
455
661
  })), // live registry projection for the home grid (token presence + status summaries; never raw tokens)
456
662
  groupTabs: !!store.settings.groupTabs,
457
663
  collapsed: store.settings.collapsed || [],
@@ -779,6 +985,134 @@ async function runSmokeCreds() {
779
985
  const raw = fs.readFileSync(path.join(app.getPath('userData'), 'credentials.json'), 'utf8');
780
986
  check('encrypted-at-rest', !raw.includes('hunter2') && !raw.includes('typed-pw'));
781
987
 
988
+ // ---------- 0.9.0: the per-account projection, the manager projection, clear semantics ----------
989
+ // Rebuild a clean fixture: one captured login per account + two imports on the same site (the
990
+ // ambiguity case the chooser exists for).
991
+ creds.clearAll(); store.accounts = [];
992
+ registerAccount('vercel', 'team-a', 'Team A');
993
+ registerAccount('vercel', 'team-b', 'Team B');
994
+ registerAccount('netlify', 'site', 'Site');
995
+ creds.rememberForAccount('vercel', 'team-a', { url: 'https://vercel.com/login', username: 'a@example.com', password: 'pw-a' });
996
+ creds.rememberForAccount('vercel', 'team-b', { url: 'https://vercel.com/login', username: 'b@example.com', password: 'pw-b' });
997
+ const csv3 = path.join(app.getPath('temp'), `mam-creds3-${process.pid}.csv`);
998
+ 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');
999
+ try {
1000
+ creds.importFromCsv(csv3);
1001
+ const aLogins = accountLoginSummaries('vercel', 'team-a');
1002
+ check('per-account-list-is-its-own-logins',
1003
+ aLogins.some(l => l.username === 'a@example.com') && aLogins.some(l => l.username === 'shared@example.com')
1004
+ && !aLogins.some(l => l.username === 'b@example.com'),
1005
+ aLogins.map(l => `${l.username}${l.own ? '*' : ''}`).join(','));
1006
+ check('per-account-list-has-no-passwords',
1007
+ aLogins.every(l => !('password' in l))
1008
+ && !JSON.stringify(aLogins).includes('pw-a') && !JSON.stringify(aLogins).includes('hero-pw'),
1009
+ `${aLogins.length} summaries`);
1010
+ const rows = managerRows();
1011
+ check('manager-list-carries-accounts-and-services',
1012
+ rows.length === 4
1013
+ && rows.every(r => Array.isArray(r.accounts) && Array.isArray(r.serviceKeys) && !('password' in r))
1014
+ && rows.find(r => r.username === 'a@example.com').accounts.map(a => a.key).join() === 'vercel::team-a',
1015
+ `${rows.length} rows`);
1016
+ check('manager-rows-have-no-passwords',
1017
+ rows.every(r => !('password' in r) && !JSON.stringify(r).includes('pw-a') && !JSON.stringify(r).includes('shared-pw')));
1018
+ // what emitState puts on a rail row: a count for the badge (and the account's opt-out flag)
1019
+ check('rail-badge-counts-per-account',
1020
+ accountLoginSummaries('vercel', 'team-a').length === 3 && accountLoginSummaries('netlify', 'site').length === 0,
1021
+ `team-a=${accountLoginSummaries('vercel', 'team-a').length} netlify=${accountLoginSummaries('netlify', 'site').length}`);
1022
+
1023
+ // set-default leaves OTHER rows' defaults untouched (the manager used to strip them client-side)
1024
+ const rowA = rows.find(r => r.username === 'a@example.com');
1025
+ const rowShared = rows.find(r => r.username === 'shared@example.com');
1026
+ setOrClearDefault(rowA.id, 'vercel', 'team-a', 'https://vercel.com/login');
1027
+ setOrClearDefault(rowShared.id, 'vercel', 'team-a', 'https://vercel.com/login');
1028
+ let now = managerRows();
1029
+ check('star-markers-come-from-main',
1030
+ now.find(r => r.username === 'a@example.com').defaultForAccounts.length === 0
1031
+ && now.find(r => r.username === 'shared@example.com').defaultForAccounts.includes('vercel::team-a'),
1032
+ now.map(r => `${r.username}:${r.defaultForAccounts.join('|')}`).join(' '));
1033
+ // row B keeps ITS default while row A is re-pointed: one row's ★ never disturbs another's
1034
+ setOrClearDefault(now.find(r => r.username === 'b@example.com').id, 'vercel', 'team-b', 'https://vercel.com/login');
1035
+ setOrClearDefault(managerRows().find(r => r.username === 'carol@example.com').id, 'vercel', 'team-b', 'https://vercel.com/login');
1036
+ now = managerRows();
1037
+ check('set-default-leaves-other-rows-defaults-intact',
1038
+ now.find(r => r.username === 'b@example.com').defaultForAccounts.length === 0
1039
+ && now.find(r => r.username === 'carol@example.com').defaultForAccounts.join() === 'vercel::team-b');
1040
+
1041
+ // null id = explicit CLEAR: clears that account's default and cannot set one anywhere
1042
+ setOrClearDefault(null, 'vercel', 'team-b', 'https://vercel.com/login');
1043
+ now = managerRows();
1044
+ check('clear-default-with-null-id-clears', now.every(r => !r.defaultForAccounts.includes('vercel::team-b')),
1045
+ now.map(r => `${r.username}:${r.defaultForAccounts.join('|')}`).join(' '));
1046
+ let threw = false;
1047
+ try { setOrClearDefault(null, '', '', ''); } catch { threw = true; }
1048
+ check('clear-default-needs-an-account', threw);
1049
+ // ...and the OTHER account's ★ survived that clear: clearing is per account, never global
1050
+ check('clear-default-is-scoped-to-one-account',
1051
+ managerRows().find(r => r.username === 'shared@example.com').defaultForAccounts.join() === 'vercel::team-a');
1052
+ setOrClearDefault(null, 'vercel', 'team-a', 'https://vercel.com/login');
1053
+ check('clear-default-cannot-invent-a-default', managerRows().every(r => r.defaultForAccounts.length === 0));
1054
+
1055
+ // fill fallback: a service whose dashboard and sign-in sit on DIFFERENT hosts (Heroku:
1056
+ // dashboard.heroku.com vs id.heroku.com). A login saved for the sign-in host has no live-host
1057
+ // candidate on the dashboard — the fallback resolves it through the service's own hosts...
1058
+ creds.rememberForAccount('heroku', 'main', { url: 'https://id.heroku.com/login', username: 'hero@example.com', password: 'hero-pw' });
1059
+ const atDashboard = fillCandidatesFor('heroku', 'main', 'https://dashboard.heroku.com/apps');
1060
+ check('fill-fallback-resolves-same-service-login',
1061
+ atDashboard.source === 'service' && atDashboard.candidates.length === 1
1062
+ && atDashboard.candidates[0].username === 'hero@example.com',
1063
+ `${atDashboard.source}:${atDashboard.candidates.map(c => c.username).join(',')}`);
1064
+ const atLogin = fillCandidatesFor('heroku', 'main', 'https://id.heroku.com/login');
1065
+ check('fill-live-host-preferred', atLogin.source === 'live' && atLogin.candidates.every(c => creds.hostRelated(c.host, 'id.heroku.com')));
1066
+ // ...but a FOREIGN live host is refused outright (D14): originAllowed is the gate every fill
1067
+ // passes first, and the fallback only ever draws from serviceHosts().
1068
+ check('fill-fallback-refuses-foreign-host',
1069
+ originAllowed('heroku', 'https://accounts.google.com/signin') === false
1070
+ && originAllowed('heroku', 'https://dashboard.heroku.com.evil.example/') === false
1071
+ && fillCandidatesFor('heroku', 'main', 'https://dashboard.heroku.com/apps').candidates
1072
+ .every(c => serviceHosts('heroku').some(h => creds.hostRelated(c.host, h))));
1073
+ // "Use from the manager" on a DASHBOARD: the login belongs to the sign-in host, so a default set
1074
+ // with the page's own live host would be refused by credentials.js. useAsDefault must pick the
1075
+ // service host that really holds the candidate.
1076
+ const heroId = fillCandidatesFor('heroku', 'main', 'https://dashboard.heroku.com/apps').candidates[0].id;
1077
+ let useOk = true; let useErr = '';
1078
+ try { useAsDefault('heroku', 'main', heroId); } catch (err) { useOk = false; useErr = err.message; }
1079
+ check('use-from-dashboard-sets-the-default', useOk
1080
+ && managerRows().find(r => r.id === heroId).defaultForAccounts.includes('heroku::main'), useErr);
1081
+ // the fallback ladder must agree with the frozen credentials.js ladder on one host
1082
+ check('fallback-ladder-matches-frozen-ladder', ['team-a', 'team-b', 'nobody', 'team-c'].every(id => {
1083
+ const live = creds.credentialForAccount('vercel', id, 'https://vercel.com/login');
1084
+ const viaFallback = resolveAmong(fillCandidatesFor('vercel', id, 'https://vercel.com/login').candidates, accountKey('vercel', id), null);
1085
+ return (live && live.id) === (viaFallback && viaFallback.id);
1086
+ }));
1087
+
1088
+ // ---------- auto-fill plan: opt-out, ambiguity, masters ----------
1089
+ const teamA = findAccount('vercel', 'team-a');
1090
+ check('autofill-plan-fills-unambiguous', (() => {
1091
+ const plan = autoFillPlan('vercel', 'team-b', 'https://vercel.com/login');
1092
+ return plan.kind === 'fill' && plan.username === 'b@example.com';
1093
+ })(), JSON.stringify(autoFillPlan('vercel', 'team-b', 'https://vercel.com/login')));
1094
+ check('autofill-plan-honours-per-account-opt-out', (() => {
1095
+ const plan = autoFillPlan('vercel', 'team-a', 'https://vercel.com/login', { account: { noAutoFill: true } });
1096
+ return plan.kind === 'skip' && plan.reason === 'account-opt-out';
1097
+ })());
1098
+ check('autofill-plan-honours-global-master', (() => {
1099
+ const plan = autoFillPlan('vercel', 'team-a', 'https://vercel.com/login', { account: teamA, settings: { autofill: false } });
1100
+ return plan.kind === 'skip' && plan.reason === 'autofill-off';
1101
+ })());
1102
+ check('autofill-plan-refuses-foreign-host',
1103
+ autoFillPlan('vercel', 'team-a', 'https://accounts.google.com/signin', { account: teamA }).reason === 'foreign-host');
1104
+ check('autofill-plan-offers-chooser-on-ambiguity', (() => {
1105
+ const plan = autoFillPlan('vercel', 'team-c', 'https://vercel.com/login');
1106
+ return plan.kind === 'choose' && plan.choices.length === 2 && plan.choices.every(c => !('password' in c));
1107
+ })(), JSON.stringify(autoFillPlan('vercel', 'team-c', 'https://vercel.com/login')).slice(0, 140));
1108
+ check('autofill-plan-is-silent-with-nothing-saved',
1109
+ autoFillPlan('netlify', 'site', 'https://app.netlify.com/login').reason === 'no-login');
1110
+ check('choose-summaries-carry-no-passwords', (() => {
1111
+ const plan = autoFillPlan('vercel', 'team-c', 'https://vercel.com/login');
1112
+ return plan.kind === 'choose' && !JSON.stringify(plan.choices).includes('shared-pw') && !JSON.stringify(plan.choices).includes('carol-pw');
1113
+ })());
1114
+ } finally { try { fs.rmSync(csv3, { force: true }); } catch {} }
1115
+
782
1116
  const st = creds.stats();
783
1117
  check('stats', st.total >= 3, JSON.stringify(st.bySource));
784
1118
  const browsers = creds.listChromiumProfiles();
@@ -834,12 +1168,44 @@ async function runSmokeAutofill() {
834
1168
  // fill: main pushes a stored credential into the page
835
1169
  creds.rememberForAccount('vercel', 'autofilltest', { url: 'https://vercel.com/login', username: 'me@example.com', password: 'p@ss-fill' });
836
1170
  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 });
1171
+ // 0.9.0: the answer is correlated by requestId, and main now WAITS for it — a fill is only
1172
+ // reported as done when the page really took the values.
1173
+ const fills = [];
1174
+ ipcMain.on('fill-result', (_e, r) => fills.push(r));
1175
+ const requestId = 'smoke-fill-1';
1176
+ tab.view.webContents.send('fill-credential', { requestId, username: cred.username, password: cred.password, submit: false });
838
1177
  await sleep(800);
839
1178
  const filled = await tab.view.webContents.executeJavaScript(
840
1179
  `({ email: document.querySelector('[name=email]').value, pw: document.querySelector('[name=pw]').value })`);
841
1180
  check('fill-username', filled.email === 'me@example.com', filled.email);
842
1181
  check('fill-password', filled.pw === 'p@ss-fill', filled.pw ? '(set)' : '(empty)');
1182
+ check('fill-result-carries-request-id', fills.some(r => r.requestId === requestId && r.ok === true && r.user === true && r.password === true),
1183
+ JSON.stringify(fills[fills.length - 1] || null));
1184
+
1185
+ // undo: clear exactly what the fill put in, and nothing else (the user's own typing survives)
1186
+ await tab.view.webContents.executeJavaScript(`document.querySelector('[name=email]').value = 'me@example.com'`);
1187
+ const undoId = 'smoke-undo-1';
1188
+ tab.view.webContents.send('clear-filled', { requestId: undoId, fillId: requestId });
1189
+ await sleep(600);
1190
+ const afterUndo = await tab.view.webContents.executeJavaScript(
1191
+ `({ email: document.querySelector('[name=email]').value, pw: document.querySelector('[name=pw]').value })`);
1192
+ check('undo-clears-filled-fields', afterUndo.pw === '' && afterUndo.email === '', JSON.stringify(afterUndo));
1193
+ check('undo-reports-what-it-cleared', fills.some(r => r.requestId === undoId && r.ok === true && r.cleared >= 1),
1194
+ JSON.stringify(fills[fills.length - 1] || null));
1195
+ // a second undo of the same fill must be refused: the field references are gone, not "still filled"
1196
+ const undoAgain = 'smoke-undo-2';
1197
+ tab.view.webContents.send('clear-filled', { requestId: undoAgain, fillId: requestId });
1198
+ await sleep(400);
1199
+ check('undo-twice-is-refused', fills.some(r => r.requestId === undoAgain && r.ok === false && r.reason === 'nothing-filled'));
1200
+ // an undo naming a DIFFERENT fill must not clear this document's fields either
1201
+ const undoWrong = 'smoke-undo-3';
1202
+ tab.view.webContents.send('fill-credential', { requestId: 'smoke-fill-2', username: cred.username, password: cred.password, submit: false });
1203
+ await sleep(500);
1204
+ tab.view.webContents.send('clear-filled', { requestId: undoWrong, fillId: 'some-other-fill' });
1205
+ await sleep(400);
1206
+ const stillFilled = await tab.view.webContents.executeJavaScript(`document.querySelector('[name=pw]').value`);
1207
+ check('undo-refuses-a-stale-fill-id', stillFilled === 'p@ss-fill' && fills.some(r => r.requestId === undoWrong && r.ok === false),
1208
+ stillFilled ? '(untouched)' : '(cleared — wrong)');
843
1209
 
844
1210
  // capture: submitting the form must hand the credentials back to main
845
1211
  await tab.view.webContents.executeJavaScript(`(() => {
@@ -991,6 +1357,54 @@ async function runSmokeRestore() {
991
1357
  app.exit(results.checks.every(c => c.ok) ? 0 : 1);
992
1358
  }
993
1359
 
1360
+ // ---------- demo seed: placeholder accounts + logins for screenshots ----------
1361
+ // ONLY for capture runs against a scratch profile (MAM_USER_DATA). The names are invented; no real
1362
+ // account name or password is ever used or written (CLAUDE.md §5). Idempotent, so a capture can be
1363
+ // re-run against the same scratch dir.
1364
+ function seedDemoLogins() {
1365
+ const placements = [
1366
+ ['vercel', 'work', 'demo.user.one@example.com'],
1367
+ ['vercel', 'personal', 'demo.user.two@example.com'],
1368
+ ['netlify', 'agency', 'demo.user.three@example.com'],
1369
+ ['supabase', 'staging', 'demo.user.four@example.com'],
1370
+ ['github', 'open-source', 'demo.user.five@example.com'],
1371
+ ['cloudflare', 'domains', 'demo.user.six@example.com'],
1372
+ ['railway', 'side-project', 'demo.user.seven@example.com'],
1373
+ ['stripe', 'payments', 'demo.user.eight@example.com'],
1374
+ ];
1375
+ for (const [svc, id, label] of placements) registerAccount(svc, id, label);
1376
+ // Seed the logins as well: on this machine safeStorage can encrypt in one process but not decrypt
1377
+ // what another process wrote (proved with a bare two-process round trip that never touches this
1378
+ // app, 2026-11-03), so a capture that only reads a previously seeded file would show 0 rows. The
1379
+ // seed runs FIRST, in the same process as the capture, so the screenshots show the real managers
1380
+ // with their real rows. Nothing outside the scratch profile is touched.
1381
+ // Imported-style logins (no accountKey) — the "all my logins imported" case the owner reported.
1382
+ // Written through the REAL CSV importer (the only import entry point credentials.js exports), so
1383
+ // this exercises the same merge rules a user's import does. Placeholder passwords only.
1384
+ const imports = [
1385
+ ['vercel.com', 'demo.user.one@example.com'],
1386
+ ['vercel.com', 'demo.user.two@example.com'],
1387
+ ['app.netlify.com', 'demo.user.three@example.com'],
1388
+ ['supabase.com', 'demo.user.four@example.com'],
1389
+ ['github.com', 'demo.user.five@example.com'],
1390
+ ['dash.cloudflare.com', 'demo.user.six@example.com'],
1391
+ ['railway.com', 'demo.user.seven@example.com'],
1392
+ ['dashboard.stripe.com', 'demo.user.eight@example.com'],
1393
+ ];
1394
+ const csv = path.join(app.getPath('temp'), `mam-demo-${process.pid}.csv`);
1395
+ fs.writeFileSync(csv, 'url,username,password\n' + imports.map(([h, u]) => `https://${h}/login,${u},demo-placeholder-${u.length}\n`).join(''));
1396
+ try { creds.importFromCsv(csv); } finally { try { fs.rmSync(csv, { force: true }); } catch {} }
1397
+ // every account keeps its OWN captured login (so "saved for this account" is exercised too)
1398
+ for (const [svc, id, who] of [['vercel', 'work', 'demo.user.one@example.com'], ['github', 'open-source', 'demo.user.five@example.com']]) {
1399
+ creds.rememberForAccount(svc, id, { url: serviceByKey(svc).loginUrl, username: who, password: `demo-placeholder-${who.length}` });
1400
+ }
1401
+ const rows = creds.listCredentials();
1402
+ // one ★ so the manager shows the "Default for …" line, the way a real profile would
1403
+ const first = rows.find(c => c.host === 'vercel.com' && c.username === 'demo.user.two@example.com');
1404
+ if (first) creds.setDefaultForAccount(first.id, 'vercel', 'personal', 'https://vercel.com/login');
1405
+ console.log(`[demo] seeded ${store.accounts.length} accounts, ${creds.stats().total} logins`);
1406
+ }
1407
+
994
1408
  // ---------- interactive mode ----------
995
1409
  function runInteractive(captureMode = false) {
996
1410
  // BrowserWindow, not BaseWindow: the chrome page needs loadFile/webContents/ready-to-show, which
@@ -1043,6 +1457,21 @@ function runInteractive(captureMode = false) {
1043
1457
  }
1044
1458
  if (scrollPx) await win.webContents.executeJavaScript(`document.getElementById('home').scrollTop = ${scrollPx}`).catch(() => {});
1045
1459
  if (runJs) await win.webContents.executeJavaScript(runJs).catch(err => console.error('[capture] js failed:', err.message));
1460
+ // --demo-notice: drive the auto-fill banner exactly the way main does (through
1461
+ // autoFillPlan + vm.setNotice), so a screenshot shows the real banner path and not
1462
+ // hand-written DOM. Verification aid for a scratch demo profile.
1463
+ if (process.argv.includes('--demo-notice') && openKey) {
1464
+ const [svc, id] = openKey.split('::');
1465
+ const plan = autoFillPlan(svc, id, serviceByKey(svc).loginUrl, { account: findAccount(svc, id) });
1466
+ // On a demo profile the logins may be unreadable (DPAPI is per-launch here), so the
1467
+ // screenshot's banner uses the same shapes the real paths raise, with placeholder text.
1468
+ vm.setNotice(openKey, plan.kind === 'fill'
1469
+ ? { kind: 'filled', username: plan.username, credentialId: plan.credentialId, fillId: 'demo' }
1470
+ : plan.kind === 'choose' ? { kind: 'choose', choices: plan.choices }
1471
+ : { kind: 'filled', username: 'demo.user.two@example.com', credentialId: 'demo-cred', fillId: 'demo' });
1472
+ console.log('[capture] demo notice:', plan.kind, (plan.choices || []).length);
1473
+ await new Promise(r => setTimeout(r, 700));
1474
+ }
1046
1475
  if (extraWait) await new Promise(r => setTimeout(r, extraWait));
1047
1476
  // UnknownVizError / empty frames clear once the compositor produces a frame — retry
1048
1477
  let img = null; let lastErr = null;
@@ -1250,73 +1679,211 @@ function runInteractive(captureMode = false) {
1250
1679
  const found = tabForSender(e.sender);
1251
1680
  if (!found) return;
1252
1681
  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) {
1682
+ if (found.tab.loginForm) {
1255
1683
  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 });
1684
+ scheduleAutoFill(found.key, found.tab, pageUrl);
1262
1685
  }
1263
1686
  vm.emitState();
1264
1687
  });
1265
- ipcMain.on('fill-result', () => {});
1688
+ // Fill outcomes from the account preload (0.9.0: the result was ignored in 0.8.0, so a manual fill
1689
+ // failed silently and an automatic one was invisible). Resolve the awaiting request, and for an
1690
+ // automatic fill raise the banner — "Filled … · Undo" or the honest reason it did not fill.
1691
+ ipcMain.on('fill-result', (e, result) => {
1692
+ const found = tabForSender(e.sender);
1693
+ if (!found) return;
1694
+ const entry = vm.settleFill(found.key, result || {});
1695
+ if (entry && entry.auto) noticeFromAutoFill(found.key, found.tab, entry.credentialId, result || {});
1696
+ });
1697
+
1698
+ // ---- auto-fill: plan -> fill -> announce (0.9.0 C) ----
1699
+ // The plan decides everything before anything is typed. It never guesses (D30) and it never fires
1700
+ // for an account whose "never auto-fill" switch is on. Runs a touch after the form report so a
1701
+ // script-rendered SPA form has settled.
1702
+ function scheduleAutoFill(key, tab, pageUrl) {
1703
+ const token = (tab.autoFillToken || 0) + 1;
1704
+ tab.autoFillToken = token;
1705
+ tab.loginPending = { url: pageUrl, at: Date.now() };
1706
+ setTimeout(() => {
1707
+ if (vm.tabs.get(key) !== tab || tab.autoFillToken !== token) return; // slept, recreated, or a newer form report
1708
+ autoFillNow(key, tab, pageUrl).catch(err => console.error('[logins] auto-fill failed:', err.message));
1709
+ }, AUTO_FILL_DELAY_MS);
1710
+ }
1711
+
1712
+ async function autoFillNow(key, tab, pageUrl) {
1713
+ const plan = autoFillPlan(tab.svc, tab.id, pageUrl);
1714
+ if (plan.kind === 'skip') {
1715
+ // The 0.8.0 defect: an ambiguous page did nothing at all, so the user could not tell a missing
1716
+ // login from a broken feature. Ambiguity now raises the chooser banner instead.
1717
+ const existing = tab.notice;
1718
+ if (!existing || existing.kind !== 'choose') vm.setNotice(key, null);
1719
+ return plan;
1720
+ }
1721
+ if (plan.kind === 'choose') {
1722
+ vm.setNotice(key, { kind: 'choose', choices: plan.choices, url: pageUrl });
1723
+ return plan;
1724
+ }
1725
+ const picked = creds.candidatesForAccount(tab.svc, tab.id, pageUrl).find(c => c.id === plan.credentialId);
1726
+ if (!picked) return { kind: 'skip', reason: 'unresolved' };
1727
+ const result = await pushFill(tab, picked, { auto: true });
1728
+ noticeFromAutoFill(key, tab, picked.id, result);
1729
+ return { kind: 'fill', requestId: result.requestId, ok: result.ok, reason: result.reason };
1730
+ }
1731
+
1732
+ function noticeFromAutoFill(key, tab, credentialId, result) {
1733
+ const username = (creds.loadAll().find(c => c.id === credentialId) || {}).username || '';
1734
+ if (result.ok) {
1735
+ vm.setNotice(key, { kind: 'filled', username, credentialId, fillId: result.requestId, fields: { user: !!result.user, password: !!result.password } });
1736
+ return;
1737
+ }
1738
+ const reasons = {
1739
+ 'no-form': 'this page shows no sign-in form yet',
1740
+ 'no-login': 'nothing is saved for this site yet',
1741
+ 'tab-closed': 'the tab closed before the form answered',
1742
+ timeout: 'the page did not answer',
1743
+ };
1744
+ vm.setNotice(key, { kind: 'failed', username, reason: reasons[result.reason] || 'the page had no visible password field' });
1745
+ }
1266
1746
 
1267
- ipcMain.handle('fill-login', (_e, key, credentialId = null) => {
1747
+ // Push one credential into a tab and WAIT for the preload's answer, so "filled" is a fact and not
1748
+ // an assumption. The timeout exists because a page can navigate mid-fill.
1749
+ function pushFill(tab, cred, opts = {}) {
1750
+ const requestId = crypto.randomUUID();
1751
+ const p = new Promise(resolve => {
1752
+ const timer = setTimeout(() => {
1753
+ tab.pendingFills.delete(requestId);
1754
+ resolve({ ok: false, reason: 'timeout' });
1755
+ }, FILL_TIMEOUT_MS);
1756
+ tab.pendingFills.set(requestId, { resolve, timer, auto: !!opts.auto, credentialId: cred.id });
1757
+ });
1758
+ try {
1759
+ tab.view.webContents.send('fill-credential', {
1760
+ requestId, username: cred.username, password: cred.password, submit: !!opts.submit,
1761
+ });
1762
+ } catch (err) {
1763
+ tab.pendingFills.delete(requestId);
1764
+ return Promise.resolve({ ok: false, reason: 'send-failed' });
1765
+ }
1766
+ return p;
1767
+ }
1768
+
1769
+ ipcMain.handle('fill-login', async (_e, key, credentialId = null) => {
1268
1770
  const t = vm.tabs.get(key);
1269
- if (!t) throw new Error('That tab is asleep — open it first');
1771
+ if (!t) return { ok: false, reason: 'asleep', message: 'That tab is asleep — open it first.' };
1270
1772
  const [svc, id] = key.split('::');
1271
1773
  const pageUrl = t.view.webContents.getURL();
1272
1774
  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`);
1775
+ return {
1776
+ ok: false, reason: 'foreign-host',
1777
+ message: `This tab is on ${creds.hostOf(pageUrl) || 'another site'}, not ${serviceHosts(svc)[0] || 'the service'} — refusing to fill a saved password here.`,
1778
+ };
1779
+ }
1780
+ const { candidates } = fillCandidatesFor(svc, id, pageUrl);
1781
+ if (!candidates.length) {
1782
+ return { ok: false, reason: 'no-login', message: `No saved login for this ${svcOfName(svc)} account yet — sign in once, or import your passwords.` };
1274
1783
  }
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
1784
  // The user may have navigated since the chooser was rendered. Recompute from the LIVE URL;
1279
1785
  // if the picked id no longer belongs here, return fresh summaries instead of persisting a stale default.
1280
1786
  if (credentialId && !candidates.some(c => c.id === credentialId)) {
1281
- return { choose: creds.candidateSummariesForAccount(svc, id, pageUrl) };
1787
+ return { choose: summariesOf(candidates, svc, id) };
1788
+ }
1789
+ const cred = resolveAmong(candidates, accountKey(svc, id), credentialId || null);
1790
+ if (!cred) return { choose: summariesOf(candidates, svc, id) };
1791
+ if (credentialId) { useAsDefault(svc, id, credentialId); vm.emitState(); } // only a candidate of this account + this service
1792
+ const result = await pushFill(t, cred, { submit: false });
1793
+ if (result.ok) {
1794
+ vm.setNotice(key, { kind: 'filled', username: cred.username, credentialId: cred.id, fillId: result.requestId, fields: { user: !!result.user, password: !!result.password } });
1795
+ return { ok: true, filled: cred.username, requestId: result.requestId };
1282
1796
  }
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 };
1797
+ const messages = {
1798
+ 'no-form': 'No sign-in form is visible on this page. Sign in once here and it will be filled next time.',
1799
+ 'tab-closed': 'The tab closed before the form answered.',
1800
+ timeout: 'The page did not answer — it may have navigated while filling.',
1801
+ };
1802
+ return { ok: false, reason: result.reason, message: messages[result.reason] || 'This page has no visible password field to fill.' };
1288
1803
  });
1289
1804
  ipcMain.handle('creds-stats', () => creds.stats());
1290
1805
  ipcMain.handle('creds-browsers', () => creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData })));
1291
1806
  ipcMain.handle('creds-import-browser', async (_e, id) => {
1292
1807
  const p = creds.listChromiumProfiles().find(x => x.loginData === id);
1293
1808
  if (!p) throw new Error('Browser profile not found');
1294
- return creds.importFromChromium(p);
1809
+ const r = await creds.importFromChromium(p);
1810
+ vm.emitState();
1811
+ return r;
1295
1812
  });
1296
1813
  ipcMain.handle('creds-import-csv', async () => {
1297
1814
  const { dialog } = require('electron');
1298
1815
  const r = await dialog.showOpenDialog({ title: 'Import passwords from CSV', filters: [{ name: 'CSV', extensions: ['csv'] }], properties: ['openFile'] });
1299
1816
  if (r.canceled || !r.filePaths[0]) return null;
1300
- return creds.importFromCsv(r.filePaths[0]);
1817
+ const res = creds.importFromCsv(r.filePaths[0]);
1818
+ vm.emitState();
1819
+ return res;
1301
1820
  });
1302
- ipcMain.handle('creds-clear', () => creds.clearAll());
1821
+ ipcMain.handle('creds-clear', () => { creds.clearAll(); vm.emitState(); return true; });
1303
1822
  ipcMain.handle('creds-for-account', (_e, svc, id) => {
1304
1823
  const svcCfg = serviceByKey(svc);
1305
1824
  const c = creds.credentialForAccount(svc, id, svcCfg && svcCfg.loginUrl);
1306
1825
  return c ? { username: c.username, host: c.host, source: c.source } : null; // never the password
1307
1826
  });
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 };
1827
+ // Per-account "Logins" list for the gear modal and the rail badge: summaries only, never passwords.
1828
+ ipcMain.handle('creds-account-list', (_e, svc, id) => ({
1829
+ logins: accountLoginSummaries(svc, id),
1830
+ signInUrl: (serviceByKey(svc) || {}).loginUrl || null,
1316
1831
  }));
1832
+ // Credential manager: summaries only, never passwords — the renderer must not hold one.
1833
+ // Imported passwords are classified by host, with the accounts each row can serve, so the manager
1834
+ // filters and the ★ markers both come from the main process (the only authority on them).
1835
+ ipcMain.handle('creds-list', () => managerRows());
1317
1836
  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));
1837
+ setOrClearDefault(credentialId, svc, id, hostHint));
1838
+ ipcMain.handle('creds-delete', (_e, credentialId) => {
1839
+ const ok = creds.deleteCredential(credentialId);
1840
+ vm.emitState();
1841
+ return { ok, rows: managerRows() };
1842
+ });
1843
+ // Per-account "never auto-fill" switch (0.9.0 C). Stored on the account, default off — the global
1844
+ // Auto-fill toggle stays the master and this can only subtract from it, never add.
1845
+ ipcMain.handle('account-no-autofill', (_e, svc, id, value) => {
1846
+ const acc = findAccount(svc, id);
1847
+ if (!acc) throw new Error('unknown account');
1848
+ acc.noAutoFill = !!value;
1849
+ store.save();
1850
+ vm.emitState();
1851
+ return acc.noAutoFill;
1852
+ });
1853
+ // Dismiss the per-tab notice (banner X).
1854
+ ipcMain.handle('dismiss-notice', (_e, key) => { vm.setNotice(key, null); return true; });
1855
+ // Undo an auto-fill: the preload clears exactly what it filled and answers with what it cleared.
1856
+ // The undo has its own requestId for routing; `fillId` names the fill it is undoing, so a stale
1857
+ // Undo (or one aimed at a different fill) is refused by the page instead of clearing live fields.
1858
+ ipcMain.handle('undo-fill', async (_e, key) => {
1859
+ const t = vm.tabs.get(key);
1860
+ if (!t) return { ok: false, reason: 'asleep' };
1861
+ const notice = t.notice;
1862
+ if (!notice || notice.kind !== 'filled' || !notice.fillId) return { ok: false, reason: 'no-notice' };
1863
+ const requestId = crypto.randomUUID();
1864
+ const result = await new Promise(resolve => {
1865
+ const timer = setTimeout(() => { t.pendingFills.delete(requestId); resolve({ ok: false, reason: 'timeout' }); }, FILL_TIMEOUT_MS);
1866
+ t.pendingFills.set(requestId, { resolve, timer, auto: false });
1867
+ t.view.webContents.send('clear-filled', { requestId, fillId: notice.fillId });
1868
+ });
1869
+ if (result.ok) vm.setNotice(key, null);
1870
+ return result;
1871
+ });
1872
+ // The chooser a notice offers: same resolution path as a manual fill, then dismiss the banner.
1873
+ ipcMain.handle('notice-choose', async (_e, key, credentialId) => {
1874
+ const t = vm.tabs.get(key);
1875
+ if (!t) return { ok: false, reason: 'asleep' };
1876
+ const [svc, id] = key.split('::');
1877
+ const pageUrl = t.view.webContents.getURL();
1878
+ const { candidates } = fillCandidatesFor(svc, id, pageUrl);
1879
+ const picked = candidates.find(c => c.id === credentialId);
1880
+ if (!picked) return { ok: false, reason: 'stale' };
1881
+ useAsDefault(svc, id, picked.id);
1882
+ vm.emitState();
1883
+ const result = await pushFill(t, picked, { submit: false });
1884
+ if (result.ok) vm.setNotice(key, { kind: 'filled', username: picked.username, credentialId: picked.id, fillId: result.requestId, fields: { user: !!result.user, password: !!result.password } });
1885
+ return result.ok ? { ok: true, filled: picked.username } : { ok: false, reason: result.reason };
1886
+ });
1320
1887
  // ---------- Phase 3: service plugins (JSON manifests) ----------
1321
1888
  ipcMain.handle('plugins-list', () => ({
1322
1889
  dir: plugins.pluginsDir(),
@@ -1498,6 +2065,14 @@ function runInteractive(captureMode = false) {
1498
2065
  });
1499
2066
  }
1500
2067
 
2068
+ // Test-only exports (used by the smoke modes). These are the exact functions the IPC handlers above
2069
+ // call — smoke proves the real projection, not a copy of it.
2070
+ module.exports = {
2071
+ accountLoginSummaries, managerRows, setOrClearDefault, resolveAmong, fillCandidatesFor, autoFillPlan,
2072
+ originAllowed, accountKey,
2073
+ _creds: creds,
2074
+ };
2075
+
1501
2076
  function cycleTab(vm, dir) {
1502
2077
  const keys = [...vm.tabs.keys()];
1503
2078
  if (!keys.length) return;
@@ -1522,7 +2097,7 @@ if (!gotLock) {
1522
2097
  const [w] = BaseWindow.getAllWindows();
1523
2098
  if (w) { if (w.isMinimized()) w.restore(); w.focus(); }
1524
2099
  });
1525
- app.whenReady().then(() => {
2100
+ app.whenReady().then(async () => {
1526
2101
  // --print-paths: where does this build keep its data? (support + rename verification)
1527
2102
  if (process.argv.includes('--print-paths')) {
1528
2103
  console.log(JSON.stringify({
@@ -1534,6 +2109,24 @@ if (!gotLock) {
1534
2109
  return app.exit(0);
1535
2110
  }
1536
2111
  store.load();
2112
+ // --seed-demo-logins: placeholder accounts + logins, for screenshots of a scratch profile only.
2113
+ // Requires MAM_USER_DATA (a scratch dir): it must never touch the real profile.
2114
+ if (process.argv.includes('--seed-demo-logins')) {
2115
+ if (!process.env.MAM_USER_DATA) {
2116
+ console.error('[demo] --seed-demo-logins refuses to run without MAM_USER_DATA — point it at a scratch profile');
2117
+ return app.exit(2);
2118
+ }
2119
+ // Wait for safeStorage to become ready before touching credentials.js. On this machine the
2120
+ // OS key is not available during app.whenReady() yet (credentials.js checks
2121
+ // isEncryptionAvailable()); a listCredentials() call before that answers [] and caching that
2122
+ // empty answer is exactly how the manager could show "0 stored" for a full profile.
2123
+ const { safeStorage } = require('electron');
2124
+ const deadline = Date.now() + 8000;
2125
+ while (!safeStorage.isEncryptionAvailable() && Date.now() < deadline) await new Promise(r => setTimeout(r, 100));
2126
+ console.log(`[demo] safeStorage available: ${safeStorage.isEncryptionAvailable()}`);
2127
+ seedDemoLogins();
2128
+ if (!process.argv.includes('--capture')) return app.exit(0);
2129
+ }
1537
2130
  if (process.argv.includes('--spike')) runSpike().catch(err => { console.error(err); app.exit(1); });
1538
2131
  else if (process.argv.includes('--smoke')) runSmoke().catch(err => { console.error(err); app.exit(1); });
1539
2132
  else if (process.argv.includes('--smoke-restore')) runSmokeRestore().catch(err => { console.error(err); app.exit(1); });