my-provider-dash-manager 0.7.0 → 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 +109 -17
- 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/src/main.js +187 -21
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
|
|
|
@@ -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,7 +202,7 @@ 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
|
-
###
|
|
205
|
+
### Automatic silent updates
|
|
192
206
|
- **Silent in-place updates** by default: downloaded in the background, installed without a wizard
|
|
193
207
|
when the app closes or on **Restart & update**, relaunched on the new version.
|
|
194
208
|
- **Auto-update toggle** for people who want to press every button themselves.
|
|
@@ -197,12 +211,17 @@ encrypted with DPAPI, revocable in one click, and never leave the main process.
|
|
|
197
211
|
- Updates panel on Home: installed vs latest, release notes, progress. Startup and hourly checks
|
|
198
212
|
against the public release feed. The interactive installer is only for first installs.
|
|
199
213
|
|
|
200
|
-
###
|
|
214
|
+
### Built-in ad-blocking and dark mode
|
|
201
215
|
- **Ad-blocking** driven by EasyList (~52,000 blocked hosts plus generic cosmetic rules, refreshed
|
|
202
216
|
weekly), applied per account partition. Main-frame navigation and sign-in infrastructure are never
|
|
203
|
-
blocked.
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
206
225
|
|
|
207
226
|
### Extensibility
|
|
208
227
|
- **Add any service by URL** — it gets its own rail row and isolated per-account sessions.
|
|
@@ -242,6 +261,78 @@ http/https only. Nothing syncs anywhere: there is no server, no account and no t
|
|
|
242
261
|
|
|
243
262
|
---
|
|
244
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
|
+
|
|
245
336
|
## Development
|
|
246
337
|
|
|
247
338
|
```bash
|
|
@@ -249,10 +340,11 @@ npm start # run the app
|
|
|
249
340
|
npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
|
|
250
341
|
npm run smoke:restore # sessions survive a restart (real cookies)
|
|
251
342
|
npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
|
|
252
|
-
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)
|
|
253
344
|
npm run smoke:autofill # real page: form detection, fill, capture, origin guard (10)
|
|
254
345
|
npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
|
|
255
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)
|
|
256
348
|
npm run capture # screenshot the running shell (--capture-out, --capture-open svc::id, --capture-js)
|
|
257
349
|
npm run spike # memory benchmark
|
|
258
350
|
npm run dist # build the Windows installer locally
|
|
@@ -261,7 +353,7 @@ npm start -- --print-paths # show the app name, version and data fold
|
|
|
261
353
|
npm start -- --open vercel::personal # open an account on launch
|
|
262
354
|
```
|
|
263
355
|
|
|
264
|
-
The suite is
|
|
356
|
+
The suite is 115 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
|
|
265
357
|
profile.
|
|
266
358
|
|
|
267
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/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
|
|
|
@@ -830,6 +887,79 @@ async function runSmokeUpdates() {
|
|
|
830
887
|
app.exit(results.checks.every(c => c.ok) ? 0 : 1);
|
|
831
888
|
}
|
|
832
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
|
+
|
|
833
963
|
// ---------- smoke restore: app restart, verify partitions persisted ----------
|
|
834
964
|
async function runSmokeRestore() {
|
|
835
965
|
const win = new BaseWindow({ width: 1280, height: 800, show: false });
|
|
@@ -875,7 +1005,6 @@ function runInteractive(captureMode = false) {
|
|
|
875
1005
|
const vm = new ViewManager();
|
|
876
1006
|
vm.attach(win);
|
|
877
1007
|
reloadPlugins(); // Phase 3: service manifests from <userData>/plugins
|
|
878
|
-
content.setNativeDark(!!store.settings.darkMode); // sites with their own dark theme follow it
|
|
879
1008
|
content.loadFilters().then(src => console.log(`[adblock] filters from ${src}: ${content.filters.hosts.size} hosts, ${content.filters.cosmetic.length} cosmetic`));
|
|
880
1009
|
win.loadFile(path.join(__dirname, 'ui.html'));
|
|
881
1010
|
// Update system: version tracking + release info + explicit user-driven download/install.
|
|
@@ -974,7 +1103,7 @@ function runInteractive(captureMode = false) {
|
|
|
974
1103
|
});
|
|
975
1104
|
}
|
|
976
1105
|
win.on('resize', applyBounds);
|
|
977
|
-
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
|
|
978
1107
|
|
|
979
1108
|
// keyboard accelerators work even when a webview has focus
|
|
980
1109
|
const menu = Menu.buildFromTemplate([
|
|
@@ -1094,6 +1223,14 @@ function runInteractive(captureMode = false) {
|
|
|
1094
1223
|
if (!store.settings.saveLogins) return;
|
|
1095
1224
|
const found = tabForSender(e.sender);
|
|
1096
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
|
+
}
|
|
1097
1234
|
// Origin guard: only a login typed on the service's OWN domain may be bound to this account.
|
|
1098
1235
|
// Without this, a password typed on an OAuth/phishing page reached from the tab would overwrite
|
|
1099
1236
|
// the account's saved login and later be autofilled into the real service.
|
|
@@ -1127,7 +1264,7 @@ function runInteractive(captureMode = false) {
|
|
|
1127
1264
|
});
|
|
1128
1265
|
ipcMain.on('fill-result', () => {});
|
|
1129
1266
|
|
|
1130
|
-
ipcMain.handle('fill-login', (_e, key) => {
|
|
1267
|
+
ipcMain.handle('fill-login', (_e, key, credentialId = null) => {
|
|
1131
1268
|
const t = vm.tabs.get(key);
|
|
1132
1269
|
if (!t) throw new Error('That tab is asleep — open it first');
|
|
1133
1270
|
const [svc, id] = key.split('::');
|
|
@@ -1135,10 +1272,19 @@ function runInteractive(captureMode = false) {
|
|
|
1135
1272
|
if (!originAllowed(svc, pageUrl)) {
|
|
1136
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`);
|
|
1137
1274
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
|
1140
1286
|
t.view.webContents.send('fill-credential', { username: cred.username, password: cred.password, submit: false });
|
|
1141
|
-
return
|
|
1287
|
+
return { filled: cred.username };
|
|
1142
1288
|
});
|
|
1143
1289
|
ipcMain.handle('creds-stats', () => creds.stats());
|
|
1144
1290
|
ipcMain.handle('creds-browsers', () => creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData })));
|
|
@@ -1156,9 +1302,21 @@ function runInteractive(captureMode = false) {
|
|
|
1156
1302
|
ipcMain.handle('creds-clear', () => creds.clearAll());
|
|
1157
1303
|
ipcMain.handle('creds-for-account', (_e, svc, id) => {
|
|
1158
1304
|
const svcCfg = serviceByKey(svc);
|
|
1159
|
-
const c = creds.
|
|
1305
|
+
const c = creds.credentialForAccount(svc, id, svcCfg && svcCfg.loginUrl);
|
|
1160
1306
|
return c ? { username: c.username, host: c.host, source: c.source } : null; // never the password
|
|
1161
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));
|
|
1162
1320
|
// ---------- Phase 3: service plugins (JSON manifests) ----------
|
|
1163
1321
|
ipcMain.handle('plugins-list', () => ({
|
|
1164
1322
|
dir: plugins.pluginsDir(),
|
|
@@ -1228,19 +1386,26 @@ function runInteractive(captureMode = false) {
|
|
|
1228
1386
|
return true;
|
|
1229
1387
|
});
|
|
1230
1388
|
ipcMain.handle('set-content-setting', (_e, name, value) => {
|
|
1231
|
-
if (!['adBlock', 'darkMode', '
|
|
1389
|
+
if (!['adBlock', 'darkMode', 'saveLogins', 'autofill', 'autoUpdate'].includes(name)) throw new Error(`unknown setting: ${name}`);
|
|
1232
1390
|
store.settings[name] = !!value;
|
|
1233
1391
|
if (name === 'autoUpdate') { store.save(); updater.setAutoUpdate(!!value); vm.emitState(); return !!value; }
|
|
1234
|
-
// Inverting a page that is already dark produces a washed-out light page, so the two modes
|
|
1235
|
-
// are mutually exclusive.
|
|
1236
|
-
if (name === 'forceDark' && value) store.settings.darkMode = false;
|
|
1237
|
-
if (name === 'darkMode' && value) store.settings.forceDark = false;
|
|
1238
1392
|
store.save();
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
+
}
|
|
1244
1409
|
}
|
|
1245
1410
|
vm.emitState();
|
|
1246
1411
|
return store.settings[name];
|
|
@@ -1342,7 +1507,7 @@ function cycleTab(vm, dir) {
|
|
|
1342
1507
|
}
|
|
1343
1508
|
|
|
1344
1509
|
// test modes default to an isolated userData: they can never pollute the real registry/partitions
|
|
1345
|
-
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'];
|
|
1346
1511
|
const testFlag = TEST_FLAGS.find(f => process.argv.includes(f));
|
|
1347
1512
|
if (testFlag && !process.env.MAM_USER_DATA) {
|
|
1348
1513
|
const dirName = testFlag === '--smoke-restore' ? 'mam-test-smoke' : `mam-test-${testFlag.slice(2)}`;
|
|
@@ -1377,6 +1542,7 @@ if (!gotLock) {
|
|
|
1377
1542
|
else if (process.argv.includes('--smoke-autofill')) runSmokeAutofill().catch(err => { console.error(err); app.exit(1); });
|
|
1378
1543
|
else if (process.argv.includes('--smoke-updates')) runSmokeUpdates().catch(err => { console.error(err); app.exit(1); });
|
|
1379
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); });
|
|
1380
1546
|
else if (process.argv.includes('--capture')) runInteractive(true);
|
|
1381
1547
|
else runInteractive();
|
|
1382
1548
|
});
|