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 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
- **One desktop app for every account you own.** Keep several Google, Microsoft, AWS, Vercel, Netlify,
4
- Cloudflare, Supabase, GitHub, npm, Stripe, Gmail, Outlook and Proton Mail accounts (37 services built
5
- in, any site by URL) signed in at the same time, each in a fully isolated session, and switch between
6
- them instantly — the way a browser handles tabs, but built around *accounts* instead of pages.
3
+ [![version](https://img.shields.io/github/v/release/FiredMosquito831/my-provider-dash-manager?label=version)](https://github.com/FiredMosquito831/my-provider-dash-manager/releases/latest)
4
+ [![npm](https://img.shields.io/npm/v/my-provider-dash-manager)](https://www.npmjs.com/package/my-provider-dash-manager)
5
+ [![downloads](https://img.shields.io/npm/dm/my-provider-dash-manager)](https://www.npmjs.com/package/my-provider-dash-manager)
6
+ [![stars](https://img.shields.io/github/stars/FiredMosquito831/my-provider-dash-manager)](https://github.com/FiredMosquito831/my-provider-dash-manager)
7
+ [![licence](https://img.shields.io/badge/licence-source--available-green)](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
- ![Home — the account rail on the left, every account as a card, the installed version bottom-left](docs/screenshots/home.png)
18
+ ![Home — the account rail on the left, every account as a card, the installed version bottom-left](https://raw.githubusercontent.com/FiredMosquito831/my-provider-dash-manager/main/docs/screenshots/home.png)
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
- ![Home, scrolled down — the service picker grouped by category, the Updates panel and saved logins](docs/screenshots/services.png)
25
+ ![Home, scrolled down — the service picker grouped by category, the Updates panel and saved logins](https://raw.githubusercontent.com/FiredMosquito831/my-provider-dash-manager/main/docs/screenshots/services.png)
19
26
 
20
- ![A Gmail account open in the app, with back / forward / reload / dashboard and the address bar in the strip](docs/screenshots/navigation.png)
27
+ ![A Gmail account open in the app, with back / forward / reload / dashboard and the address bar in the strip](https://raw.githubusercontent.com/FiredMosquito831/my-provider-dash-manager/main/docs/screenshots/navigation.png)
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
- - **Inside the app.** The version is always shown bottom-left in the rail. When a newer release
50
- exists it turns into an **Update x.y.z** button; click it to open the Updates panel on Home, which
51
- shows the release notes and offers **Download** and **Restart & install** with progress. The app
52
- checks quietly on startup and once an hour after that. Nothing is downloaded or installed until you
53
- press the button.
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
- installs the newest release over the current one. Close the app first, or the running copy keeps
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
- - **Unlimited accounts per service.** Each account gets its own Chromium storage partition — separate
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
- ### Saved logins
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
- ### Updates
192
- - Version always visible in the rail; an **Update** button appears when a newer release exists.
193
- - Updates panel on Home: installed vs latest, release notes, Check / Download / Restart & install with
194
- progress. Nothing installs itself.
195
- - Startup and hourly checks against the public release feed.
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
- ### Content
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
- - **Dark mode**: sites that support `prefers-color-scheme` go dark natively, and an optional
202
- force-dark inversion covers the ones that don't.
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 (7)
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 + real release-feed check (8)
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 108 checks across seven modes. `MAM_USER_DATA=<dir>` points any command at an isolated
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.6.3",
4
- "description": "Manage many accounts per cloud service (Vercel, Netlify, Supabase, Cloudflare, Railway, Render, GitHub) in one desktop app \u2014 isolated persistent sessions, one tab per account.",
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
- "railway",
80
- "render",
96
+ "aws",
97
+ "azure",
98
+ "google",
99
+ "microsoft",
100
+ "google-cloud",
81
101
  "github",
82
- "electron",
83
- "dashboard",
84
- "sessions",
85
- "windows"
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
- console.log(`\nRunning the installer${flags.has('--silent') ? ' (silent)' : ''}…`);
86
- const child = spawn(dest, flags.has('--silent') ? ['/S'] : [], { detached: true, stdio: 'ignore' });
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 && flags.has('--silent')) {
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, forceDark: false, saveLogins: true, autofill: 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
- this.settings = Object.assign({ warmLimit: 5, groupTabs: false, collapsed: [], stripCollapsed: [], adBlock: true, darkMode: true, forceDark: false, saveLogins: true, autofill: true }, readJson(userDataPath('settings.json'), {}));
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, forceDark: !!store.settings.forceDark });
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
- const cred = creds.forAccount(svc, id, pageUrl);
1136
- if (!cred) throw new Error('No saved login for this account yet — sign in once and it will be remembered');
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 true;
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.forAccount(svc, id, svcCfg && svcCfg.loginUrl);
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', 'forceDark', 'saveLogins', 'autofill'].includes(name)) throw new Error(`unknown setting: ${name}`);
1389
+ if (!['adBlock', 'darkMode', 'saveLogins', 'autofill', 'autoUpdate'].includes(name)) throw new Error(`unknown setting: ${name}`);
1229
1390
  store.settings[name] = !!value;
1230
- // Inverting a page that is already dark produces a washed-out light page, so the two modes
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
- content.setNativeDark(!!store.settings.darkMode);
1236
- // re-apply to every live tab so the change is visible immediately
1237
- for (const t of vm.tabs.values()) {
1238
- if ((name === 'forceDark' || name === 'adBlock') && !value) t.view.webContents.reload(); // injected CSS can only be undone by reloading
1239
- else content.applyToPage(t.view.webContents, { adBlock: !!store.settings.adBlock, forceDark: !!store.settings.forceDark });
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
  });