my-provider-dash-manager 0.6.3 → 0.8.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 +125 -30
- package/docs/screenshots/home.png +0 -0
- package/docs/screenshots/navigation.png +0 -0
- package/docs/screenshots/services.png +0 -0
- package/package.json +30 -8
- package/scripts/cli.js +11 -6
- package/src/main.js +192 -22
package/README.md
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
# My Provider Dash Manager
|
|
1
|
+
# My Provider Dash Manager — multi-account manager for 37 cloud services
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
[](https://github.com/FiredMosquito831/my-provider-dash-manager/releases/latest)
|
|
4
|
+
[](https://www.npmjs.com/package/my-provider-dash-manager)
|
|
5
|
+
[](https://www.npmjs.com/package/my-provider-dash-manager)
|
|
6
|
+
[](https://github.com/FiredMosquito831/my-provider-dash-manager)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
**For freelancers, agencies and developers running client plus personal infrastructure:** one desktop
|
|
10
|
+
app for every account you own. Keep several Google, Microsoft, AWS, Vercel, Netlify, Cloudflare,
|
|
11
|
+
Supabase, GitHub, npm, Stripe, Gmail, Outlook and Proton Mail accounts (37 services built in, any
|
|
12
|
+
site by URL) signed in at the same time, each in a fully isolated session, and switch between them
|
|
13
|
+
instantly — the way a browser handles tabs, but built around *accounts* instead of pages.
|
|
7
14
|
|
|
8
15
|
<sub>Windows desktop app · Electron 43 · local-first · no telemetry, no account, no server ·
|
|
9
16
|
source-available licence: free for personal and commercial use</sub>
|
|
10
17
|
|
|
11
|
-

|
|
18
|
+

|
|
12
19
|
|
|
13
20
|
Further down Home: the **Add an account** picker with every built-in service you are not using yet
|
|
14
21
|
(37 in total, or add any site by URL), the **Updates** panel with the installed version, and the
|
|
15
22
|
saved-login controls. Install with `npx my-provider-dash-manager` or straight from
|
|
16
23
|
[Releases](https://github.com/FiredMosquito831/my-provider-dash-manager/releases) — see [Install](#install).
|
|
17
24
|
|
|
18
|
-

|
|
25
|
+

|
|
19
26
|
|
|
20
|
-

|
|
27
|
+

|
|
21
28
|
|
|
22
29
|
<sub>Screenshots use a demo profile with placeholder account names.</sub>
|
|
23
30
|
|
|
@@ -46,14 +53,14 @@ Start Menu and Desktop shortcuts. Your accounts, sessions and saved logins live
|
|
|
46
53
|
|
|
47
54
|
Three ways, all ending in the same place:
|
|
48
55
|
|
|
49
|
-
- **
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
- **Automatically, inside the app (default).** The app checks on startup and hourly. A newer release
|
|
57
|
+
downloads in the background and installs silently, with no wizard, the next time you close the app;
|
|
58
|
+
the rail badge bottom-left turns into **Restart to update x.y.z** if you would rather do it now.
|
|
59
|
+
Turn **Auto-update** off in the Updates panel on Home and every step waits for a button press
|
|
60
|
+
instead (Check, Download, Restart & update), with release notes and progress shown.
|
|
54
61
|
- **From the command line.** `npx my-provider-dash-manager` (or `mpdm update` if installed globally)
|
|
55
|
-
|
|
56
|
-
showing the old version until you restart it.
|
|
62
|
+
updates an existing install silently and in place; add `--interactive` if you want the wizard.
|
|
63
|
+
Close the app first, or the running copy keeps showing the old version until you restart it.
|
|
57
64
|
- **From the release page.** Run the newest installer; it upgrades in place.
|
|
58
65
|
|
|
59
66
|
The repository is public, so updates need no token or account.
|
|
@@ -72,7 +79,7 @@ and it is also available as `mpdm`:
|
|
|
72
79
|
| `… where` | Print the install and data paths |
|
|
73
80
|
| `… uninstall` | Run the uninstaller (accounts and sessions are kept) |
|
|
74
81
|
|
|
75
|
-
Flags: `--silent` (no wizard), `--download-only`, `--force`, `--version`, `--help`.
|
|
82
|
+
Flags: `--silent` (no wizard on a first install), `--interactive` (wizard for an update), `--download-only`, `--force`, `--version`, `--help`.
|
|
76
83
|
From a checkout of this repository the equivalent is `npm run install:latest`.
|
|
77
84
|
|
|
78
85
|
### Run from source
|
|
@@ -127,7 +134,9 @@ Keyboard: `Ctrl+W` sleep tab · `Ctrl+Shift+W` sleep all · `Ctrl+Tab` cycle ·
|
|
|
127
134
|
## Features
|
|
128
135
|
|
|
129
136
|
### Accounts and sessions
|
|
130
|
-
|
|
137
|
+
|
|
138
|
+
**Multi-account session isolation** — every account in its own Chromium partition:
|
|
139
|
+
- **Unlimited accounts per service.** Each account gets its own storage partition — separate
|
|
131
140
|
cookies, localStorage, IndexedDB and cache. Accounts can never see each other's session.
|
|
132
141
|
- **Sessions persist across restarts.** Sign in once; you stay signed in (verified by an automated
|
|
133
142
|
restart test that counts real cookies after relaunch).
|
|
@@ -170,10 +179,15 @@ Every login page is verified to load inside an isolated partition, landing on a
|
|
|
170
179
|
guard trusts (`npm run smoke:services`). Google sign-in was verified against Google's own flow and
|
|
171
180
|
works in the app. Anything else can be added by URL or as a plugin.
|
|
172
181
|
|
|
173
|
-
###
|
|
182
|
+
### Password manager with per-service import
|
|
174
183
|
- **Remembers logins you type** inside an account tab, bound to *that* account, so two accounts on the
|
|
175
184
|
same service refill with different credentials.
|
|
176
185
|
- **Auto-fills sign-in pages** when it recognises one (toggleable), plus a manual fill action.
|
|
186
|
+
- **Manage logins** from Home → Saved logins → *Manage logins…*: every stored login grouped by
|
|
187
|
+
service, with delete. No password is ever shown.
|
|
188
|
+
- **Choose which login fills:** when more than one matches a site the app asks instead of guessing,
|
|
189
|
+
and your choice is remembered as that account's default (marked ★ in the manager).
|
|
190
|
+
- **Re-importing updates changed passwords** instead of silently keeping the old one.
|
|
177
191
|
- **Imports existing passwords** from installed Chromium browsers (Chrome, Edge, Brave, Vivaldi,
|
|
178
192
|
Opera) or from a CSV exported by any browser, including Firefox.
|
|
179
193
|
- Encrypted at rest with Windows DPAPI. Passwords never reach the app's own UI process.
|
|
@@ -188,18 +202,26 @@ are upload-only), latest deploy state on Vercel, account name plus charges/payou
|
|
|
188
202
|
The token field says which credential each service expects. Tokens are validated before being stored,
|
|
189
203
|
encrypted with DPAPI, revocable in one click, and never leave the main process.
|
|
190
204
|
|
|
191
|
-
###
|
|
192
|
-
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
-
|
|
205
|
+
### Automatic silent updates
|
|
206
|
+
- **Silent in-place updates** by default: downloaded in the background, installed without a wizard
|
|
207
|
+
when the app closes or on **Restart & update**, relaunched on the new version.
|
|
208
|
+
- **Auto-update toggle** for people who want to press every button themselves.
|
|
209
|
+
- Version always visible in the rail; it becomes **Update x.y.z** / **Restart to update** as the
|
|
210
|
+
release moves through found → downloaded.
|
|
211
|
+
- Updates panel on Home: installed vs latest, release notes, progress. Startup and hourly checks
|
|
212
|
+
against the public release feed. The interactive installer is only for first installs.
|
|
196
213
|
|
|
197
|
-
###
|
|
214
|
+
### Built-in ad-blocking and dark mode
|
|
198
215
|
- **Ad-blocking** driven by EasyList (~52,000 blocked hosts plus generic cosmetic rules, refreshed
|
|
199
216
|
weekly), applied per account partition. Main-frame navigation and sign-in infrastructure are never
|
|
200
|
-
blocked.
|
|
201
|
-
|
|
202
|
-
|
|
217
|
+
blocked. uBlock Origin cannot run inside Electron — its service worker needs browser APIs Electron
|
|
218
|
+
does not expose — so this built-in blocker covers that need.
|
|
219
|
+
- **Dark mode** for every account tab, powered by Dark Reader's own engine: one toggle on Home
|
|
220
|
+
applies it everywhere. Flipping it is instant — pages do not reload, so your scroll position and
|
|
221
|
+
anything half-typed into a form is preserved. It runs Dark Reader inside each tab's isolated
|
|
222
|
+
session, costing roughly 2 MB per open account. Dark Reader's browser extension cannot run in
|
|
223
|
+
Electron (its background worker needs APIs Electron does not expose, and its content script
|
|
224
|
+
would touch the page's own JavaScript), so the engine is bundled and driven directly.
|
|
203
225
|
|
|
204
226
|
### Extensibility
|
|
205
227
|
- **Add any service by URL** — it gets its own rail row and isolated per-account sessions.
|
|
@@ -239,6 +261,78 @@ http/https only. Nothing syncs anywhere: there is no server, no account and no t
|
|
|
239
261
|
|
|
240
262
|
---
|
|
241
263
|
|
|
264
|
+
## Requirements
|
|
265
|
+
|
|
266
|
+
- **Windows 10 or 11** (64-bit). The app is Windows-only today; macOS and Linux are not supported yet.
|
|
267
|
+
- About **350 MB** of memory with all tabs asleep, plus roughly **125 MB per live dashboard** and
|
|
268
|
+
**2 MB per open account** when dark mode is on. A machine with 8 GB of RAM handles the default
|
|
269
|
+
five warm tabs comfortably.
|
|
270
|
+
- **Node.js 18+ only for the `npx` route** — the app itself is a standalone Windows executable and
|
|
271
|
+
needs nothing else installed.
|
|
272
|
+
|
|
273
|
+
## Frequently asked questions
|
|
274
|
+
|
|
275
|
+
**Can I be signed into two accounts on the same service at once?**
|
|
276
|
+
Yes. Each account gets its own Chromium storage partition, so both stay signed in simultaneously and
|
|
277
|
+
you switch between them like tabs. Nothing is ever signed out to reach the other.
|
|
278
|
+
|
|
279
|
+
**How much memory does an extra account use?**
|
|
280
|
+
Roughly 125 MB of private memory per live dashboard (the figure Task Manager reports), on a ~350 MB
|
|
281
|
+
baseline with everything asleep. The app keeps five tabs warm by default and sleeps the rest; slept
|
|
282
|
+
tabs stay in the strip and resume on click.
|
|
283
|
+
|
|
284
|
+
**Is it safe to store my passwords?**
|
|
285
|
+
Saved logins are encrypted with Windows DPAPI and bound to the account *and* the service's own
|
|
286
|
+
domains — a password typed on an unrelated page reached from a tab is never captured, and a saved
|
|
287
|
+
password is never filled into a page outside the service's domains. DPAPI protects against another
|
|
288
|
+
user on the machine, a stolen disk and casual copying; it does not protect against malware running as
|
|
289
|
+
you, which no desktop app can. Passwords never reach the app's own UI process.
|
|
290
|
+
|
|
291
|
+
**Does it work on macOS or Linux?**
|
|
292
|
+
Not yet — the app and its installer are Windows-only. The architecture is cross-platform, so other
|
|
293
|
+
operating systems stay open as future work.
|
|
294
|
+
|
|
295
|
+
**How do I import passwords from Chrome or Edge?**
|
|
296
|
+
Home → *Import from browser* reads installed Chromium browsers directly. Chrome 127 and newer lock
|
|
297
|
+
some entries with app-bound encryption that no other app can read; export those to CSV from the
|
|
298
|
+
browser and use *Import CSV* instead. Firefox always uses the CSV path. Re-importing updates
|
|
299
|
+
passwords you have changed.
|
|
300
|
+
|
|
301
|
+
**Why does SmartScreen warn me?**
|
|
302
|
+
The installer is not code-signed yet, so Windows shows a warning the first time. Choose **More info →
|
|
303
|
+
Run anyway**. Code signing is planned and removes the warning entirely.
|
|
304
|
+
|
|
305
|
+
**Can I add a service that is not on the list?**
|
|
306
|
+
Yes — *Add service by URL* gives any site its own rail row and isolated per-account sessions, and
|
|
307
|
+
JSON plugin manifests can define a service plus how to read its API for status cards.
|
|
308
|
+
|
|
309
|
+
**What happens to my accounts when I update?**
|
|
310
|
+
Nothing is lost. Accounts, sessions, saved logins and settings live in
|
|
311
|
+
`%APPDATA%\multi-acc-manager` and survive updates and reinstalls. Updates install silently in place
|
|
312
|
+
and relaunch the app.
|
|
313
|
+
|
|
314
|
+
## Compared to the alternatives
|
|
315
|
+
|
|
316
|
+
| Approach | What it gives you | What it does not |
|
|
317
|
+
|---|---|---|
|
|
318
|
+
| Separate browser profiles | Isolated sessions per profile | One window each, no single place to see every account, no per-account password management |
|
|
319
|
+
| Incognito / private windows | A throwaway session | Nothing persists — you sign in again every time, and juggling windows is manual |
|
|
320
|
+
| Other multi-account managers | Similar switching | Usually extension-based in a normal browser, so one crash or update can take every session with it |
|
|
321
|
+
| **This app** | One window, persistent isolated sessions, a password manager, ad-blocking, dark mode and silent updates | Windows-only, unsigned installer, and no Chrome Web Store extensions (Electron cannot run them) |
|
|
322
|
+
|
|
323
|
+
## Troubleshooting
|
|
324
|
+
|
|
325
|
+
- **SmartScreen warns on first run.** Expected while the installer is unsigned: **More info → Run
|
|
326
|
+
anyway**.
|
|
327
|
+
- **A service will not load inside the app** (Google Cloud Console is a known case). Use the
|
|
328
|
+
**Browser** button in the strip to open that page in your system browser — it keeps working from
|
|
329
|
+
there, and the rest of the app is unaffected.
|
|
330
|
+
- **"Which account am I in right now?"** Every tab and rail row is colour-coded and labelled by
|
|
331
|
+
account, and the tab strip shows the service and account name together. The active tab is
|
|
332
|
+
highlighted; slept tabs are dimmed.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
242
336
|
## Development
|
|
243
337
|
|
|
244
338
|
```bash
|
|
@@ -246,10 +340,11 @@ npm start # run the app
|
|
|
246
340
|
npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
|
|
247
341
|
npm run smoke:restore # sessions survive a restart (real cookies)
|
|
248
342
|
npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
|
|
249
|
-
npm run smoke:creds # import, per-account recall, encryption at rest (
|
|
343
|
+
npm run smoke:creds # import, per-account recall/selection, defaults, encryption at rest (16)
|
|
250
344
|
npm run smoke:autofill # real page: form detection, fill, capture, origin guard (10)
|
|
251
|
-
npm run smoke:updates # version comparison
|
|
345
|
+
npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
|
|
252
346
|
npm run smoke:services # every built-in login page loads inside an isolated partition (42)
|
|
347
|
+
npm run smoke:dark # Dark Reader darkens a white page, stays out of the page's own JS, re-injects after navigation (9)
|
|
253
348
|
npm run capture # screenshot the running shell (--capture-out, --capture-open svc::id, --capture-js)
|
|
254
349
|
npm run spike # memory benchmark
|
|
255
350
|
npm run dist # build the Windows installer locally
|
|
@@ -258,7 +353,7 @@ npm start -- --print-paths # show the app name, version and data fold
|
|
|
258
353
|
npm start -- --open vercel::personal # open an account on launch
|
|
259
354
|
```
|
|
260
355
|
|
|
261
|
-
The suite is
|
|
356
|
+
The suite is 115 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
|
|
262
357
|
profile.
|
|
263
358
|
|
|
264
359
|
Releases are cut by pushing a `v*` tag: GitHub Actions checks the tag against `package.json`, builds
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-provider-dash-manager",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.0",
|
|
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": {
|
|
7
7
|
"start": "electron .",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"internal:log": "git --git-dir=.internal-git --work-tree=. log --oneline",
|
|
18
18
|
"smoke:updates": "electron . --smoke-updates",
|
|
19
19
|
"smoke:services": "electron . --smoke-services",
|
|
20
|
+
"smoke:dark": "electron . --smoke-dark",
|
|
20
21
|
"install:latest": "node scripts/install-latest.js"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"productName": "My Provider Dash Manager",
|
|
32
33
|
"files": [
|
|
33
34
|
"src/**/*",
|
|
35
|
+
"vendor/**/*",
|
|
34
36
|
"package.json"
|
|
35
37
|
],
|
|
36
38
|
"win": {
|
|
@@ -57,6 +59,7 @@
|
|
|
57
59
|
"files": [
|
|
58
60
|
"scripts/cli.js",
|
|
59
61
|
"scripts/install-latest.js",
|
|
62
|
+
"docs/screenshots",
|
|
60
63
|
"README.md",
|
|
61
64
|
"LICENSE"
|
|
62
65
|
],
|
|
@@ -72,17 +75,36 @@
|
|
|
72
75
|
"keywords": [
|
|
73
76
|
"multi-account",
|
|
74
77
|
"account-manager",
|
|
78
|
+
"account-switcher",
|
|
79
|
+
"session-isolation",
|
|
80
|
+
"browser-profiles",
|
|
81
|
+
"isolated-sessions",
|
|
82
|
+
"desktop-app",
|
|
83
|
+
"electron",
|
|
84
|
+
"windows",
|
|
85
|
+
"developer-tools",
|
|
86
|
+
"dashboard",
|
|
87
|
+
"ad-block",
|
|
88
|
+
"adblocker",
|
|
89
|
+
"dark-mode",
|
|
90
|
+
"password-manager",
|
|
91
|
+
"password-importer",
|
|
75
92
|
"vercel",
|
|
76
93
|
"netlify",
|
|
77
94
|
"supabase",
|
|
78
95
|
"cloudflare",
|
|
79
|
-
"
|
|
80
|
-
"
|
|
96
|
+
"aws",
|
|
97
|
+
"azure",
|
|
98
|
+
"google",
|
|
99
|
+
"microsoft",
|
|
100
|
+
"google-cloud",
|
|
81
101
|
"github",
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
102
|
+
"gitlab",
|
|
103
|
+
"npm",
|
|
104
|
+
"stripe",
|
|
105
|
+
"gmail",
|
|
106
|
+
"proton-mail",
|
|
107
|
+
"cli"
|
|
86
108
|
],
|
|
87
109
|
"os": [
|
|
88
110
|
"win32"
|
package/scripts/cli.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// npx my-provider-dash-manager uninstall run the app's uninstaller
|
|
10
10
|
// npx my-provider-dash-manager where print the install and data paths
|
|
11
11
|
//
|
|
12
|
-
// Flags: --silent (no wizard) · --download-only · --version · --help
|
|
12
|
+
// Flags: --silent (no wizard) · --interactive (wizard even for an update) · --download-only · --version · --help
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
@@ -82,11 +82,15 @@ async function install({ launchAfter }) {
|
|
|
82
82
|
if (rel.notes) console.log(`\n${rel.notes.split('\n').slice(0, 10).join('\n')}\n`);
|
|
83
83
|
const dest = await downloadAsset(rel);
|
|
84
84
|
if (flags.has('--download-only')) { console.log(`\nSaved to ${dest}`); return; }
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// An update over an existing install runs silently (no wizard) unless --interactive is given;
|
|
86
|
+
// a first install shows the wizard unless --silent is given.
|
|
87
|
+
const silent = flags.has('--silent') || (!!have && !flags.has('--interactive'));
|
|
88
|
+
console.log(`\nRunning the installer${silent ? ' (silent, in place)' : ''}…`);
|
|
89
|
+
if (silent && have) console.log('Close the app if it is running, or the running copy keeps the old version until restarted.');
|
|
90
|
+
const child = spawn(dest, silent ? ['/S'] : [], { detached: true, stdio: 'ignore' });
|
|
87
91
|
child.unref();
|
|
88
|
-
console.log('The installer is unsigned, so SmartScreen may ask you to confirm.');
|
|
89
|
-
if (launchAfter &&
|
|
92
|
+
if (!silent) console.log('The installer is unsigned, so SmartScreen may ask you to confirm.');
|
|
93
|
+
if (launchAfter && silent) {
|
|
90
94
|
setTimeout(() => { try { launch(); } catch (e) { console.error(e.message); } }, 15000);
|
|
91
95
|
}
|
|
92
96
|
}
|
|
@@ -131,7 +135,8 @@ function help() {
|
|
|
131
135
|
npx my-provider-dash-manager where print install and data paths
|
|
132
136
|
npx my-provider-dash-manager uninstall run the uninstaller (keeps your data)
|
|
133
137
|
|
|
134
|
-
Flags: --silent --download-only --force --version --help
|
|
138
|
+
Flags: --silent --interactive --download-only --force --version --help
|
|
139
|
+
Updates over an existing install run silently; first installs show the wizard.
|
|
135
140
|
Windows only. https://github.com/FiredMosquito831/my-provider-dash-manager`);
|
|
136
141
|
}
|
|
137
142
|
|
package/src/main.js
CHANGED
|
@@ -5,6 +5,7 @@ const SERVICES = require('./services');
|
|
|
5
5
|
const tokens = require('./api-tokens');
|
|
6
6
|
const { PROVIDERS } = require('./providers');
|
|
7
7
|
const content = require('./content');
|
|
8
|
+
const dark = require('./darkmode');
|
|
8
9
|
const creds = require('./credentials');
|
|
9
10
|
const plugins = require('./plugins');
|
|
10
11
|
const updater = require('./updater');
|
|
@@ -35,9 +36,15 @@ function writeJson(file, data) {
|
|
|
35
36
|
fs.writeFileSync(file, JSON.stringify(data, null, 2));
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// ---------- dark mode: Dark Reader's API build, in each account view's isolated world ----------
|
|
40
|
+
// Deliberately NOT a browser extension. Dark Reader's MV3 extension cannot work here: its
|
|
41
|
+
// background service worker dies on the chrome.* APIs Electron 43 does not expose, and its
|
|
42
|
+
// content script declares world:"MAIN", which mutates page prototypes the account's own
|
|
43
|
+
// JavaScript can observe — a breach of invariant 2. See src/darkmode.js for the measurements.
|
|
44
|
+
|
|
38
45
|
const store = {
|
|
39
46
|
accounts: [], // {svc, id, label, colorIdx, proxy, createdAt, lastState}
|
|
40
|
-
settings: { warmLimit: 5, groupTabs: false, collapsed: [], stripCollapsed: [], adBlock: true, darkMode: true,
|
|
47
|
+
settings: { warmLimit: 5, groupTabs: false, collapsed: [], stripCollapsed: [], adBlock: true, darkMode: true, saveLogins: true, autofill: true },
|
|
41
48
|
openTabs: [], // session memory: [{svc, id, url, lastActivated, active}] — survives restarts
|
|
42
49
|
customServices: [], // Phase 2: user-added services {key,name,dashboardUrl,loginUrl,color,custom:true}
|
|
43
50
|
save() {
|
|
@@ -48,7 +55,12 @@ const store = {
|
|
|
48
55
|
saveSession() { writeJson(userDataPath('session.json'), this.openTabs); },
|
|
49
56
|
load() {
|
|
50
57
|
this.accounts = readJson(userDataPath('accounts.json'), []);
|
|
51
|
-
|
|
58
|
+
const saved = readJson(userDataPath('settings.json'), {});
|
|
59
|
+
// 0.8.0: the force-dark CSS invert is replaced by bundled Dark Reader. A profile that had it
|
|
60
|
+
// on keeps dark mode on, and the dead key is dropped rather than left in the file.
|
|
61
|
+
if (saved.forceDark) saved.darkMode = true;
|
|
62
|
+
delete saved.forceDark;
|
|
63
|
+
this.settings = Object.assign({ warmLimit: 5, groupTabs: false, collapsed: [], stripCollapsed: [], adBlock: true, darkMode: true, saveLogins: true, autofill: true, autoUpdate: true }, saved);
|
|
52
64
|
this.openTabs = readJson(userDataPath('session.json'), []);
|
|
53
65
|
this.customServices = readJson(userDataPath('services.json'), []);
|
|
54
66
|
},
|
|
@@ -258,6 +270,8 @@ class ViewManager {
|
|
|
258
270
|
this.trackSession(svc, id, tab.url); // tab joins the remembered open set
|
|
259
271
|
this.window.contentView.addChildView(view);
|
|
260
272
|
if (show) this.activate(key);
|
|
273
|
+
// Dark mode is enabled per document from did-navigate below (the isolated world cannot exist
|
|
274
|
+
// before its page does), so there is nothing to attach before the first load.
|
|
261
275
|
view.webContents.loadURL(tab.url).catch(err => console.error(`load failed ${key}:`, err.message));
|
|
262
276
|
view.webContents.on('page-title-updated', (_e, title) => { tab.title = title; this.emitState(); });
|
|
263
277
|
// Escape must reach Home even while the dashboard page has focus (the renderer's keydown
|
|
@@ -266,7 +280,13 @@ class ViewManager {
|
|
|
266
280
|
if (input.type === 'keyDown' && input.key === 'Escape' && !this.modalOpen) this.deactivate();
|
|
267
281
|
});
|
|
268
282
|
view.webContents.on('did-finish-load', () => {
|
|
269
|
-
content.applyToPage(view.webContents, { adBlock: !!store.settings.adBlock
|
|
283
|
+
content.applyToPage(view.webContents, { adBlock: !!store.settings.adBlock });
|
|
284
|
+
// Enable dark mode once the document's styles exist. did-navigate fires too early: the
|
|
285
|
+
// dynamic engine finds no stylesheets to transform yet, so enabling there leaves the page
|
|
286
|
+
// white. This fires again after every cross-origin navigation, which is exactly what the
|
|
287
|
+
// isolated-world reset requires — the world (and Dark Reader in it) is gone by then and must
|
|
288
|
+
// be re-injected. Same-document (pushState) routing never reaches here, so it never re-injects.
|
|
289
|
+
if (store.settings.darkMode) dark.enable(view.webContents);
|
|
270
290
|
});
|
|
271
291
|
const onNav = (_e, url2) => {
|
|
272
292
|
tab.url = url2;
|
|
@@ -280,6 +300,10 @@ class ViewManager {
|
|
|
280
300
|
if (a) { a.lastState = tab.lastState; store.save(); }
|
|
281
301
|
this.emitState();
|
|
282
302
|
};
|
|
303
|
+
// Dark mode is (re-)enabled from did-finish-load above, not here: that handler runs after the
|
|
304
|
+
// document's styles exist and after every cross-origin navigation, which is exactly when the
|
|
305
|
+
// isolated-world engine needs (re-)injecting. did-navigate-in-page (pushState) keeps the world,
|
|
306
|
+
// so it deliberately does not re-inject.
|
|
283
307
|
view.webContents.on('did-navigate', onNav);
|
|
284
308
|
view.webContents.on('did-navigate-in-page', onNav); // dashboards route client-side (pushState) — must track too
|
|
285
309
|
view.webContents.on('did-start-loading', () => this.emitState());
|
|
@@ -366,6 +390,9 @@ class ViewManager {
|
|
|
366
390
|
try { this.window.contentView.removeChildView(tab.view); } catch {}
|
|
367
391
|
try { tab.view.webContents.close(); } catch {} // separate: a dead contentView must not skip closing the renderer
|
|
368
392
|
this.tabs.delete(key);
|
|
393
|
+
// Nothing to undo for dark mode here: closing the renderer above discards its isolated world
|
|
394
|
+
// and the Dark Reader inside it. darkmode.js tracks state in a WeakMap, so the dead view is
|
|
395
|
+
// collected with it — no per-tab teardown, and reopening re-enables on its first navigation.
|
|
369
396
|
// NOTE: the session entry is intentionally kept — a slept tab stays in the strip and can be resumed.
|
|
370
397
|
if (this.activeKey === key) {
|
|
371
398
|
this.activeKey = null;
|
|
@@ -389,6 +416,7 @@ class ViewManager {
|
|
|
389
416
|
this.tabs.delete(key);
|
|
390
417
|
}
|
|
391
418
|
this.untrackSession(svc, id); // also drop it from the remembered tab set
|
|
419
|
+
try { creds.removeForAccount(svc, id); } catch {} // a deleted account's captured login must not linger for a future account with the same id
|
|
392
420
|
store.accounts = store.accounts.filter(a => !(a.svc === svc && a.id === id));
|
|
393
421
|
store.save();
|
|
394
422
|
try {
|
|
@@ -719,6 +747,35 @@ async function runSmokeCreds() {
|
|
|
719
747
|
const fb = creds.forAccount('vercel', 'never-signed-in', 'https://vercel.com/login');
|
|
720
748
|
check('host-fallback-from-import', !!fb && fb.username === 'alice@example.com', fb ? fb.username : 'none');
|
|
721
749
|
|
|
750
|
+
// a second username captured for the same account is a SECOND candidate — no silent first-wins
|
|
751
|
+
creds.rememberForAccount('vercel', 'acc9', { url: 'https://vercel.com/login', username: 'second@example.com', password: 'second-pw' });
|
|
752
|
+
const cands = creds.candidatesForAccount('vercel', 'acc9', 'https://vercel.com/login');
|
|
753
|
+
check('candidates-include-both-usernames', cands.length === 3 && cands.filter(c => c.accountKey === 'vercel::acc9').length === 2 && cands.every(c => !!c.id), `${cands.length} candidates (2 own + 1 imported)`);
|
|
754
|
+
check('ambiguous-own-never-guesses', creds.credentialForAccount('vercel', 'acc9', 'https://vercel.com/login') === null);
|
|
755
|
+
const pick = cands.find(c => c.username === 'second@example.com');
|
|
756
|
+
creds.setDefaultForAccount(pick.id, 'vercel', 'acc9', 'https://vercel.com/login');
|
|
757
|
+
check('chosen-default-wins', (creds.credentialForAccount('vercel', 'acc9', 'https://vercel.com/login') || {}).username === 'second@example.com');
|
|
758
|
+
// the chooser projection must carry no password — invariant 3
|
|
759
|
+
const sums = creds.candidateSummariesForAccount('vercel', 'acc9', 'https://vercel.com/login');
|
|
760
|
+
check('chooser-summaries-have-no-passwords', sums.length === 3 && sums.every(c => !('password' in c) && !!c.id && c.isDefault === (c.username === 'second@example.com')), `${sums.length} summaries`);
|
|
761
|
+
check('delete-credential', creds.deleteCredential(pick.id) === true && !creds.listCredentials().some(c => c.id === pick.id) && (creds.credentialForAccount('vercel', 'acc9', 'https://vercel.com/login') || {}).username === 'typed@example.com');
|
|
762
|
+
check('manager-list-has-no-passwords', creds.listCredentials().every(c => !('password' in c) && !!c.id));
|
|
763
|
+
|
|
764
|
+
// two imported logins on one host are ambiguous: autofill asks instead of guessing
|
|
765
|
+
const csv2 = path.join(app.getPath('temp'), `mam-creds2-${process.pid}.csv`);
|
|
766
|
+
fs.writeFileSync(csv2, 'url,username,password\nhttps://vercel.com/login,carol@example.com,carol-pw\n');
|
|
767
|
+
try {
|
|
768
|
+
const r2 = creds.importFromCsv(csv2);
|
|
769
|
+
check('second-import-added', r2.added === 1, JSON.stringify(r2));
|
|
770
|
+
check('ambiguous-import-never-guesses', creds.credentialForAccount('vercel', 'third-account', 'https://vercel.com/login') === null);
|
|
771
|
+
} finally { try { fs.rmSync(csv2, { force: true }); } catch {} }
|
|
772
|
+
|
|
773
|
+
// deleting an account drops its captured logins AND its defaults
|
|
774
|
+
creds.rememberForAccount('vercel', 'acc9', { url: 'https://vercel.com/login', username: 'third@example.com', password: 'third-pw' });
|
|
775
|
+
creds.setDefaultForAccount(creds.candidatesForAccount('vercel', 'acc9', 'https://vercel.com/login').find(c => c.username === 'third@example.com').id, 'vercel', 'acc9', 'https://vercel.com/login');
|
|
776
|
+
const removed = creds.removeForAccount('vercel', 'acc9');
|
|
777
|
+
check('account-delete-drops-captures-and-defaults', removed === 2 && !creds.listCredentials().some(c => (c.accountKey === 'vercel::acc9') || (c.defaultForAccounts || []).includes('vercel::acc9')), `removed=${removed}`);
|
|
778
|
+
|
|
722
779
|
const raw = fs.readFileSync(path.join(app.getPath('userData'), 'credentials.json'), 'utf8');
|
|
723
780
|
check('encrypted-at-rest', !raw.includes('hunter2') && !raw.includes('typed-pw'));
|
|
724
781
|
|
|
@@ -816,6 +873,9 @@ async function runSmokeUpdates() {
|
|
|
816
873
|
const init = updater.init(() => {});
|
|
817
874
|
check('reports-current-version', init.currentVersion === app.getVersion(), init.currentVersion);
|
|
818
875
|
check('knows-it-cannot-install-in-dev', init.canDownload === false && init.packaged === false);
|
|
876
|
+
check('update-installs-silently-and-relaunches', updater.INSTALL_MODE.isSilent === true && updater.INSTALL_MODE.isForceRunAfter === true);
|
|
877
|
+
check('auto-update-defaults-on', store.settings.autoUpdate === true && init.autoUpdate === true);
|
|
878
|
+
check('auto-update-toggle', updater.setAutoUpdate(false) === false && updater.snapshot().autoUpdate === false && updater.setAutoUpdate(true) === true);
|
|
819
879
|
|
|
820
880
|
const s = await updater.check();
|
|
821
881
|
check('check-completes-with-a-verdict', ['available', 'up-to-date', 'error'].includes(s.status), `status=${s.status}`);
|
|
@@ -827,6 +887,79 @@ async function runSmokeUpdates() {
|
|
|
827
887
|
app.exit(results.checks.every(c => c.ok) ? 0 : 1);
|
|
828
888
|
}
|
|
829
889
|
|
|
890
|
+
// ---------- smoke dark: Dark Reader's API build really darkens, and only inside the isolated world ----------
|
|
891
|
+
// The old check asserted a Dark Reader extension was LOADED, which was vacuous: its MV3 worker
|
|
892
|
+
// crashed on Electron 43 and only the OS-preference fallback CSS ever ran. This one asserts the
|
|
893
|
+
// engine actually changes computed colours AND that the page's own main world is left untouched
|
|
894
|
+
// (invariant 2), which is the whole reason the engine is not shipped as a browser extension.
|
|
895
|
+
async function runSmokeDark() {
|
|
896
|
+
const results = { mode: 'dark', checks: [] };
|
|
897
|
+
const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:dark] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
|
|
898
|
+
const win = new BaseWindow({ width: 900, height: 700, show: false });
|
|
899
|
+
const vm = new ViewManager();
|
|
900
|
+
vm.attach(win);
|
|
901
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
902
|
+
|
|
903
|
+
// The vendored bundle must be the exact self-contained build the app ships.
|
|
904
|
+
const vroot = path.join(__dirname, '..', 'vendor', 'darkreader');
|
|
905
|
+
let bytes = 0; let version = '';
|
|
906
|
+
try { bytes = fs.statSync(path.join(vroot, 'darkreader.js')).size; } catch {}
|
|
907
|
+
try { version = JSON.parse(fs.readFileSync(path.join(vroot, 'package.json'), 'utf8')).version || ''; } catch {}
|
|
908
|
+
check('vendored-bundle-present', bytes > 300000, `${bytes} bytes`);
|
|
909
|
+
check('vendored-version', version === '4.9.133', version || 'unreadable');
|
|
910
|
+
check('vendored-license-mit', (() => { try { return /MIT License/.test(fs.readFileSync(path.join(vroot, 'LICENSE'), 'utf8')); } catch { return false; } })());
|
|
911
|
+
|
|
912
|
+
// A local page whose background is explicitly WHITE. Its baseline colour is known, so "it got
|
|
913
|
+
// dark" can never be vacuous (a real dashboard that already honours prefers-color-scheme would).
|
|
914
|
+
// Served over file://, so this check needs no network.
|
|
915
|
+
const html = '<!doctype html><html><head><style>html,body{background:#ffffff;color:#111111}</style></head><body><h1>probe</h1></body></html>';
|
|
916
|
+
const p1 = path.join(app.getPath('temp'), 'mam-dark-white.html');
|
|
917
|
+
const p2 = path.join(app.getPath('temp'), 'mam-dark-white2.html');
|
|
918
|
+
fs.writeFileSync(p1, html); fs.writeFileSync(p2, html);
|
|
919
|
+
const toUrl = p => require('url').pathToFileURL(p).href;
|
|
920
|
+
|
|
921
|
+
store.settings.darkMode = true; // the create path injects only when this is on
|
|
922
|
+
vm.create('vercel', 'darkprobe', toUrl(p1), { show: false });
|
|
923
|
+
const probe = vm.tabs.get(accountKey('vercel', 'darkprobe'));
|
|
924
|
+
const wc = probe.view.webContents;
|
|
925
|
+
const bgOf = async () => {
|
|
926
|
+
try { return await wc.executeJavaScript(`getComputedStyle(document.documentElement).backgroundColor`); } catch { return ''; }
|
|
927
|
+
};
|
|
928
|
+
const inWorld = async code => {
|
|
929
|
+
try { return await wc.executeJavaScriptInIsolatedWorld(dark.DARK_WORLD_ID, [{ code }]); } catch { return undefined; }
|
|
930
|
+
};
|
|
931
|
+
await new Promise(r => wc.once('did-finish-load', r));
|
|
932
|
+
await sleep(3000); // let the engine compute and apply
|
|
933
|
+
|
|
934
|
+
const bgOn = await bgOf();
|
|
935
|
+
check('page-darkens-with-dark-reader', !!bgOn && bgOn !== '' && !/255,\s*255,\s*255/.test(bgOn), bgOn);
|
|
936
|
+
|
|
937
|
+
// Invariant 2: the page's own main world must be untouched while darkening is active.
|
|
938
|
+
const mainGlobal = await wc.executeJavaScript(`typeof window.DarkReader`).catch(() => '');
|
|
939
|
+
check('main-world-has-no-darkreader-global', mainGlobal === 'undefined', `typeof window.DarkReader = ${mainGlobal}`);
|
|
940
|
+
const sheetsGetter = await wc.executeJavaScript(`String(Object.getOwnPropertyDescriptor(Document.prototype,'styleSheets').get)`).catch(() => '');
|
|
941
|
+
check('main-world-stylesheets-getter-native', /native code/.test(sheetsGetter), sheetsGetter.slice(0, 40));
|
|
942
|
+
// The engine itself lives in the isolated world — exactly where it is supposed to be.
|
|
943
|
+
const worldType = await inWorld('typeof window.DarkReader');
|
|
944
|
+
check('engine-runs-in-isolated-world', worldType === 'object', `isolated world typeof = ${worldType}`);
|
|
945
|
+
|
|
946
|
+
// Toggling OFF restores the page's own colours, in place (no reload).
|
|
947
|
+
await dark.disable(wc);
|
|
948
|
+
await sleep(1500);
|
|
949
|
+
const bgOff = await bgOf();
|
|
950
|
+
check('disable-restores-native-colours', /255,\s*255,\s*255/.test(bgOff), `off=${bgOff}`);
|
|
951
|
+
|
|
952
|
+
// A cross-origin navigation discards the isolated world and the Dark Reader inside it; the
|
|
953
|
+
// did-navigate hook must re-inject and re-darken. A second local file is a different origin.
|
|
954
|
+
await new Promise(r => { wc.once('did-finish-load', r); wc.loadURL(toUrl(p2)).catch(() => {}); });
|
|
955
|
+
await sleep(3000);
|
|
956
|
+
const bgNav = await bgOf();
|
|
957
|
+
check('re-darkens-after-cross-origin-navigation', !!bgNav && !/255,\s*255,\s*255/.test(bgNav), bgNav);
|
|
958
|
+
|
|
959
|
+
fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
|
|
960
|
+
app.exit(results.checks.every(c => c.ok) ? 0 : 1);
|
|
961
|
+
}
|
|
962
|
+
|
|
830
963
|
// ---------- smoke restore: app restart, verify partitions persisted ----------
|
|
831
964
|
async function runSmokeRestore() {
|
|
832
965
|
const win = new BaseWindow({ width: 1280, height: 800, show: false });
|
|
@@ -872,12 +1005,11 @@ function runInteractive(captureMode = false) {
|
|
|
872
1005
|
const vm = new ViewManager();
|
|
873
1006
|
vm.attach(win);
|
|
874
1007
|
reloadPlugins(); // Phase 3: service manifests from <userData>/plugins
|
|
875
|
-
content.setNativeDark(!!store.settings.darkMode); // sites with their own dark theme follow it
|
|
876
1008
|
content.loadFilters().then(src => console.log(`[adblock] filters from ${src}: ${content.filters.hosts.size} hosts, ${content.filters.cosmetic.length} cosmetic`));
|
|
877
1009
|
win.loadFile(path.join(__dirname, 'ui.html'));
|
|
878
1010
|
// Update system: version tracking + release info + explicit user-driven download/install.
|
|
879
1011
|
// Nothing downloads or installs on its own; the UI shows what is available and you decide.
|
|
880
|
-
updater.init(s => { if (win && win.webContents && !win.webContents.isDestroyed()) win.webContents.send('update-state', s); });
|
|
1012
|
+
updater.init(s => { if (win && win.webContents && !win.webContents.isDestroyed()) win.webContents.send('update-state', s); }, { autoUpdate: store.settings.autoUpdate !== false });
|
|
881
1013
|
updater.check().catch(() => {}); // one check at startup
|
|
882
1014
|
setInterval(() => updater.check().catch(() => {}), 60 * 60 * 1000); // and hourly after that
|
|
883
1015
|
const applyBounds = () => {
|
|
@@ -971,7 +1103,7 @@ function runInteractive(captureMode = false) {
|
|
|
971
1103
|
});
|
|
972
1104
|
}
|
|
973
1105
|
win.on('resize', applyBounds);
|
|
974
|
-
win.on('close', () => { vm.hibernateAll(); }); // 'close' fires before destruction — contentView is still usable here
|
|
1106
|
+
win.on('close', () => { vm.hibernateAll(); vm.flushSession(); }); // 'close' fires before destruction — contentView is still usable here
|
|
975
1107
|
|
|
976
1108
|
// keyboard accelerators work even when a webview has focus
|
|
977
1109
|
const menu = Menu.buildFromTemplate([
|
|
@@ -1091,6 +1223,14 @@ function runInteractive(captureMode = false) {
|
|
|
1091
1223
|
if (!store.settings.saveLogins) return;
|
|
1092
1224
|
const found = tabForSender(e.sender);
|
|
1093
1225
|
if (!found || !data || !data.password) return;
|
|
1226
|
+
// A username-less capture (an autofilled form, a stray Enter) must never overwrite a real login
|
|
1227
|
+
// for THIS account. Only this account's own captures count — an imported fallback row belongs to
|
|
1228
|
+
// no account and must not block the user recording their own login.
|
|
1229
|
+
if (!data.username) {
|
|
1230
|
+
const own = creds.candidatesForAccount(found.tab.svc, found.tab.id, data.url)
|
|
1231
|
+
.some(c => c.accountKey === found.key && c.username);
|
|
1232
|
+
if (own) return;
|
|
1233
|
+
}
|
|
1094
1234
|
// Origin guard: only a login typed on the service's OWN domain may be bound to this account.
|
|
1095
1235
|
// Without this, a password typed on an OAuth/phishing page reached from the tab would overwrite
|
|
1096
1236
|
// the account's saved login and later be autofilled into the real service.
|
|
@@ -1124,7 +1264,7 @@ function runInteractive(captureMode = false) {
|
|
|
1124
1264
|
});
|
|
1125
1265
|
ipcMain.on('fill-result', () => {});
|
|
1126
1266
|
|
|
1127
|
-
ipcMain.handle('fill-login', (_e, key) => {
|
|
1267
|
+
ipcMain.handle('fill-login', (_e, key, credentialId = null) => {
|
|
1128
1268
|
const t = vm.tabs.get(key);
|
|
1129
1269
|
if (!t) throw new Error('That tab is asleep — open it first');
|
|
1130
1270
|
const [svc, id] = key.split('::');
|
|
@@ -1132,10 +1272,19 @@ function runInteractive(captureMode = false) {
|
|
|
1132
1272
|
if (!originAllowed(svc, pageUrl)) {
|
|
1133
1273
|
throw new Error(`This tab is on ${creds.hostOf(pageUrl) || 'another site'}, not ${serviceHosts(svc)[0] || 'the service'} — refusing to fill a saved password here`);
|
|
1134
1274
|
}
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
+
// The user may have navigated since the chooser was rendered. Recompute from the LIVE URL;
|
|
1279
|
+
// if the picked id no longer belongs here, return fresh summaries instead of persisting a stale default.
|
|
1280
|
+
if (credentialId && !candidates.some(c => c.id === credentialId)) {
|
|
1281
|
+
return { choose: creds.candidateSummariesForAccount(svc, id, pageUrl) };
|
|
1282
|
+
}
|
|
1283
|
+
const cred = creds.credentialForAccount(svc, id, pageUrl, credentialId || null);
|
|
1284
|
+
if (!cred) return { choose: creds.candidateSummariesForAccount(svc, id, pageUrl) };
|
|
1285
|
+
if (credentialId) creds.setDefaultForAccount(credentialId, svc, id, pageUrl); // remember only a live-host candidate
|
|
1137
1286
|
t.view.webContents.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
|
|
1138
|
-
return
|
|
1287
|
+
return { filled: cred.username };
|
|
1139
1288
|
});
|
|
1140
1289
|
ipcMain.handle('creds-stats', () => creds.stats());
|
|
1141
1290
|
ipcMain.handle('creds-browsers', () => creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData })));
|
|
@@ -1153,9 +1302,21 @@ function runInteractive(captureMode = false) {
|
|
|
1153
1302
|
ipcMain.handle('creds-clear', () => creds.clearAll());
|
|
1154
1303
|
ipcMain.handle('creds-for-account', (_e, svc, id) => {
|
|
1155
1304
|
const svcCfg = serviceByKey(svc);
|
|
1156
|
-
const c = creds.
|
|
1305
|
+
const c = creds.credentialForAccount(svc, id, svcCfg && svcCfg.loginUrl);
|
|
1157
1306
|
return c ? { username: c.username, host: c.host, source: c.source } : null; // never the password
|
|
1158
1307
|
});
|
|
1308
|
+
// Credential manager: summaries only, never passwords — the renderer must not hold one.
|
|
1309
|
+
// Imported passwords are classified by host so the manager can filter/group them per service.
|
|
1310
|
+
ipcMain.handle('creds-list', () => creds.listCredentials().map(c => {
|
|
1311
|
+
const accountSvc = c.accountKey ? c.accountKey.split('::')[0] : null;
|
|
1312
|
+
const serviceKeys = accountSvc ? [accountSvc] : allServices()
|
|
1313
|
+
.filter(s => serviceHosts(s.key).some(h => creds.hostRelated(c.host, h)))
|
|
1314
|
+
.map(s => s.key);
|
|
1315
|
+
return { ...c, serviceKeys };
|
|
1316
|
+
}));
|
|
1317
|
+
ipcMain.handle('creds-default', (_e, credentialId, svc, id, hostHint) =>
|
|
1318
|
+
creds.setDefaultForAccount(credentialId, svc, id, hostHint));
|
|
1319
|
+
ipcMain.handle('creds-delete', (_e, credentialId) => creds.deleteCredential(credentialId));
|
|
1159
1320
|
// ---------- Phase 3: service plugins (JSON manifests) ----------
|
|
1160
1321
|
ipcMain.handle('plugins-list', () => ({
|
|
1161
1322
|
dir: plugins.pluginsDir(),
|
|
@@ -1225,18 +1386,26 @@ function runInteractive(captureMode = false) {
|
|
|
1225
1386
|
return true;
|
|
1226
1387
|
});
|
|
1227
1388
|
ipcMain.handle('set-content-setting', (_e, name, value) => {
|
|
1228
|
-
if (!['adBlock', 'darkMode', '
|
|
1389
|
+
if (!['adBlock', 'darkMode', 'saveLogins', 'autofill', 'autoUpdate'].includes(name)) throw new Error(`unknown setting: ${name}`);
|
|
1229
1390
|
store.settings[name] = !!value;
|
|
1230
|
-
|
|
1231
|
-
// are mutually exclusive.
|
|
1232
|
-
if (name === 'forceDark' && value) store.settings.darkMode = false;
|
|
1233
|
-
if (name === 'darkMode' && value) store.settings.forceDark = false;
|
|
1391
|
+
if (name === 'autoUpdate') { store.save(); updater.setAutoUpdate(!!value); vm.emitState(); return !!value; }
|
|
1234
1392
|
store.save();
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1393
|
+
if (name === 'darkMode') {
|
|
1394
|
+
// The engine runs in each view's isolated world, so a flip is one enable/disable call per
|
|
1395
|
+
// live tab — no reload. The previous extension path forced a reload of every open tab, which
|
|
1396
|
+
// threw away scroll position and anything typed into a half-filled form.
|
|
1397
|
+
const on = !!value;
|
|
1398
|
+
for (const t of vm.tabs.values()) {
|
|
1399
|
+
if (on) dark.enable(t.view.webContents);
|
|
1400
|
+
else dark.disable(t.view.webContents);
|
|
1401
|
+
}
|
|
1402
|
+
} else if (name === 'adBlock') {
|
|
1403
|
+
// the network layer flips live through its closure; cosmetic CSS needs a re-apply on, and a
|
|
1404
|
+
// reload off (injected CSS can only be undone by reloading)
|
|
1405
|
+
for (const t of vm.tabs.values()) {
|
|
1406
|
+
if (value) content.applyToPage(t.view.webContents, { adBlock: true });
|
|
1407
|
+
else t.view.webContents.reload();
|
|
1408
|
+
}
|
|
1240
1409
|
}
|
|
1241
1410
|
vm.emitState();
|
|
1242
1411
|
return store.settings[name];
|
|
@@ -1338,7 +1507,7 @@ function cycleTab(vm, dir) {
|
|
|
1338
1507
|
}
|
|
1339
1508
|
|
|
1340
1509
|
// test modes default to an isolated userData: they can never pollute the real registry/partitions
|
|
1341
|
-
const TEST_FLAGS = ['--spike', '--smoke', '--smoke-restore', '--smoke-tokens', '--smoke-creds', '--smoke-autofill', '--smoke-updates', '--smoke-services'];
|
|
1510
|
+
const TEST_FLAGS = ['--spike', '--smoke', '--smoke-restore', '--smoke-tokens', '--smoke-creds', '--smoke-autofill', '--smoke-updates', '--smoke-services', '--smoke-dark'];
|
|
1342
1511
|
const testFlag = TEST_FLAGS.find(f => process.argv.includes(f));
|
|
1343
1512
|
if (testFlag && !process.env.MAM_USER_DATA) {
|
|
1344
1513
|
const dirName = testFlag === '--smoke-restore' ? 'mam-test-smoke' : `mam-test-${testFlag.slice(2)}`;
|
|
@@ -1373,6 +1542,7 @@ if (!gotLock) {
|
|
|
1373
1542
|
else if (process.argv.includes('--smoke-autofill')) runSmokeAutofill().catch(err => { console.error(err); app.exit(1); });
|
|
1374
1543
|
else if (process.argv.includes('--smoke-updates')) runSmokeUpdates().catch(err => { console.error(err); app.exit(1); });
|
|
1375
1544
|
else if (process.argv.includes('--smoke-services')) runSmokeServices().catch(err => { console.error(err); app.exit(1); });
|
|
1545
|
+
else if (process.argv.includes('--smoke-dark')) runSmokeDark().catch(err => { console.error(err); app.exit(1); });
|
|
1376
1546
|
else if (process.argv.includes('--capture')) runInteractive(true);
|
|
1377
1547
|
else runInteractive();
|
|
1378
1548
|
});
|