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

|
|
28
34
|
|
|
29
|
-
<sub>Screenshots
|
|
35
|
+
<sub>Screenshots come from a demo profile on v0.6.x. The service-picker capture shows 27 services
|
|
36
|
+
because that was the registry at the time; the current build has 37. Account names are examples.
|
|
37
|
+
Current UI additions — the single Dark mode toggle and Manage logins — are in the
|
|
38
|
+
[changelog](docs/CHANGELOG.md).</sub>
|
|
39
|
+
|
|
30
40
|
|
|
31
41
|
---
|
|
32
42
|
|
|
@@ -110,7 +120,7 @@ sessions, one place.
|
|
|
110
120
|
|
|
111
121
|
---
|
|
112
122
|
|
|
113
|
-
##
|
|
123
|
+
## Quickstart
|
|
114
124
|
|
|
115
125
|
1. **Add an account.** Click **Add account** at the bottom of the rail (or the `+` next to a provider
|
|
116
126
|
you already use), pick the service from the searchable picker, name the account, then choose
|
|
@@ -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
|
-
- **
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
344
|
-
npm run smoke:autofill # real page: form detection, fill, capture, origin guard (
|
|
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
|
|
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.
|
|
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(
|
|
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.
|
|
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',
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
const serviceKeys = accountSvc ? [accountSvc] : allServices()
|
|
1313
|
-
.filter(s => serviceHosts(s.key).some(h => creds.hostRelated(c.host, h)))
|
|
1314
|
-
.map(s => s.key);
|
|
1315
|
-
return { ...c, serviceKeys };
|
|
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
|
-
|
|
1319
|
-
ipcMain.handle('creds-delete', (_e, 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); });
|