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.
- package/README.md +33 -11
- package/package.json +1 -1
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|

|
|
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
|
+

|
|
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
|
-
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
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
|
-
|
|
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: '
|
|
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 || {}));
|