my-provider-dash-manager 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/package.json +1 -1
- package/src/main.js +204 -2
package/README.md
CHANGED
|
@@ -190,8 +190,14 @@ guard trusts (`npm run smoke:services`). Google sign-in was verified against Goo
|
|
|
190
190
|
works in the app. Anything else can be added by URL or as a plugin.
|
|
191
191
|
|
|
192
192
|
### Password manager with per-service import
|
|
193
|
+
- **A picker for the site you are on.** A key chip beside the address bar counts the saved logins for
|
|
194
|
+
the page's current site; click it for a **searchable** list with **Use** (fills the email and
|
|
195
|
+
password fields and sets that account's default, with Undo), **Copy email** and **Copy password**.
|
|
196
|
+
The chip refreshes as you navigate and disappears when the site has none.
|
|
193
197
|
- **Remembers logins you type** inside an account tab, bound to *that* account, so two accounts on the
|
|
194
198
|
same service refill with different credentials.
|
|
199
|
+
- **Your imported logins show up on every matching site.** The picker lists this account's own saved
|
|
200
|
+
logins *and* every imported login whose site matches the page, labelled with where it came from.
|
|
195
201
|
- **See each account's logins.** Open an account's **Manage** (gear) window and its *Saved logins* list
|
|
196
202
|
shows exactly the logins that belong to it, with **Use** to make one that account's default and fill
|
|
197
203
|
it into the open tab. A small key on the rail row marks every account that has a saved login, even
|
|
@@ -204,6 +210,9 @@ works in the app. Anything else can be added by URL or as a plugin.
|
|
|
204
210
|
filterable by service or by account, with set/clear default and delete. No password is ever shown.
|
|
205
211
|
- **Choose which login fills:** when more than one matches a site the app asks instead of guessing,
|
|
206
212
|
and your choice is remembered as that account's default (marked ★ in the manager).
|
|
213
|
+
- **Copy without revealing:** copying an email or password puts it on the Windows clipboard from the
|
|
214
|
+
app's secured process. A password is never displayed and never sent to the window that draws the
|
|
215
|
+
interface, and a copy is refused unless the tab is on that login's own site.
|
|
207
216
|
- **Fills from anywhere** on the service's own site: if the page you are on has no matching login (a
|
|
208
217
|
dashboard, say), the account's login for that service is used. Filling on any other domain is
|
|
209
218
|
refused, and a fill that cannot happen now says why instead of failing quietly.
|
|
@@ -360,7 +369,7 @@ npm start # run the app
|
|
|
360
369
|
npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
|
|
361
370
|
npm run smoke:restore # sessions survive a restart (real cookies)
|
|
362
371
|
npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
|
|
363
|
-
npm run smoke:creds # import, per-account/manager lists, defaults, fill fallback, auto-fill plan, encryption at rest (
|
|
372
|
+
npm run smoke:creds # import, per-account/manager/site lists, defaults, copy, fill fallback, auto-fill plan, encryption at rest (50)
|
|
364
373
|
npm run smoke:autofill # real page: form detection, fill + request ids, undo, capture, origin guard (15)
|
|
365
374
|
npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
|
|
366
375
|
npm run smoke:services # every built-in login page loads inside an isolated partition (42)
|
|
@@ -373,7 +382,7 @@ npm start -- --print-paths # show the app name, version and data fold
|
|
|
373
382
|
npm start -- --open vercel::personal # open an account on launch
|
|
374
383
|
```
|
|
375
384
|
|
|
376
|
-
The suite is
|
|
385
|
+
The suite is 156 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
|
|
377
386
|
profile.
|
|
378
387
|
|
|
379
388
|
Releases are cut by pushing a `v*` tag: GitHub Actions checks the tag against `package.json`, builds
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-provider-dash-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "CLI to install and update My Provider Dash Manager: a free Windows desktop app that keeps many accounts per service (Vercel, Netlify, Supabase, Cloudflare, AWS, Google, Microsoft, GitHub, Gmail) signed in at once, each in a fully isolated session. Also available as mpdm.",
|
|
5
5
|
"main": "src/main.js",
|
|
6
6
|
"scripts": {
|
package/src/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { app, BaseWindow, BrowserWindow, WebContentsView, session, ipcMain, shell, Menu } = require('electron');
|
|
1
|
+
const { app, BaseWindow, BrowserWindow, WebContentsView, session, ipcMain, shell, Menu, clipboard } = require('electron');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const crypto = require('crypto');
|
|
@@ -227,6 +227,67 @@ function useAsDefault(svc, id, credentialId) {
|
|
|
227
227
|
creds.setDefaultForAccount(credentialId, svc, id, host);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// ---------- per-site picker (0.10.0) ----------
|
|
231
|
+
// What the key chip in the address strip counts, and what its popover lists: everything that could
|
|
232
|
+
// sign you in on the host the tab is ON right now — this account's own captures for that host, plus
|
|
233
|
+
// every SHARED import whose host relates to it (the owner's "all my google passes on any google
|
|
234
|
+
// login field"; imports are not owned by an account, so they are the same list for every account).
|
|
235
|
+
// A capture made by a DIFFERENT account of the same service is deliberately NOT here: invariant 1
|
|
236
|
+
// binds a credential to its account AND origin, and 0.9.0's accountLoginSummaries draws the same
|
|
237
|
+
// boundary ("it is that account's login"). Widening this to siblings would let one account's tab
|
|
238
|
+
// read another account's saved password on a shared host — the account half of D14.
|
|
239
|
+
// Summaries only: id / username / host / source / ★.
|
|
240
|
+
function siteLoginRows(svc, id, liveUrl, list) {
|
|
241
|
+
const host = creds.hostOf(liveUrl || '');
|
|
242
|
+
if (!host) return { host: '', rows: [] };
|
|
243
|
+
const key = accountKey(svc, id);
|
|
244
|
+
const rows = (list || credSummaries())
|
|
245
|
+
.filter(c => c.host && creds.hostRelated(c.host, host))
|
|
246
|
+
.filter(c => !c.accountKey || c.accountKey === key)
|
|
247
|
+
.map(c => {
|
|
248
|
+
// An import has no owning account, so its label is null; if a credential ever carries another
|
|
249
|
+
// account's key it would be named here (the projection stays honest either way).
|
|
250
|
+
const owner = c.accountKey && c.accountKey !== key ? store.accounts.find(a => accountKey(a.svc, a.id) === c.accountKey) : null;
|
|
251
|
+
return {
|
|
252
|
+
id: c.id, username: c.username || '', host: c.host || '', source: c.source || 'unknown',
|
|
253
|
+
own: c.accountKey === key, accountLabel: owner ? (owner.label || owner.id) : null,
|
|
254
|
+
isDefault: (c.defaultForAccounts || []).includes(key),
|
|
255
|
+
};
|
|
256
|
+
})
|
|
257
|
+
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || Number(b.own) - Number(a.own)
|
|
258
|
+
|| String(a.username).localeCompare(String(b.username)));
|
|
259
|
+
return { host, rows };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Copying a saved password is the one operation that touches the secret outside a fill. It stays in
|
|
263
|
+
// the MAIN process: the value goes from credentials.js straight to the OS clipboard, and the caller
|
|
264
|
+
// gets a label, never the value (invariant 3 holds and is not weakened by a reveal path).
|
|
265
|
+
// `svc`/`id`/`liveUrl` are supplied by the IPC wrapper from the ACTIVE TAB (never from the renderer),
|
|
266
|
+
// so a copy can only ever happen on the tab the picker belongs to.
|
|
267
|
+
function copyCredentialField(credentialId, field, svc, id, liveUrl) {
|
|
268
|
+
if (field !== 'username' && field !== 'password') return { ok: false, reason: 'bad-field', message: 'Unknown field.' };
|
|
269
|
+
const liveHost = creds.hostOf(liveUrl || '');
|
|
270
|
+
// The same gate every fill passes (D14): the tab must be ON one of this service's own sites...
|
|
271
|
+
if (!originAllowed(svc, liveUrl)) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false, reason: 'foreign-host',
|
|
274
|
+
message: `This tab is on ${liveHost || 'another site'}, not ${serviceHosts(svc)[0] || 'the service'} — refusing to copy a saved password here.`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const key = accountKey(svc, id);
|
|
278
|
+
// ...and the credential must be one THIS account may use on this page: its own capture for the
|
|
279
|
+
// live site, or a shared import for it. Another account's capture is refused here even though the
|
|
280
|
+
// picker never lists it — the account half of invariant 1 (a capture belongs to its account).
|
|
281
|
+
const cred = creds.loadAll().find(c => c.id === credentialId);
|
|
282
|
+
if (!cred) return { ok: false, reason: 'unknown-id', message: 'That saved login no longer exists.' };
|
|
283
|
+
if (cred.accountKey && cred.accountKey !== key) return { ok: false, reason: 'other-account', message: 'That saved login belongs to a different account.' };
|
|
284
|
+
if (!cred.host || !creds.hostRelated(cred.host, liveHost)) return { ok: false, reason: 'other-site', message: 'That saved login belongs to a different site.' };
|
|
285
|
+
const value = field === 'password' ? cred.password : cred.username;
|
|
286
|
+
if (!value) return { ok: false, reason: 'empty', message: `That saved login has no ${field} stored.` };
|
|
287
|
+
clipboard.writeText(value);
|
|
288
|
+
return { ok: true, field, label: `${field === 'password' ? 'password' : 'email'} for ${cred.username || cred.host || 'this login'}` };
|
|
289
|
+
}
|
|
290
|
+
|
|
230
291
|
// Explicit clear semantics for creds-default (0.8.0 relied on the "null id never matches" side
|
|
231
292
|
// effect). A null id may ONLY clear: it never sets a default anywhere, and it needs the account it
|
|
232
293
|
// applies to. Returns the authoritative manager list either way.
|
|
@@ -479,6 +540,9 @@ class ViewManager {
|
|
|
479
540
|
// isolated-world reset requires — the world (and Dark Reader in it) is gone by then and must
|
|
480
541
|
// be re-injected. Same-document (pushState) routing never reaches here, so it never re-injects.
|
|
481
542
|
if (store.settings.darkMode) dark.enable(view.webContents);
|
|
543
|
+
// A full load can land on a different host than did-navigate reported (client-side redirects):
|
|
544
|
+
// refresh the address strip's key chip from the URL that actually finished loading (0.10.0).
|
|
545
|
+
this.emitState();
|
|
482
546
|
});
|
|
483
547
|
const onNav = (_e, url2) => {
|
|
484
548
|
tab.url = url2;
|
|
@@ -648,8 +712,19 @@ class ViewManager {
|
|
|
648
712
|
emitState() {
|
|
649
713
|
if (!this.window || !this.window.webContents) return;
|
|
650
714
|
const loginList = credSummaries(); // one decryption per state emit, not one per account
|
|
715
|
+
// The key chip in the address strip counts saved logins for the ACTIVE tab's live host (0.10.0).
|
|
716
|
+
// Recomputed here, so it is fresh after every navigation and every tab activation, and it is
|
|
717
|
+
// computed for the active tab only — the other tabs refresh when they are activated.
|
|
718
|
+
let siteLogins = null;
|
|
719
|
+
const activeTab = this.activeKey ? this.tabs.get(this.activeKey) : null;
|
|
720
|
+
if (activeTab && !activeTab.view.webContents.isDestroyed()) {
|
|
721
|
+
const liveUrl = activeTab.view.webContents.getURL();
|
|
722
|
+
const { host, rows } = siteLoginRows(activeTab.svc, activeTab.id, liveUrl, loginList);
|
|
723
|
+
siteLogins = { key: this.activeKey, host, count: host ? rows.length : 0 };
|
|
724
|
+
}
|
|
651
725
|
this.window.webContents.send('tabs-state', {
|
|
652
726
|
activeKey: this.activeKey,
|
|
727
|
+
siteLogins,
|
|
653
728
|
warmLimit: store.settings.warmLimit,
|
|
654
729
|
registry: store.accounts.map(a => ({
|
|
655
730
|
...a,
|
|
@@ -1111,6 +1186,65 @@ async function runSmokeCreds() {
|
|
|
1111
1186
|
const plan = autoFillPlan('vercel', 'team-c', 'https://vercel.com/login');
|
|
1112
1187
|
return plan.kind === 'choose' && !JSON.stringify(plan.choices).includes('shared-pw') && !JSON.stringify(plan.choices).includes('carol-pw');
|
|
1113
1188
|
})());
|
|
1189
|
+
|
|
1190
|
+
// ---------- 0.10.0: the per-site picker ----------
|
|
1191
|
+
// The fixture above: team-a holds a@example.com plus the two shared imports, team-b owns
|
|
1192
|
+
// b@example.com, netlify's account owns nothing. A picker on vercel.com for team-a shows the
|
|
1193
|
+
// account's own capture AND every shared import on the live host — never a sibling's capture
|
|
1194
|
+
// (invariant 1: a capture belongs to the account that made it).
|
|
1195
|
+
const siteA = siteLoginRows('vercel', 'team-a', 'https://vercel.com/login');
|
|
1196
|
+
check('site-list-includes-shared-imports-from-another-account',
|
|
1197
|
+
siteA.rows.map(r => r.username).sort().join() === ['a@example.com', 'carol@example.com', 'shared@example.com'].join()
|
|
1198
|
+
&& !siteA.rows.some(r => r.username === 'b@example.com'),
|
|
1199
|
+
`${siteA.host}: ${siteA.rows.map(r => `${r.username}${r.accountLabel ? `[${r.accountLabel}]` : r.own ? '[own]' : '[import]'}`).join(',')}`);
|
|
1200
|
+
check('site-list-rows-have-no-password-key',
|
|
1201
|
+
siteA.rows.every(r => !('password' in r) && !JSON.stringify(r).includes('shared-pw') && !JSON.stringify(r).includes('pw-a')),
|
|
1202
|
+
`${siteA.rows.length} rows: ${JSON.stringify(Object.keys(siteA.rows[0] || {}))}`);
|
|
1203
|
+
// An import is shared (no owning account); a capture made by another account is that account's
|
|
1204
|
+
// login and is neither listed nor copyable from here (invariant 1, the 0.9.0 boundary).
|
|
1205
|
+
check('site-list-scope-is-own-captures-plus-shared-imports',
|
|
1206
|
+
siteA.rows.filter(r => r.accountLabel === null).length === 3
|
|
1207
|
+
&& siteA.rows.find(r => r.username === 'a@example.com').own === true
|
|
1208
|
+
&& siteA.rows.filter(r => r.username === 'shared@example.com' || r.username === 'carol@example.com').every(r => r.own === false && r.accountLabel === null),
|
|
1209
|
+
siteA.rows.map(r => `${r.username}:${r.own ? 'own' : 'shared'}`).join(' '));
|
|
1210
|
+
// D14: a copy is refused when the tab is not on the service's own site ...
|
|
1211
|
+
const offSite = copyCredentialField(siteA.rows[0].id, 'password', 'vercel', 'team-a', 'https://accounts.google.com/signin');
|
|
1212
|
+
check('copy-refused-off-site', offSite.ok === false && offSite.reason === 'foreign-host', `reason=${offSite.reason}`);
|
|
1213
|
+
// ... and an unknown id never yields a secret
|
|
1214
|
+
const unknown = copyCredentialField('no-such-credential', 'password', 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1215
|
+
check('copy-refused-unknown-id', unknown.ok === false && unknown.reason === 'unknown-id', `reason=${unknown.reason}`);
|
|
1216
|
+
// a sibling account's capture is never available here, even on its own site (invariant 1)
|
|
1217
|
+
const otherAcct = creds.loadAll().find(c => c.accountKey === 'vercel::team-b');
|
|
1218
|
+
const sibling = copyCredentialField(otherAcct.id, 'password', 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1219
|
+
check('copy-refused-another-accounts-capture', sibling.ok === false && sibling.reason === 'other-account', `reason=${sibling.reason}`);
|
|
1220
|
+
clipboard.clear();
|
|
1221
|
+
// the real copy path: main writes the clipboard, and the answer is a label — never the secret
|
|
1222
|
+
clipboard.writeText('sentinel-before-copy');
|
|
1223
|
+
const sharedRow = siteA.rows.find(r => r.username === 'shared@example.com');
|
|
1224
|
+
const copied = copyCredentialField(sharedRow.id, 'password', 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1225
|
+
check('copy-writes-the-right-password-to-the-clipboard',
|
|
1226
|
+
copied.ok === true && clipboard.readText() === 'shared-pw' && !JSON.stringify(copied).includes('shared-pw'),
|
|
1227
|
+
`ok=${copied.ok} label="${copied.label}" clipboard-match=${clipboard.readText() === 'shared-pw'}`);
|
|
1228
|
+
const copiedUser = copyCredentialField(sharedRow.id, 'username', 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1229
|
+
check('copy-email-writes-the-username', copiedUser.ok === true && clipboard.readText() === 'shared@example.com',
|
|
1230
|
+
`label="${copiedUser.label}"`);
|
|
1231
|
+
const badField = copyCredentialField(sharedRow.id, 'reveal', 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1232
|
+
check('copy-refuses-an-unknown-field', badField.ok === false && badField.reason === 'bad-field', `reason=${badField.reason}`);
|
|
1233
|
+
clipboard.clear();
|
|
1234
|
+
// the chip counts exactly the rows the picker would list (main computes both)
|
|
1235
|
+
check('site-list-count-matches-the-rows',
|
|
1236
|
+
siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.length === 3
|
|
1237
|
+
&& siteLoginRows('vercel', 'team-c', 'https://example.com/').rows.length === 0,
|
|
1238
|
+
`team-a=${siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.length}`);
|
|
1239
|
+
// Use from the picker is the SAME 0.9.0 path: fill-login(key, id) sets the default, then fills.
|
|
1240
|
+
// Main-side we can prove the default half; the fill half is the existing Use contract.
|
|
1241
|
+
const pickRow = siteA.rows.find(r => r.username === 'carol@example.com');
|
|
1242
|
+
useAsDefault('vercel', 'team-a', pickRow.id);
|
|
1243
|
+
check('use-from-picker-sets-the-account-default',
|
|
1244
|
+
siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.find(r => r.id === pickRow.id).isDefault === true
|
|
1245
|
+
&& siteLoginRows('vercel', 'team-b', 'https://vercel.com/login').rows.find(r => r.id === pickRow.id).isDefault === false,
|
|
1246
|
+
managerRows().find(r => r.id === pickRow.id).defaultForAccounts.join() || 'none');
|
|
1247
|
+
setOrClearDefault(null, 'vercel', 'team-a', 'https://vercel.com/login');
|
|
1114
1248
|
} finally { try { fs.rmSync(csv3, { force: true }); } catch {} }
|
|
1115
1249
|
|
|
1116
1250
|
const st = creds.stats();
|
|
@@ -1439,7 +1573,16 @@ function runInteractive(captureMode = false) {
|
|
|
1439
1573
|
// --capture-scroll <px> scroll the Home grid down first
|
|
1440
1574
|
// --capture-wait <ms> extra settle time
|
|
1441
1575
|
// --capture-js "<expr>" run an expression in the chrome page (e.g. open a modal)
|
|
1442
|
-
|
|
1576
|
+
// Both "--flag value" and "--flag=value" are accepted. The "=" form matters on this machine:
|
|
1577
|
+
// an account key contains "::" and the Git Bash/MSYS layer mangles that in a separate argv
|
|
1578
|
+
// token (the process exits 127 before it starts), while "--capture-open=vercel::work" passes
|
|
1579
|
+
// through intact. Same value, same code path.
|
|
1580
|
+
const argVal = name => {
|
|
1581
|
+
const viaEq = process.argv.find(a => a.startsWith(`${name}=`));
|
|
1582
|
+
if (viaEq) return viaEq.slice(name.length + 1);
|
|
1583
|
+
const i = process.argv.indexOf(name);
|
|
1584
|
+
return i > -1 ? process.argv[i + 1] : null;
|
|
1585
|
+
};
|
|
1443
1586
|
const outFile = argVal('--capture-out') || 'ui-screenshot.png';
|
|
1444
1587
|
const openKey = argVal('--capture-open');
|
|
1445
1588
|
const scrollPx = Number(argVal('--capture-scroll') || 0);
|
|
@@ -1472,6 +1615,47 @@ function runInteractive(captureMode = false) {
|
|
|
1472
1615
|
console.log('[capture] demo notice:', plan.kind, (plan.choices || []).length);
|
|
1473
1616
|
await new Promise(r => setTimeout(r, 700));
|
|
1474
1617
|
}
|
|
1618
|
+
// --demo-picker: open the address-strip key chip's picker from MAIN (state → emitState → the
|
|
1619
|
+
// renderer's own click handler), so a screenshot shows the real popover and not hand-written
|
|
1620
|
+
// DOM. The chip is drawn from state.siteLogins, so one emitState is enough. Verification aid
|
|
1621
|
+
// for a scratch demo profile, like --demo-notice (0.9.0); it changes no shipped behaviour.
|
|
1622
|
+
if (process.argv.includes('--demo-picker') && openKey) {
|
|
1623
|
+
const key = openKey;
|
|
1624
|
+
if (vm.tabs.has(key)) vm.activate(key); // the chip only exists for the ACTIVE tab
|
|
1625
|
+
await new Promise(r => setTimeout(r, 800));
|
|
1626
|
+
vm.emitState();
|
|
1627
|
+
await new Promise(r => setTimeout(r, 600));
|
|
1628
|
+
if (win.webContents && !win.webContents.isDestroyed()) {
|
|
1629
|
+
await win.webContents.executeJavaScript('document.getElementById("keysite").click()').catch(err => console.error('[capture] picker click failed:', err.message));
|
|
1630
|
+
}
|
|
1631
|
+
const info = await win.webContents.executeJavaScript(`JSON.stringify({
|
|
1632
|
+
chip: document.getElementById('keysite').hidden ? 'hidden' : document.getElementById('keysite').innerText.trim(),
|
|
1633
|
+
open: !document.getElementById('keypicker').hidden,
|
|
1634
|
+
rows: document.querySelectorAll('#keypicker [data-kp-id]').length,
|
|
1635
|
+
search: !!document.getElementById('kp-q'),
|
|
1636
|
+
rowsMeta: [...document.querySelectorAll('#keypicker .kp-meta')].map(m => m.innerText.trim()).slice(0, 4),
|
|
1637
|
+
})`).catch(err => 'probe failed: ' + err.message);
|
|
1638
|
+
console.log('[capture] demo picker:', info);
|
|
1639
|
+
// --demo-picker-copied: type in the search box and press Copy password through the real
|
|
1640
|
+
// renderer → preload → main chain, then read the clipboard back from MAIN (the secret must
|
|
1641
|
+
// never appear in the renderer). Photographs the searched + "Copied ✓" state.
|
|
1642
|
+
if (process.argv.includes('--demo-picker-copied') && win.webContents && !win.webContents.isDestroyed()) {
|
|
1643
|
+
const wired = await win.webContents.executeJavaScript(`(async () => {
|
|
1644
|
+
const q = document.getElementById('kp-q');
|
|
1645
|
+
q.value = 'demo.user.two'; q.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1646
|
+
const visible = [...document.querySelectorAll('#keypicker [data-kp-id]')].filter(r => !r.hidden).length;
|
|
1647
|
+
const btn = [...document.querySelectorAll('#keypicker [data-kp-copy][data-kp-field="password"]')]
|
|
1648
|
+
.find(b => !b.closest('[data-kp-id]').hidden);
|
|
1649
|
+
if (!btn) return JSON.stringify({ filterVisible: visible, btnFound: false });
|
|
1650
|
+
btn.click();
|
|
1651
|
+
await new Promise(r => setTimeout(r, 400));
|
|
1652
|
+
return JSON.stringify({ filterVisible: visible, btnFound: true, btnText: btn.innerText.trim() });
|
|
1653
|
+
})()`).catch(err => 'wired probe failed: ' + err.message);
|
|
1654
|
+
console.log('[capture] demo picker wired:', wired, 'clipboard-is-a-demo-password:', clipboard.readText().startsWith('demo-placeholder-'));
|
|
1655
|
+
clipboard.clear();
|
|
1656
|
+
}
|
|
1657
|
+
await new Promise(r => setTimeout(r, 250));
|
|
1658
|
+
}
|
|
1475
1659
|
if (extraWait) await new Promise(r => setTimeout(r, extraWait));
|
|
1476
1660
|
// UnknownVizError / empty frames clear once the compositor produces a frame — retry
|
|
1477
1661
|
let img = null; let lastErr = null;
|
|
@@ -1829,6 +2013,24 @@ function runInteractive(captureMode = false) {
|
|
|
1829
2013
|
logins: accountLoginSummaries(svc, id),
|
|
1830
2014
|
signInUrl: (serviceByKey(svc) || {}).loginUrl || null,
|
|
1831
2015
|
}));
|
|
2016
|
+
// Per-SITE picker (0.10.0): the logins that can sign you in on the ACTIVE tab's live host. The
|
|
2017
|
+
// renderer names the tab; main reads that tab's LIVE url, so the list can never describe a page the
|
|
2018
|
+
// user has navigated away from. Summaries only (no `password` key, ever).
|
|
2019
|
+
ipcMain.handle('creds-site-list', (_e, key) => {
|
|
2020
|
+
const t = key ? vm.tabs.get(String(key)) : null;
|
|
2021
|
+
if (!t) return { host: '', logins: [], count: 0, live: false };
|
|
2022
|
+
const liveUrl = t.view.webContents.isDestroyed() ? '' : t.view.webContents.getURL();
|
|
2023
|
+
const { host, rows } = siteLoginRows(t.svc, t.id, liveUrl);
|
|
2024
|
+
return { host, logins: rows, count: rows.length, live: true };
|
|
2025
|
+
});
|
|
2026
|
+
// Copy a saved email/password to the OS clipboard without ever showing it (main-process clipboard).
|
|
2027
|
+
// The account and the site gate come from the ACTIVE tab in main, not from the caller.
|
|
2028
|
+
ipcMain.handle('creds-copy', (_e, key, credentialId, field) => {
|
|
2029
|
+
const t = key ? vm.tabs.get(String(key)) : null;
|
|
2030
|
+
if (!t) return { ok: false, reason: 'asleep', message: 'That tab is asleep — open it first.' };
|
|
2031
|
+
const liveUrl = t.view.webContents.isDestroyed() ? '' : t.view.webContents.getURL();
|
|
2032
|
+
return copyCredentialField(String(credentialId || ''), String(field || ''), t.svc, t.id, liveUrl);
|
|
2033
|
+
});
|
|
1832
2034
|
// Credential manager: summaries only, never passwords — the renderer must not hold one.
|
|
1833
2035
|
// Imported passwords are classified by host, with the accounts each row can serve, so the manager
|
|
1834
2036
|
// filters and the ★ markers both come from the main process (the only authority on them).
|