my-provider-dash-manager 0.4.1 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +33 -11
  2. package/package.json +1 -1
  3. package/src/main.js +79 -5
package/README.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # My Provider Dash Manager
2
2
 
3
- **One desktop app for every cloud account you own.** Keep several Vercel, Netlify, Cloudflare,
4
- Railway, Render, Fly.io, Heroku, DigitalOcean, Supabase, Neon, GitHub, GitLab, npm, PyPI, Docker Hub,
5
- Hugging Face and Stripe accounts signed in at the same time, each in a fully isolated session, and
6
- switch between them instantly — the way a browser handles tabs, but built around *accounts* instead
7
- of pages.
3
+ **One desktop app for every account you own.** Keep several Google, Microsoft, AWS, Vercel,
4
+ Netlify, Cloudflare, Supabase, GitHub, npm, Stripe, Gmail, Outlook, Proton Mail (and 25 more) accounts
5
+ signed in at the same time, each in a fully isolated session, and switch between them instantly — the
6
+ way a browser handles tabs, but built around *accounts* instead of pages.
8
7
 
9
8
  <sub>Windows desktop app · Electron 43 · local-first · no telemetry, no account, no server ·
10
9
  source-available licence: free for personal and commercial use</sub>
@@ -73,6 +72,11 @@ Each provider lists its accounts; hovering one reveals open / sleep / close / fi
73
72
 
74
73
  ![A dashboard open in the app](docs/screenshots/dashboard.png)
75
74
 
75
+ Browser controls appear in the strip while a tab is open: back, forward, reload, dashboard, and an
76
+ address bar that navigates inside that account's own session.
77
+
78
+ ![Navigation bar with a Gmail sign-in open](docs/screenshots/navigation.png)
79
+
76
80
  **The top bar is only your open tabs** — filter them, or group them by provider with collapsible sections.
77
81
  Slept tabs stay in the strip (dimmed) and resume on click.
78
82
 
@@ -127,7 +131,10 @@ sessions, one place.
127
131
  hover actions: open, sleep, close, fill login, connect an API token, manage. Collapse any provider.
128
132
  - **Top bar — open tabs only.** A filter box and a group-by-provider toggle with collapsible groups.
129
133
  - **Home** is always one click (or `Esc`) away: a grid of every service and account with live status.
130
- - Keyboard: `Ctrl+W` sleep, `Ctrl+Shift+W` sleep all, `Ctrl+Tab` cycle, `Ctrl+1…9` jump.
134
+ - **Browser controls** while a tab is open: back, forward, reload/stop, back-to-dashboard, and an
135
+ editable address bar that navigates inside that account's own session (http/https only).
136
+ - Keyboard: `Ctrl+W` sleep, `Ctrl+Shift+W` sleep all, `Ctrl+Tab` cycle, `Ctrl+1…9` jump,
137
+ `Alt+Left`/`Alt+Right` back/forward, `Ctrl+R`/`F5` reload, `Alt+Home` dashboard, `Ctrl+L` address.
131
138
 
132
139
  ### Saved logins
133
140
  - **Remembers logins you type** inside an account tab, bound to *that* account, so two accounts on the
@@ -141,11 +148,26 @@ sessions, one place.
141
148
  account's login, and a saved password is never filled into a page outside the service's domains.
142
149
 
143
150
  ### Built-in services
144
- Seventeen services ship ready to use, grouped in the rail: **hosting** (Vercel, Netlify, Cloudflare,
145
- Railway, Render, Fly.io, Heroku, DigitalOcean), **data** (Supabase, Neon), **code and packages**
146
- (GitHub, GitLab, npm, PyPI, Docker Hub, Hugging Face) and **payments** (Stripe). Every login page is
147
- verified to load inside an isolated partition by `npm run smoke:services`. Anything else can be added
148
- by URL or as a plugin (below).
151
+ Thirty-seven services ship ready to use, grouped by category. The rail and Home show only the ones
152
+ you have accounts on; everything else sits one click away behind **Add account**, a searchable picker.
153
+
154
+ | Category | Services |
155
+ |---|---|
156
+ | Hosting & deploy | Vercel, Netlify, Cloudflare, Railway, Render, Fly.io, Heroku, DigitalOcean |
157
+ | Databases | Supabase, Neon |
158
+ | Code & packages | GitHub, GitLab, npm, PyPI, Docker Hub, Hugging Face |
159
+ | Payments | Stripe |
160
+ | Google | Google Account, Gmail, Google Cloud, Firebase, Google Workspace Admin, Google Play Console, Google AI Studio |
161
+ | Microsoft | Microsoft Account, Outlook, Azure, Microsoft 365 Admin |
162
+ | Mail | Proton Mail, Zoho Mail, Fastmail, Yahoo Mail, Tuta Mail, iCloud Mail |
163
+ | Cloud | AWS |
164
+ | AI | OpenAI Platform, Claude Platform (Anthropic) |
165
+
166
+ Every login page is verified to load inside an isolated partition, landing on a host the credential
167
+ guard trusts, by `npm run smoke:services`. Google sign-in works directly in the app (verified against
168
+ Google's own sign-in flow); "Continue with Google" buttons on *other* sites remain blocked by Google
169
+ in embedded browsers, so use email/password or GitHub there. Anything else can be added by URL or as
170
+ a plugin (below).
149
171
 
150
172
  ### API status
151
173
  Paste a read-only API token into an account and its Home card shows live status — project, app,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-provider-dash-manager",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
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.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
package/src/main.js CHANGED
@@ -58,8 +58,12 @@ function userData(name) { return userDataPath(name); }
58
58
  // Built-in curated services + user-added generic ones (Phase 2) + plugin manifests (Phase 3).
59
59
  let pluginServices = [];
60
60
  let pluginErrors = [];
61
+ // Sign-in infrastructure the managed services depend on but that is not a credential host: the
62
+ // ad-blocker must leave these alone (EasyList rules hit e.g. accounts.youtube.com/CheckConnection,
63
+ // which Google sign-in loads in an iframe). This list grants NO password trust — see serviceHosts.
64
+ const AUTH_INFRA_HOSTS = ['accounts.youtube.com', 'accounts.google.com', 'gstatic.com', 'login.microsoftonline.com', 'login.live.com', 'msftauth.net', 'msauth.net', 'appleid.cdn-apple.com'];
61
65
  function refreshManagedHosts() {
62
- const hosts = new Set();
66
+ const hosts = new Set(AUTH_INFRA_HOSTS);
63
67
  for (const s of allServices()) for (const h of serviceHosts(s.key)) hosts.add(h);
64
68
  content.setManagedHosts([...hosts]);
65
69
  }
@@ -266,14 +270,20 @@ class ViewManager {
266
270
  });
267
271
  const onNav = (_e, url2) => {
268
272
  tab.url = url2;
269
- // conservative sign-in heuristic: sitting on the service's login page means not signed in
270
- tab.lastState = url2.startsWith(service.loginUrl) ? 'signin' : 'ok';
273
+ // conservative sign-in heuristic: sitting on the service's login page means not signed in.
274
+ // When the service authenticates on a separate host (accounts.google.com, login.live.com,
275
+ // id.heroku.com…) any URL on that host counts as "signing in".
276
+ const loginHost = creds.hostOf(service.loginUrl);
277
+ const onAuthHost = loginHost && loginHost !== creds.hostOf(service.dashboardUrl) && creds.hostOf(url2) === loginHost;
278
+ tab.lastState = (url2.startsWith(service.loginUrl) || onAuthHost) ? 'signin' : 'ok';
271
279
  const a = findAccount(svc, id);
272
280
  if (a) { a.lastState = tab.lastState; store.save(); }
273
281
  this.emitState();
274
282
  };
275
283
  view.webContents.on('did-navigate', onNav);
276
284
  view.webContents.on('did-navigate-in-page', onNav); // dashboards route client-side (pushState) — must track too
285
+ view.webContents.on('did-start-loading', () => this.emitState());
286
+ view.webContents.on('did-stop-loading', () => this.emitState());
277
287
  view.webContents.on('render-process-gone', () => {
278
288
  console.error(`renderer gone: ${key}`);
279
289
  this.hibernate(key); // drop the dead view; partition survives, reopening rehydrates
@@ -431,12 +441,43 @@ class ViewManager {
431
441
  key: k, svc: s.svc, id: s.id, label: a.label || s.id, colorIdx: a.colorIdx || 0,
432
442
  title: t ? t.title : '', url: t ? t.url : s.url,
433
443
  live: !!t, visible: t ? t.visible : false,
444
+ canGoBack: !!(t && !t.view.webContents.isDestroyed() && t.view.webContents.navigationHistory.canGoBack()),
445
+ canGoForward: !!(t && !t.view.webContents.isDestroyed() && t.view.webContents.navigationHistory.canGoForward()),
446
+ loading: !!(t && !t.view.webContents.isDestroyed() && t.view.webContents.isLoading()),
434
447
  lastState: t ? t.lastState : (a.lastState || 'unknown'),
435
448
  };
436
449
  }),
437
450
  });
438
451
  }
439
452
 
453
+ // Browser-style navigation for the ACTIVE tab. `navigate` accepts http(s) only — the address bar
454
+ // must never become a way to launch other schemes from the chrome.
455
+ nav(action, url) {
456
+ const t = this.tabs.get(this.activeKey);
457
+ if (!t || t.view.webContents.isDestroyed()) return false;
458
+ const wc = t.view.webContents;
459
+ switch (action) {
460
+ case 'back': if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); break;
461
+ case 'forward': if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); break;
462
+ case 'reload': wc.reload(); break;
463
+ case 'stop': wc.stop(); break;
464
+ case 'dashboard': { const svc = serviceByKey(t.svc); if (svc) wc.loadURL(svc.dashboardUrl).catch(() => {}); break; }
465
+ case 'navigate': {
466
+ let target = String(url || '').trim();
467
+ if (!target) return false;
468
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(target)) target = `https://${target}`; // "example.com/path" → https
469
+ let parsed;
470
+ try { parsed = new URL(target); } catch { return false; }
471
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false;
472
+ wc.loadURL(parsed.href).catch(err => console.error(`navigate failed ${this.activeKey}:`, err.message));
473
+ break;
474
+ }
475
+ default: return false;
476
+ }
477
+ this.emitState();
478
+ return true;
479
+ }
480
+
440
481
  snapshotMemory() {
441
482
  // Summing workingSetSize over-reports badly: Chromium maps shared pages (the framework binary and
442
483
  // friends) into every process, so they are counted once per process — with 10 processes that read
@@ -567,6 +608,25 @@ async function runSmoke() {
567
608
  const mem = vm.snapshotMemory();
568
609
  check('live-tabs', mem.liveTabs === 3, `${mem.totalMB}MB`);
569
610
 
611
+ // browser-style navigation on the active tab (address bar, back, dashboard) — http(s) only
612
+ const key = accountKey('netlify', 'a1');
613
+ vm.activate(key);
614
+ const urlOf = () => vm.tabs.get(key).view.webContents.getURL();
615
+ const waitFor = async (pred, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (pred()) return true; await sleep(250); } return pred(); };
616
+ check('nav-rejects-javascript-scheme', vm.nav('navigate', 'javascript:alert(1)') === false && !urlOf().startsWith('javascript:'));
617
+ check('nav-rejects-file-scheme', vm.nav('navigate', 'file:///C:/Windows') === false && !urlOf().startsWith('file:'));
618
+ check('nav-rejects-empty', vm.nav('navigate', ' ') === false);
619
+ vm.nav('navigate', 'example.com'); // bare host → https
620
+ check('nav-address-goes-there', await waitFor(() => /^https:\/\/example\.com\/?$/.test(urlOf())), urlOf());
621
+ check('nav-can-go-back', vm.tabs.get(key).view.webContents.navigationHistory.canGoBack());
622
+ vm.nav('back');
623
+ check('nav-back-returns', await waitFor(() => /netlify\.com/.test(urlOf())), urlOf());
624
+ vm.nav('navigate', 'https://example.org/');
625
+ await waitFor(() => /example\.org/.test(urlOf()));
626
+ vm.nav('dashboard');
627
+ check('nav-dashboard-returns-to-service', await waitFor(() => /app\.netlify\.com/.test(urlOf())), urlOf());
628
+ check('nav-noop-without-active-tab', (() => { const k = vm.activeKey; vm.activeKey = null; const r = vm.nav('reload'); vm.activeKey = k; return r === false; })());
629
+
570
630
  fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
571
631
  app.exit(results.checks.every(c => c.ok) ? 0 : 1);
572
632
  }
@@ -609,8 +669,9 @@ async function runSmokeServices() {
609
669
  const check = (name, ok, detail = '') => { results.checks.push({ name, ok, detail }); console.log(`[smoke:services] ${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`); };
610
670
  const keys = SERVICES.map(s => s.key);
611
671
  check('unique-keys', new Set(keys).size === keys.length, `${keys.length} services`);
612
- check('definitions-complete', SERVICES.every(s => s.name && /^https:\/\//.test(s.dashboardUrl) && /^https:\/\//.test(s.loginUrl) && /^https:\/\//.test(s.signupUrl) && /^#[0-9a-f]{6}$/i.test(s.color) && ['ok', 'warn', 'one-per-person'].includes(s.multiAccountPolicy)));
672
+ check('definitions-complete', SERVICES.every(s => s.name && /^https:\/\//.test(s.dashboardUrl) && /^https:\/\//.test(s.loginUrl) && /^https:\/\//.test(s.signupUrl) && /^#[0-9a-f]{6}$/i.test(s.color) && ['ok', 'warn', 'one-per-person'].includes(s.multiAccountPolicy) && typeof s.category === 'string' && s.category));
613
673
  check('partition-ids-safe', SERVICES.every(s => /^[a-z0-9_-]+$/.test(s.key)), keys.join(','));
674
+ check('extra-hosts-are-specific', SERVICES.every(s => (s.extraHosts || []).every(h => /^[a-z0-9.-]+\.[a-z]+$/.test(h) && h.split('.').length >= 3)));
614
675
  check('extra-hosts-in-guard', serviceHosts('dockerhub').includes('login.docker.com') && originAllowed('dockerhub', 'https://login.docker.com/u/login?x=1') && !originAllowed('dockerhub', 'https://evil.example/'));
615
676
 
616
677
  const win = new BaseWindow({ width: 1280, height: 800, show: false });
@@ -929,8 +990,20 @@ function runInteractive(captureMode = false) {
929
990
  })),
930
991
  ],
931
992
  },
993
+ {
994
+ label: 'Navigate',
995
+ submenu: [
996
+ { label: 'Back', accelerator: 'Alt+Left', click: () => vm.nav('back') },
997
+ { label: 'Forward', accelerator: 'Alt+Right', click: () => vm.nav('forward') },
998
+ { label: 'Reload', accelerator: 'CmdOrCtrl+R', click: () => vm.nav('reload') },
999
+ { label: 'Reload', accelerator: 'F5', click: () => vm.nav('reload') },
1000
+ { label: 'Dashboard', accelerator: 'Alt+Home', click: () => vm.nav('dashboard') },
1001
+ { type: 'separator' },
1002
+ { label: 'Focus Address Bar', accelerator: 'CmdOrCtrl+L', click: () => { if (win.webContents && !win.webContents.isDestroyed()) win.webContents.send('focus-address'); } },
1003
+ ],
1004
+ },
932
1005
  { label: 'Edit', submenu: [{ role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }] },
933
- { label: 'View', submenu: [{ role: 'reload' }, { role: 'forceReload' }, { type: 'separator' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'zoomReset' }, { type: 'separator' }, { role: 'toggleDevTools' }] },
1006
+ { label: 'View', submenu: [{ role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'zoomReset' }, { type: 'separator' }, { role: 'toggleDevTools' }] },
934
1007
  ]);
935
1008
  Menu.setApplicationMenu(menu);
936
1009
 
@@ -980,6 +1053,7 @@ function runInteractive(captureMode = false) {
980
1053
  return key;
981
1054
  });
982
1055
  ipcMain.handle('activate-tab', (_e, key) => vm.activate(key));
1056
+ ipcMain.handle('nav', (_e, action, url) => vm.nav(String(action || ''), url));
983
1057
  ipcMain.handle('sleep-tab', (_e, key) => vm.hibernate(key));
984
1058
  ipcMain.handle('sleep-all', () => vm.hibernateAll());
985
1059
  ipcMain.handle('update-account', (_e, svc, id, patch) => vm.updateAccount(svc, id, patch || {}));