webhelm 1.0.5 → 1.0.7
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 +10 -0
- package/package.json +3 -2
- package/runtime/cli.mjs +6 -3
- package/runtime/config-paths.mjs +3 -3
- package/runtime/extension/control-channel.mjs +120 -0
- package/runtime/extension/helm.html +125 -0
- package/runtime/extension/icons/webhelm-128.png +0 -0
- package/runtime/extension/icons/webhelm-16.png +0 -0
- package/runtime/extension/icons/webhelm-32.png +0 -0
- package/runtime/extension/icons/webhelm-48.png +0 -0
- package/runtime/extension/manifest.json +8 -4
- package/runtime/extension/popup.html +61 -0
- package/runtime/extension/popup.mjs +136 -0
- package/runtime/extension/service-worker.mjs +96 -45
- package/runtime/extension/workspace-manager.mjs +46 -6
- package/runtime/extension/workspace-page.mjs +25 -3
- package/runtime/extension/workspace-summary.mjs +35 -0
- package/runtime/extension/workspace.html +8 -5
- package/runtime/extension-manager.mjs +25 -6
- package/runtime/login-guard.mjs +43 -9
- package/runtime/managed-chrome-guard.mjs +11 -8
- package/runtime/node-daemon.mjs +92 -18
- package/runtime/session-control.mjs +13 -0
- package/runtime/session-lifecycle.mjs +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,16 @@ npx --yes webhelm@latest session stop SESSION_ID
|
|
|
29
29
|
|
|
30
30
|
Use `npx skills add lalalic/webhelm --skill webhelm` to install agent guidance. The Extension is bundled with the npm Runtime and staged by WebHelm Daemon at `~/.local/webhelm/extension`. The Skill lives **in this repo** at `skills/webhelm/SKILL.md`. No Chrome Web Store publication or manual Extension installation is required.
|
|
31
31
|
|
|
32
|
+
## WebHelm Chrome Extension control panel
|
|
33
|
+
|
|
34
|
+
The daemon-managed Chrome Extension is installed from the npm Runtime and does not require the Chrome Web Store. Click the WebHelm toolbar icon to see daemon connectivity, each configured workspace's available capacity and current Session count. The badge shows total available slots across configured workspaces, including unallocated lazy slots; `!` means the daemon heartbeat is stale and `?` means capacity cannot be determined.
|
|
35
|
+
|
|
36
|
+
The popup has a confirmation step to stop one Session, clear Sessions in one workspace, or clear Sessions across all workspaces. These actions are executed by the same Node daemon as the CLI, over the existing CDP-backed Extension RPC. No shell access or HTTP listener is granted to the Extension. The persistent ChatGPT queue is not deleted, and queued work may create new Sessions afterward.
|
|
37
|
+
|
|
38
|
+
New installations use the default Chrome tab group `WebHelm` and the idle page `helm.html`. Legacy `Harness` default groups migrate only when their tabs are idle. Custom workspace names are unchanged; the old `workspace.html` path and lease titles remain readable until their Sessions finish. Closing an allocated Session returns its managed tabs to the idle page, retaining physical Chrome targets, while unleased popup children are closed. Do not manually remove active managed tabs.
|
|
39
|
+
|
|
40
|
+
For a real browser integration check on macOS, run `node test/e2e/extension-control.e2e.mjs`; it creates a temporary isolated Chrome profile and daemon and does not touch the current production Sessions.
|
|
41
|
+
|
|
32
42
|
The Actions checkout is `~/.local/webhelm`, with site modules at `~/.local/webhelm/platforms`. Setup clones if absent, then reuses that checkout without discarding local modifications. Run `platform sync` for new commits. For a verified local Action fix, `platform publish --message "..."` tests, commits and pushes only the action changes to the separate repo.
|
|
33
43
|
|
|
34
44
|
## Architecture and compatibility
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webhelm",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "node --test test/node/*.test.mjs test/workspace-manager.test.mjs skills/webhelm/tests/*.test.mjs",
|
|
20
|
-
"test:package": "npm pack --dry-run --json"
|
|
20
|
+
"test:package": "npm pack --dry-run --json",
|
|
21
|
+
"test:e2e:extension": "node test/e2e/extension-control.e2e.mjs"
|
|
21
22
|
},
|
|
22
23
|
"license": "MIT",
|
|
23
24
|
"repository": {
|
package/runtime/cli.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { ensureDaemon, request, handle, SOCKET } from './node-daemon.mjs';
|
|
|
8
8
|
import { PLATFORMS, listPlatforms, platformProfile, actionPath, validateConfig } from './platforms.mjs';
|
|
9
9
|
import { ACTIONS_DIR, ensureActions, syncActions, publishActions } from './actions-repo.mjs';
|
|
10
10
|
import { migrateConfig, migrateChromeProfile } from './migrate.mjs';
|
|
11
|
-
import { installLoginGuard } from './login-guard.mjs';
|
|
11
|
+
import { installLoginGuard, stageRuntimeForLoginGuard } from './login-guard.mjs';
|
|
12
12
|
|
|
13
13
|
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const emit = value => console.log(JSON.stringify(value));
|
|
@@ -25,8 +25,9 @@ Usage:
|
|
|
25
25
|
webhelm migrate # after stopping Chrome and the old WebHelm daemon
|
|
26
26
|
webhelm setup
|
|
27
27
|
webhelm status
|
|
28
|
+
webhelm ensure # verify/recover managed Chrome and WebHelm group, not just daemon ping
|
|
28
29
|
webhelm capacity [--workspace NAME]
|
|
29
|
-
webhelm session start [--url URL]
|
|
30
|
+
webhelm session start [--workspace NAME] [--url URL]
|
|
30
31
|
webhelm session list
|
|
31
32
|
webhelm session heartbeat SESSION_ID # keep a working Session alive
|
|
32
33
|
webhelm session exec SESSION_ID [--code-file FILE] # JavaScript
|
|
@@ -109,9 +110,11 @@ async function main(args = process.argv.slice(2)) {
|
|
|
109
110
|
if (group === 'setup') {
|
|
110
111
|
const repo=await ensureActions();
|
|
111
112
|
const chrome=await ensureDaemon();
|
|
112
|
-
const
|
|
113
|
+
const persisted=stageRuntimeForLoginGuard();
|
|
114
|
+
const login_guard=installLoginGuard({cliPath:path.join(persisted.path,'bin/webhelm.mjs'),nodePath:process.execPath});
|
|
113
115
|
return emit({status:'ready',...repo,daemon_pid:chrome.pid,managed_chrome:chrome.managed_chrome,login_guard});
|
|
114
116
|
}
|
|
117
|
+
if (group === 'ensure') return emit(await request({op:'guard-check'}));
|
|
115
118
|
if (group === 'status') {
|
|
116
119
|
const daemon = await ensureDaemon();
|
|
117
120
|
return emit({version:daemon.version, platform_dir:PLATFORMS, root:ROOT,
|
package/runtime/config-paths.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import os from 'node:os';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
|
|
5
|
-
export const CONFIG_ROOT = path.join(os.homedir(), '.config', 'webhelm');
|
|
6
|
-
export const LEGACY_ROOT = path.join(os.homedir(), '.config', 'browser-workspace');
|
|
7
|
-
export const LOCAL_ROOT = path.join(os.homedir(), '.local', 'webhelm');
|
|
5
|
+
export const CONFIG_ROOT = path.resolve(process.env.WEBHELM_CONFIG_ROOT || path.join(os.homedir(), '.config', 'webhelm'));
|
|
6
|
+
export const LEGACY_ROOT = path.resolve(process.env.WEBHELM_LEGACY_ROOT || (process.env.WEBHELM_CONFIG_ROOT ? path.join(CONFIG_ROOT,'legacy-browser-workspace') : path.join(os.homedir(), '.config', 'browser-workspace')));
|
|
7
|
+
export const LOCAL_ROOT = path.resolve(process.env.WEBHELM_LOCAL_ROOT || path.join(os.homedir(), '.local', 'webhelm'));
|
|
8
8
|
export const OLD_PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
|
|
9
9
|
export const PROFILE_DIR = path.join(LOCAL_ROOT, 'chrome');
|
|
10
10
|
export const RUNTIME_DIR = path.join(CONFIG_ROOT, 'runtime');
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The daemon polls this short-lived mailbox through the existing CDP Extension
|
|
2
|
+
// RPC. Never expose a local HTTP service or execute shell commands in Chrome.
|
|
3
|
+
const KEY = 'webhelm.control.v1';
|
|
4
|
+
const FRESH_MS = 8_000;
|
|
5
|
+
const EXPIRY_MS = 20_000;
|
|
6
|
+
const ABANDONED_MS = 10 * 60_000;
|
|
7
|
+
|
|
8
|
+
export class ControlChannel {
|
|
9
|
+
constructor(chromeApi, now = () => Date.now(), makeId = () => crypto.randomUUID()) {
|
|
10
|
+
this.chrome = chromeApi;
|
|
11
|
+
this.now = now;
|
|
12
|
+
this.makeId = makeId;
|
|
13
|
+
this.queue = Promise.resolve();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
serialize(fn) {
|
|
17
|
+
const task = this.queue.then(fn);
|
|
18
|
+
this.queue = task.catch(() => {});
|
|
19
|
+
return task;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async read() {
|
|
23
|
+
return (await this.chrome.storage.session.get(KEY))[KEY] || {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async save(state) {
|
|
27
|
+
await this.chrome.storage.session.set({[KEY]: state});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
normalize(state) {
|
|
31
|
+
const result = {...state};
|
|
32
|
+
if (result.request) {
|
|
33
|
+
const age = this.now() - result.request.createdAt;
|
|
34
|
+
if (result.request.state === 'pending' && age > EXPIRY_MS) {
|
|
35
|
+
result.lastResult = {id: result.request.id, ok: false, error: 'The request expired. Try again.'};
|
|
36
|
+
result.request = null;
|
|
37
|
+
} else if (result.request.state === 'processing' && age > ABANDONED_MS) {
|
|
38
|
+
result.lastResult = {id: result.request.id, ok: false,
|
|
39
|
+
error: 'The daemon did not confirm completion. Check current sessions before retrying.'};
|
|
40
|
+
result.request = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async status() {
|
|
47
|
+
const state = this.normalize(await this.read());
|
|
48
|
+
const connected = Number.isFinite(state.daemon?.seenAt) &&
|
|
49
|
+
this.now() - state.daemon.seenAt < FRESH_MS;
|
|
50
|
+
return {
|
|
51
|
+
connected,
|
|
52
|
+
sessionCount: connected ? state.daemon.sessions?.length ?? 0 : null,
|
|
53
|
+
sessions: connected ? state.daemon.sessions || [] : [],
|
|
54
|
+
request: state.request || null,
|
|
55
|
+
lastResult: state.lastResult || null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async requestStop(action, sessionId, workspace) {
|
|
60
|
+
return this.serialize(async () => {
|
|
61
|
+
if (!['stop-all', 'stop', 'stop-workspace'].includes(action)) throw new Error('Unsupported action');
|
|
62
|
+
const state = this.normalize(await this.read());
|
|
63
|
+
if (state.request) throw new Error('Another WebHelm operation is already running');
|
|
64
|
+
if (!Number.isFinite(state.daemon?.seenAt) || this.now() - state.daemon.seenAt >= FRESH_MS)
|
|
65
|
+
throw new Error('WebHelm daemon is not responding');
|
|
66
|
+
const sessions = state.daemon.sessions || [];
|
|
67
|
+
if (action === 'stop-all' && !sessions.length) throw new Error('No active sessions to stop');
|
|
68
|
+
if (action === 'stop' && !sessions.some(s => s.session_id === sessionId))
|
|
69
|
+
throw new Error('Session is no longer active');
|
|
70
|
+
if (action === 'stop-workspace' && (!workspace || !sessions.some(s => s.workspace === workspace)))
|
|
71
|
+
throw new Error('No active sessions in this workspace');
|
|
72
|
+
const request = {id: this.makeId(), action, ...(action === 'stop' ? {sessionId} : {}),
|
|
73
|
+
...(action === 'stop-workspace' ? {workspace} : {}),
|
|
74
|
+
state: 'pending', createdAt: this.now()};
|
|
75
|
+
await this.save({...state, request, lastResult: null});
|
|
76
|
+
return {id: request.id, state: 'pending'};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async heartbeat(sessions) {
|
|
81
|
+
return this.serialize(async()=>{
|
|
82
|
+
if (!Array.isArray(sessions)) throw new Error('Invalid sessions');
|
|
83
|
+
const state=this.normalize(await this.read());
|
|
84
|
+
await this.save({...state,daemon:{seenAt:this.now(),sessions}});
|
|
85
|
+
return {updated:true};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async poll(sessions) {
|
|
90
|
+
return this.serialize(async () => {
|
|
91
|
+
if (!Array.isArray(sessions) || sessions.some(s =>
|
|
92
|
+
typeof s?.session_id !== 'string' || typeof s?.workspace !== 'string'))
|
|
93
|
+
throw new Error('Invalid daemon session listing');
|
|
94
|
+
const state = this.normalize(await this.read());
|
|
95
|
+
const request = state.request?.state === 'pending'
|
|
96
|
+
? {...state.request, state: 'processing'}
|
|
97
|
+
: state.request || null;
|
|
98
|
+
await this.save({...state, request, daemon: {seenAt: this.now(), sessions}});
|
|
99
|
+
return request?.state === 'processing' && state.request?.state === 'pending'
|
|
100
|
+
? {id: request.id, action: request.action, ...(request.sessionId ? {sessionId:request.sessionId} : {}),
|
|
101
|
+
...(request.workspace ? {workspace:request.workspace} : {})}
|
|
102
|
+
: null;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async complete(id, response) {
|
|
107
|
+
return this.serialize(async () => {
|
|
108
|
+
const state = this.normalize(await this.read());
|
|
109
|
+
if (!state.request || state.request.id !== id) return {ignored: true};
|
|
110
|
+
const stoppedCount = response?.stopped_count ?? (response?.stopped ? 1 : 0);
|
|
111
|
+
const errors = response?.errors || [];
|
|
112
|
+
const errorText = response?.error || errors.map(e => e.error || String(e)).join('; ');
|
|
113
|
+
const lastResult = response?.ok
|
|
114
|
+
? {id, ok: errors.length === 0, stoppedCount, errors, ...(errors.length ? {error: errorText} : {})}
|
|
115
|
+
: {id, ok: false, stoppedCount, errors, error: String(errorText || 'Unable to stop sessions')};
|
|
116
|
+
await this.save({...state, request: null, lastResult});
|
|
117
|
+
return lastResult;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>WebHelm</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
--page: #f5f7fb;
|
|
11
|
+
--card: #fff;
|
|
12
|
+
--ink: #182234;
|
|
13
|
+
--subtle: #5f6d81;
|
|
14
|
+
--border: #e2e8f0;
|
|
15
|
+
--accent: #335bd1;
|
|
16
|
+
--accent-bg: #edf2ff;
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body {
|
|
20
|
+
min-height: 100vh;
|
|
21
|
+
margin: 0;
|
|
22
|
+
padding: 32px 20px;
|
|
23
|
+
display: grid;
|
|
24
|
+
place-items: center;
|
|
25
|
+
font: 15px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
26
|
+
color: var(--ink);
|
|
27
|
+
background: var(--page);
|
|
28
|
+
}
|
|
29
|
+
main {
|
|
30
|
+
width: min(100%, 460px);
|
|
31
|
+
padding: clamp(24px, 6vw, 36px);
|
|
32
|
+
border: 1px solid var(--border);
|
|
33
|
+
border-radius: 20px;
|
|
34
|
+
background: var(--card);
|
|
35
|
+
box-shadow: 0 12px 35px rgb(25 42 76 / 5%);
|
|
36
|
+
}
|
|
37
|
+
header { display: flex; align-items: center; gap: 14px; }
|
|
38
|
+
.mark {
|
|
39
|
+
display: grid;
|
|
40
|
+
place-items: center;
|
|
41
|
+
width: 48px;
|
|
42
|
+
height: 48px;
|
|
43
|
+
border-radius: 14px;
|
|
44
|
+
background: var(--accent);
|
|
45
|
+
color: white;
|
|
46
|
+
font-size: 24px;
|
|
47
|
+
font-weight: 750;
|
|
48
|
+
letter-spacing: -2px;
|
|
49
|
+
}
|
|
50
|
+
.eyebrow {
|
|
51
|
+
margin: 0;
|
|
52
|
+
color: var(--subtle);
|
|
53
|
+
font-size: 11px;
|
|
54
|
+
font-weight: 650;
|
|
55
|
+
letter-spacing: 0.12em;
|
|
56
|
+
text-transform: uppercase;
|
|
57
|
+
}
|
|
58
|
+
h1 { margin: 1px 0 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.03em; }
|
|
59
|
+
.status {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 9px;
|
|
63
|
+
margin-top: 32px;
|
|
64
|
+
padding: 7px 12px;
|
|
65
|
+
border-radius: 999px;
|
|
66
|
+
background: var(--accent-bg);
|
|
67
|
+
color: var(--accent);
|
|
68
|
+
font-weight: 650;
|
|
69
|
+
font-size: 13px;
|
|
70
|
+
}
|
|
71
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
|
72
|
+
.description { margin: 16px 0 24px; color: var(--subtle); }
|
|
73
|
+
dl {
|
|
74
|
+
display: grid;
|
|
75
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
76
|
+
gap: 16px;
|
|
77
|
+
margin: 0;
|
|
78
|
+
padding: 18px 0;
|
|
79
|
+
border-block: 1px solid var(--border);
|
|
80
|
+
}
|
|
81
|
+
dt { margin-bottom: 3px; color: var(--subtle); font-size: 12px; }
|
|
82
|
+
dd { margin: 0; font-weight: 650; overflow-wrap: anywhere; }
|
|
83
|
+
footer { margin-top: 20px; color: var(--subtle); font-size: 12px; }
|
|
84
|
+
@media (prefers-color-scheme: dark) {
|
|
85
|
+
:root {
|
|
86
|
+
--page: #10151e;
|
|
87
|
+
--card: #192232;
|
|
88
|
+
--ink: #edf2f9;
|
|
89
|
+
--subtle: #a8b3c5;
|
|
90
|
+
--border: #354256;
|
|
91
|
+
--accent: #a5baff;
|
|
92
|
+
--accent-bg: #293656;
|
|
93
|
+
}
|
|
94
|
+
.mark { background: #4269e0; }
|
|
95
|
+
main { box-shadow: none; }
|
|
96
|
+
}
|
|
97
|
+
@media (max-width: 380px) {
|
|
98
|
+
body { padding: 16px; }
|
|
99
|
+
main { border-radius: 14px; }
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
102
|
+
<script type="module" src="workspace-page.mjs"></script>
|
|
103
|
+
</head>
|
|
104
|
+
<body>
|
|
105
|
+
<main aria-labelledby="page-title">
|
|
106
|
+
<header>
|
|
107
|
+
<div class="mark" aria-hidden="true">W</div>
|
|
108
|
+
<div>
|
|
109
|
+
<p class="eyebrow">Managed browser tab</p>
|
|
110
|
+
<h1 id="page-title">WebHelm</h1>
|
|
111
|
+
</div>
|
|
112
|
+
</header>
|
|
113
|
+
<div class="status" role="status">
|
|
114
|
+
<span class="dot" aria-hidden="true"></span>
|
|
115
|
+
<span id="status-label">Managed workspace</span>
|
|
116
|
+
</div>
|
|
117
|
+
<p class="description" id="description">This page belongs to WebHelm's managed browser workspace.</p>
|
|
118
|
+
<dl>
|
|
119
|
+
<div><dt>Workspace</dt><dd id="workspace-name">—</dd></div>
|
|
120
|
+
<div><dt>Tab slot</dt><dd id="slot-number">—</dd></div>
|
|
121
|
+
</dl>
|
|
122
|
+
<footer id="help-text">Tab status does not indicate whether the daemon is connected.</footer>
|
|
123
|
+
</main>
|
|
124
|
+
</body>
|
|
125
|
+
</html>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
|
-
"name": "
|
|
4
|
-
"version": "0.
|
|
3
|
+
"name": "WebHelm",
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "Own named Chrome tab-group workspaces and lazy reusable tab pools for browser agents.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"storage",
|
|
8
8
|
"tabs",
|
|
9
9
|
"tabGroups",
|
|
10
10
|
"webNavigation",
|
|
11
|
-
"contextMenus"
|
|
11
|
+
"contextMenus",
|
|
12
|
+
"alarms"
|
|
12
13
|
],
|
|
13
14
|
"host_permissions": [
|
|
14
15
|
"http://*/*",
|
|
@@ -18,8 +19,11 @@
|
|
|
18
19
|
"service_worker": "service-worker.mjs",
|
|
19
20
|
"type": "module"
|
|
20
21
|
},
|
|
22
|
+
"icons": { "16": "icons/webhelm-16.png", "32": "icons/webhelm-32.png", "48": "icons/webhelm-48.png", "128": "icons/webhelm-128.png" },
|
|
21
23
|
"action": {
|
|
22
|
-
"default_title": "
|
|
24
|
+
"default_title": "WebHelm",
|
|
25
|
+
"default_popup": "popup.html",
|
|
26
|
+
"default_icon": { "16": "icons/webhelm-16.png", "32": "icons/webhelm-32.png" }
|
|
23
27
|
},
|
|
24
28
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtovrqHYRxYDBSofmlykO8RDtsePhg8KV1fmq07nnhtbmYiNXAnIKrgRyLHeZv+cwm9DSgP5Ig7FDJ7Q6XrSi+Z1FpU6Y/SJkX5Rozo0oAJcX1ggoGD3vxpfHZThSSG/vRSnD8pH3XFf2nDUD7QP6GXc8dX9lN5pslRUNh1J4UCAOcu2UpPSsvpA+wPPTZr2JYzm3AevFXkQM6y0B+4exgw/Hy21ULST4nLWZihuCSu+gfWnftiTNzUPHtseRJcrpd0zT0R46d+OC+JRwXMVBpYSksDDtOXgERwMBSZf+vvskrEqYoXKN8Z9+Rrr7y9PTOvlwQioVLEDEjr9e7RIegQIDAQAB"
|
|
25
29
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>WebHelm</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; --ink:#182234; --muted:#5f6d81; --border:#e1e7f0; --accent:#345fd5; --accent-light:#edf2ff; --warning:#ae3849; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { width:340px; min-height:250px; margin:0; padding:18px; background:light-dark(#fff,#172131); color:light-dark(var(--ink),#edf2f9); font:13px/1.5 system-ui,sans-serif; }
|
|
11
|
+
h1 { margin:0 0 2px; font-size:18px; letter-spacing:-.02em; }
|
|
12
|
+
p { margin:4px 0 12px; color:light-dark(var(--muted),#acb8c9); }
|
|
13
|
+
.stats { display:flex; gap:10px; padding:12px 0; border-block:1px solid light-dark(var(--border),#334155); margin-bottom:12px; }
|
|
14
|
+
.stat { flex:1; }
|
|
15
|
+
.stat strong { display:block; font-size:20px; }
|
|
16
|
+
.stat span { font-size:11px; color:light-dark(var(--muted),#acb8c9); }
|
|
17
|
+
button { width:100%; border:1px solid light-dark(var(--border),#40516a); background:light-dark(#fff,#22314a); color:inherit; border-radius:9px; padding:9px; cursor:pointer; font:inherit; font-weight:600; }
|
|
18
|
+
button:disabled { opacity:.5; cursor:not-allowed; }
|
|
19
|
+
button.danger { background:#ae3849; color:#fff; border-color:#ae3849; }
|
|
20
|
+
button:focus-visible { outline:3px solid var(--accent); outline-offset:2px; }
|
|
21
|
+
#confirmation { margin:8px 0; padding:11px; background:light-dark(#fff1f2,#43222a); border-radius:9px; }
|
|
22
|
+
#confirmation p { color:inherit; }
|
|
23
|
+
.buttons { display:flex; gap:8px; }
|
|
24
|
+
.session-header { margin:12px 0 6px; font-size:13px; font-weight:700; }
|
|
25
|
+
.session-list { max-height:160px; overflow:auto; }
|
|
26
|
+
.workspace-row { padding:12px 0; border-bottom:1px solid light-dark(var(--border),#334155); }
|
|
27
|
+
.workspace-title { display:flex; align-items:center; gap:8px; justify-content:space-between; }
|
|
28
|
+
.workspace-title strong { font-size:13px; overflow-wrap:anywhere; }
|
|
29
|
+
.workspace-title button { width:auto; flex-shrink:0; padding:5px 9px; font-size:11px; }
|
|
30
|
+
.workspace-details { color:light-dark(var(--muted),#acb8c9); font-size:11px; margin:4px 0; }
|
|
31
|
+
.session-row { display:flex; align-items:center; gap:8px; padding:7px 0; border-bottom:1px solid light-dark(var(--border),#334155); }
|
|
32
|
+
.session-info { min-width:0; flex:1; }
|
|
33
|
+
.session-info strong { display:block; font-size:12px; }
|
|
34
|
+
.session-info span { display:block; color:light-dark(var(--muted),#acb8c9); font-size:11px; }
|
|
35
|
+
.session-row button { width:auto; padding:5px 9px; font-size:11px; }
|
|
36
|
+
#message { min-height:20px; margin-top:10px; font-size:12px; overflow-wrap:anywhere; }
|
|
37
|
+
[hidden] { display:none !important; }
|
|
38
|
+
</style>
|
|
39
|
+
<script type="module" src="popup.mjs"></script>
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<h1>WebHelm</h1>
|
|
43
|
+
<p>Managed Chrome · Workspace control</p>
|
|
44
|
+
<div class="stats" aria-live="polite">
|
|
45
|
+
<div class="stat"><strong id="available">—</strong><span>Total available slots</span></div>
|
|
46
|
+
<div class="stat"><strong id="sessions">—</strong><span>Active sessions</span></div>
|
|
47
|
+
<div class="stat"><strong id="daemon">—</strong><span>Daemon</span></div>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="session-header">Workspaces</div>
|
|
50
|
+
<div id="workspace-list" class="session-list" aria-live="polite">Connecting…</div>
|
|
51
|
+
<button id="clear">Clear all sessions in all workspaces</button>
|
|
52
|
+
<section id="confirmation" hidden aria-label="Confirm clear sessions">
|
|
53
|
+
<p id="confirm-text">Stop the selected WebHelm session(s)? Tasks in progress may be interrupted. Queued jobs remain and may create new sessions.</p>
|
|
54
|
+
<div class="buttons">
|
|
55
|
+
<button id="cancel">Cancel</button>
|
|
56
|
+
<button class="danger" id="confirm">Confirm stop</button>
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
<div id="message" role="status" aria-live="polite"></div>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const el = id => document.getElementById(id);
|
|
2
|
+
let confirming = false;
|
|
3
|
+
let requestId = null;
|
|
4
|
+
let chosen = {action: 'stop-all'};
|
|
5
|
+
let lastSignature = '';
|
|
6
|
+
let refreshRunning = false;
|
|
7
|
+
|
|
8
|
+
async function rpc(method, args = {}) {
|
|
9
|
+
const response = await chrome.runtime.sendMessage({method, args});
|
|
10
|
+
if (!response?.ok) throw new Error(response?.error?.message || 'WebHelm request failed');
|
|
11
|
+
return response.result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function showConfirmation(action, params = {}) {
|
|
15
|
+
chosen = {action, ...params};
|
|
16
|
+
confirming = true;
|
|
17
|
+
el('confirmation').hidden = false;
|
|
18
|
+
el('clear').hidden = true;
|
|
19
|
+
if (action === 'stop-all') {
|
|
20
|
+
el('confirm-text').textContent = 'Stop sessions in ALL workspaces? Active tasks will be interrupted; queued jobs may create new sessions.';
|
|
21
|
+
} else if (action === 'stop-workspace') {
|
|
22
|
+
el('confirm-text').textContent = `Stop all sessions in "${params.workspace}"? Other workspaces are unaffected.`;
|
|
23
|
+
} else {
|
|
24
|
+
el('confirm-text').textContent = `Stop session ${params.sessionId.slice(0,12)}…? Active work may be interrupted.`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function actionButton(label, disabled, onClick) {
|
|
29
|
+
const button = document.createElement('button');
|
|
30
|
+
button.textContent = label;
|
|
31
|
+
button.disabled = disabled;
|
|
32
|
+
button.addEventListener('click', onClick);
|
|
33
|
+
return button;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderWorkspaces(workspaces, busy, connected) {
|
|
37
|
+
const signature = JSON.stringify(workspaces.map(w => [
|
|
38
|
+
w.name,w.initialized,w.available,w.poolSize,w.idleTabs,w.leasedTabs,w.sessions.map(s => s.session_id),
|
|
39
|
+
])) + ':' + busy + ':' + connected;
|
|
40
|
+
if (signature === lastSignature) return;
|
|
41
|
+
lastSignature = signature;
|
|
42
|
+
const target = el('workspace-list');
|
|
43
|
+
target.replaceChildren();
|
|
44
|
+
if (!workspaces.length) {
|
|
45
|
+
target.textContent = 'No configured workspaces';
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const workspace of workspaces) {
|
|
49
|
+
const row = document.createElement('section');
|
|
50
|
+
row.className = 'workspace-row';
|
|
51
|
+
const header = document.createElement('div');
|
|
52
|
+
header.className = 'workspace-title';
|
|
53
|
+
const heading = document.createElement('strong');
|
|
54
|
+
heading.textContent = workspace.name;
|
|
55
|
+
header.append(heading, actionButton('Clear group', busy || !connected || !workspace.sessions.length,
|
|
56
|
+
() => showConfirmation('stop-workspace', {workspace:workspace.name})));
|
|
57
|
+
const info = document.createElement('div');
|
|
58
|
+
info.className = 'workspace-details';
|
|
59
|
+
info.textContent = workspace.initialized
|
|
60
|
+
? `${workspace.available} available / ${workspace.poolSize} slots · ${workspace.idleTabs} idle tabs · ${workspace.leasedTabs} leased · ${workspace.sessions.length} sessions`
|
|
61
|
+
: `Unavailable · ${workspace.sessions.length} sessions · ${workspace.reason || 'workspace missing'}`;
|
|
62
|
+
row.append(header, info);
|
|
63
|
+
for (const session of workspace.sessions) {
|
|
64
|
+
const sessionRow = document.createElement('div');
|
|
65
|
+
sessionRow.className = 'session-row';
|
|
66
|
+
const details = document.createElement('div');
|
|
67
|
+
details.className = 'session-info';
|
|
68
|
+
const identifier = document.createElement('span');
|
|
69
|
+
identifier.textContent = `Session ${session.session_id.slice(0, 12)}…`;
|
|
70
|
+
details.append(identifier);
|
|
71
|
+
sessionRow.append(details, actionButton('Stop', busy || !connected,
|
|
72
|
+
() => showConfirmation('stop', {sessionId:session.session_id})));
|
|
73
|
+
row.append(sessionRow);
|
|
74
|
+
}
|
|
75
|
+
target.append(row);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function refresh() {
|
|
80
|
+
if (refreshRunning) return;
|
|
81
|
+
refreshRunning = true;
|
|
82
|
+
try {
|
|
83
|
+
const state = await rpc('workspace.control.status');
|
|
84
|
+
el('available').textContent = state.connected ? state.totalAvailable ?? '—' : '—';
|
|
85
|
+
el('sessions').textContent = state.connected ? state.totalSessions : '—';
|
|
86
|
+
el('daemon').textContent = state.connected ? 'Online' : 'Offline';
|
|
87
|
+
const busy = Boolean(state.request);
|
|
88
|
+
renderWorkspaces(state.workspaces || [], busy, state.connected);
|
|
89
|
+
el('clear').disabled = !state.connected || !state.sessionCount || busy;
|
|
90
|
+
el('confirm').disabled = !state.connected || busy;
|
|
91
|
+
if (busy) {
|
|
92
|
+
el('message').textContent = 'Stopping sessions…';
|
|
93
|
+
} else if (state.lastResult) {
|
|
94
|
+
el('message').textContent = state.lastResult.ok
|
|
95
|
+
? `Last action: stopped ${state.lastResult.stoppedCount} session(s).`
|
|
96
|
+
: `Last action failed: ${state.lastResult.error}`;
|
|
97
|
+
requestId = null;
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
el('clear').disabled = true;
|
|
101
|
+
el('confirm').disabled = true;
|
|
102
|
+
el('daemon').textContent = 'Offline';
|
|
103
|
+
el('available').textContent = '—';
|
|
104
|
+
el('sessions').textContent = '—';
|
|
105
|
+
el('message').textContent = error.message;
|
|
106
|
+
} finally {
|
|
107
|
+
refreshRunning = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
el('clear').addEventListener('click', () => showConfirmation('stop-all'));
|
|
112
|
+
el('cancel').addEventListener('click', () => {
|
|
113
|
+
confirming = false;
|
|
114
|
+
el('confirmation').hidden = true;
|
|
115
|
+
el('clear').hidden = false;
|
|
116
|
+
refresh();
|
|
117
|
+
});
|
|
118
|
+
el('confirm').addEventListener('click', async () => {
|
|
119
|
+
if (!confirming) return;
|
|
120
|
+
el('confirm').disabled = true;
|
|
121
|
+
try {
|
|
122
|
+
const result = await rpc('workspace.control.requestStop', chosen);
|
|
123
|
+
requestId = result.id;
|
|
124
|
+
el('message').textContent = 'Waiting for WebHelm daemon…';
|
|
125
|
+
} catch (error) {
|
|
126
|
+
el('message').textContent = error.message;
|
|
127
|
+
} finally {
|
|
128
|
+
confirming = false;
|
|
129
|
+
el('confirmation').hidden = true;
|
|
130
|
+
el('clear').hidden = false;
|
|
131
|
+
refresh();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
refresh();
|
|
136
|
+
setInterval(refresh, 1500);
|