webhelm 1.0.6 → 1.0.8
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 +2 -2
- package/runtime/extension/control-channel.mjs +120 -0
- package/runtime/extension/helm.html +124 -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 +57 -17
- package/runtime/extension/workspace-page.mjs +26 -4
- package/runtime/extension/workspace-summary.mjs +35 -0
- package/runtime/extension/workspace.html +8 -5
- package/runtime/extension-manager.mjs +23 -5
- package/runtime/node-daemon.mjs +93 -19
- package/runtime/session-control.mjs +13 -0
- package/runtime/session-lifecycle.mjs +7 -4
|
@@ -1,54 +1,97 @@
|
|
|
1
1
|
import { WorkspaceManager } from "./workspace-manager.mjs";
|
|
2
|
+
import { ControlChannel } from "./control-channel.mjs";
|
|
3
|
+
import { summarizeWorkspaces } from "./workspace-summary.mjs";
|
|
2
4
|
|
|
3
5
|
const manager = new WorkspaceManager(chrome);
|
|
6
|
+
const control = new ControlChannel(chrome);
|
|
4
7
|
const closingWindowIds = new Set();
|
|
8
|
+
let badgeTimer = null;
|
|
9
|
+
|
|
10
|
+
async function refreshBadge() {
|
|
11
|
+
const state = await control.status();
|
|
12
|
+
const summary = summarizeWorkspaces((await manager.list()).workspaces, state.sessions);
|
|
13
|
+
const available = summary.totalAvailable;
|
|
14
|
+
await chrome.action.setBadgeText({
|
|
15
|
+
text: !state.connected ? "!" : available === null ? "?" : available > 99 ? "99+" : String(available),
|
|
16
|
+
});
|
|
17
|
+
await chrome.action.setBadgeBackgroundColor({
|
|
18
|
+
color: !state.connected || available === null ? "#667085"
|
|
19
|
+
: available === 0 ? "#A43145" : "#345FD5",
|
|
20
|
+
});
|
|
21
|
+
await chrome.action.setTitle({title: state.connected
|
|
22
|
+
? `WebHelm — ${available === null ? "unknown" : available} available slot(s) across ${summary.workspaces.length} workspace(s)`
|
|
23
|
+
: "WebHelm — daemon offline or status stale"});
|
|
24
|
+
}
|
|
5
25
|
|
|
26
|
+
function scheduleBadge() {
|
|
27
|
+
if (badgeTimer !== null) clearTimeout(badgeTimer);
|
|
28
|
+
badgeTimer = setTimeout(() => {
|
|
29
|
+
badgeTimer = null;
|
|
30
|
+
refreshBadge().catch(error => console.warn("WebHelm badge update failed", error));
|
|
31
|
+
}, 100);
|
|
32
|
+
}
|
|
6
33
|
|
|
7
|
-
|
|
8
|
-
|
|
34
|
+
const BADGE_ALARM = "webhelm.badge-refresh";
|
|
35
|
+
async function scheduleBadgeAlarm() {
|
|
36
|
+
await chrome.alarms.create(BADGE_ALARM, {periodInMinutes: 0.5});
|
|
37
|
+
}
|
|
38
|
+
chrome.alarms.onAlarm.addListener(alarm => {
|
|
39
|
+
if (alarm.name === BADGE_ALARM) scheduleBadge();
|
|
9
40
|
});
|
|
41
|
+
scheduleBadgeAlarm().catch(error => console.warn("WebHelm badge alarm failed", error));
|
|
10
42
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
await chrome.contextMenus.remove(RELEASE_ALL_MENU_ID);
|
|
16
|
-
} catch {
|
|
17
|
-
// Menu does not exist yet.
|
|
18
|
-
}
|
|
19
|
-
chrome.contextMenus.create({
|
|
20
|
-
id: RELEASE_ALL_MENU_ID,
|
|
21
|
-
title: "Release all tabs",
|
|
22
|
-
contexts: ["action"],
|
|
23
|
-
});
|
|
43
|
+
async function removeLegacyContextMenu() {
|
|
44
|
+
// Legacy release-all operated on extension tabs but did not stop Node Sessions.
|
|
45
|
+
try { await chrome.contextMenus.remove("browser-workspace.release-all"); }
|
|
46
|
+
catch { /* already absent */ }
|
|
24
47
|
}
|
|
25
48
|
|
|
49
|
+
manager.restoreConfiguredWorkspaces()
|
|
50
|
+
.then(scheduleBadge)
|
|
51
|
+
.catch(error => console.warn("WebHelm failed to restore configured workspaces", error));
|
|
52
|
+
removeLegacyContextMenu().catch(() => {});
|
|
26
53
|
chrome.runtime.onInstalled.addListener(() => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
54
|
+
removeLegacyContextMenu().catch(() => {});
|
|
55
|
+
scheduleBadge();
|
|
30
56
|
});
|
|
31
|
-
|
|
32
57
|
chrome.runtime.onStartup.addListener(() => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
ensureContextMenus().catch((error) => {
|
|
39
|
-
console.warn("Browser Workspace failed to initialize extension menu", error);
|
|
58
|
+
removeLegacyContextMenu().catch(() => {});
|
|
59
|
+
scheduleBadge();
|
|
40
60
|
});
|
|
41
61
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
62
|
+
async function controlRpc(method, args) {
|
|
63
|
+
switch (method) {
|
|
64
|
+
case "workspace.control.status": {
|
|
65
|
+
const state = await control.status();
|
|
66
|
+
const summary = summarizeWorkspaces((await manager.list()).workspaces, state.sessions);
|
|
67
|
+
return {...state,...summary};
|
|
68
|
+
}
|
|
69
|
+
case "workspace.control.requestStop":
|
|
70
|
+
return control.requestStop(args.action, args.sessionId, args.workspace);
|
|
71
|
+
case "workspace.control.heartbeat":
|
|
72
|
+
return control.heartbeat(args.sessions);
|
|
73
|
+
case "workspace.control.poll": {
|
|
74
|
+
const result = await control.poll(args.sessions);
|
|
75
|
+
scheduleBadge();
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
case "workspace.control.complete":
|
|
79
|
+
return control.complete(args.id, args.response);
|
|
80
|
+
default:
|
|
81
|
+
throw new Error("Unknown workspace control method");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
48
84
|
|
|
49
85
|
globalThis.browserWorkspaceManagerRpc = async (request) => {
|
|
50
86
|
try {
|
|
51
|
-
|
|
87
|
+
const method = String(request?.method || "");
|
|
88
|
+
const result = method.startsWith("workspace.control.")
|
|
89
|
+
? await controlRpc(method, request.args || {})
|
|
90
|
+
: await manager.rpc(request);
|
|
91
|
+
if (["workspace.acquireIdentity", "workspace.acquire", "workspace.release",
|
|
92
|
+
"workspace.releaseAll", "workspace.resize", "workspace.ensure",
|
|
93
|
+
"workspace.delete", "workspace.create"].includes(method)) scheduleBadge();
|
|
94
|
+
return {ok: true, result};
|
|
52
95
|
} catch (error) {
|
|
53
96
|
return {
|
|
54
97
|
ok: false,
|
|
@@ -60,45 +103,53 @@ globalThis.browserWorkspaceManagerRpc = async (request) => {
|
|
|
60
103
|
}
|
|
61
104
|
};
|
|
62
105
|
|
|
63
|
-
chrome.runtime.onMessage.addListener((request,
|
|
106
|
+
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|
107
|
+
if (request?.method?.startsWith("workspace.control.") &&
|
|
108
|
+
(sender?.url !== chrome.runtime.getURL("popup.html") ||
|
|
109
|
+
!["workspace.control.requestStop", "workspace.control.status"].includes(request.method))) {
|
|
110
|
+
sendResponse({ok:false,error:{message:"WebHelm control actions require the toolbar popup"}});
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
64
113
|
globalThis.browserWorkspaceManagerRpc(request).then(sendResponse);
|
|
65
114
|
return true;
|
|
66
115
|
});
|
|
67
116
|
|
|
68
117
|
chrome.tabs.onCreated.addListener((tab) => {
|
|
69
|
-
manager.inheritWorkspaceForCreatedTab(tab).catch((error) => {
|
|
70
|
-
console.warn("
|
|
118
|
+
manager.inheritWorkspaceForCreatedTab(tab).then(scheduleBadge).catch((error) => {
|
|
119
|
+
console.warn("WebHelm failed to inherit child tab group", error);
|
|
71
120
|
});
|
|
72
121
|
|
|
73
122
|
setTimeout(() => {
|
|
74
|
-
manager.reconcileCreatedTab(tab.id).catch((error) => {
|
|
123
|
+
manager.reconcileCreatedTab(tab.id).then(scheduleBadge).catch((error) => {
|
|
75
124
|
if (!/No tab with id|Invalid tab ID/i.test(String(error?.message || error))) {
|
|
76
|
-
console.warn("
|
|
125
|
+
console.warn("WebHelm failed delayed created-tab reconciliation", error);
|
|
77
126
|
}
|
|
78
127
|
});
|
|
79
128
|
}, 150);
|
|
80
129
|
});
|
|
81
130
|
|
|
82
131
|
chrome.webNavigation.onCreatedNavigationTarget.addListener((details) => {
|
|
83
|
-
manager.inheritNavigationTarget(details).catch((error) => {
|
|
84
|
-
console.warn("
|
|
132
|
+
manager.inheritNavigationTarget(details).then(scheduleBadge).catch((error) => {
|
|
133
|
+
console.warn("WebHelm failed to inherit navigation target", error);
|
|
85
134
|
});
|
|
86
135
|
});
|
|
87
136
|
|
|
88
137
|
chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
|
|
138
|
+
if (changeInfo.url || Object.prototype.hasOwnProperty.call(changeInfo, "groupId")) scheduleBadge();
|
|
89
139
|
if (!Object.prototype.hasOwnProperty.call(changeInfo, "groupId")) return;
|
|
90
|
-
manager.inheritWorkspaceForCreatedTab(tab).catch((error) => {
|
|
91
|
-
console.warn("
|
|
140
|
+
manager.inheritWorkspaceForCreatedTab(tab).then(scheduleBadge).catch((error) => {
|
|
141
|
+
console.warn("WebHelm failed to reconcile tab group ownership", error);
|
|
92
142
|
});
|
|
93
143
|
});
|
|
94
144
|
|
|
95
145
|
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
96
146
|
manager.noteActivatedTab(activeInfo).catch((error) => {
|
|
97
|
-
console.warn("
|
|
147
|
+
console.warn("WebHelm failed to record active tab context", error);
|
|
98
148
|
});
|
|
99
149
|
});
|
|
100
150
|
|
|
101
151
|
chrome.tabs.onRemoved.addListener((_tabId, removeInfo) => {
|
|
152
|
+
scheduleBadge();
|
|
102
153
|
if (!removeInfo?.isWindowClosing) return;
|
|
103
154
|
closingWindowIds.add(removeInfo.windowId);
|
|
104
155
|
setTimeout(() => closingWindowIds.delete(removeInfo.windowId), 1000);
|
|
@@ -110,8 +161,8 @@ chrome.tabGroups.onRemoved.addListener((group) => {
|
|
|
110
161
|
// reappear, and preserve persisted workspaces across normal window closes.
|
|
111
162
|
setTimeout(() => {
|
|
112
163
|
if (closingWindowIds.has(group.windowId)) return;
|
|
113
|
-
manager.removeDeletedGroup(group).catch((error) => {
|
|
114
|
-
console.warn("
|
|
164
|
+
manager.removeDeletedGroup(group).then(scheduleBadge).catch((error) => {
|
|
165
|
+
console.warn("WebHelm failed to remove deleted workspace", error);
|
|
115
166
|
});
|
|
116
167
|
}, 100);
|
|
117
168
|
});
|
|
@@ -18,8 +18,8 @@ export function normalizePoolSize(value, fallback = 8) {
|
|
|
18
18
|
return number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function queryString(workspace
|
|
22
|
-
return new URLSearchParams({ workspace, role: "idle"
|
|
21
|
+
function queryString(workspace) {
|
|
22
|
+
return new URLSearchParams({ workspace, role: "idle" }).toString();
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export class WorkspaceManager {
|
|
@@ -41,32 +41,33 @@ export class WorkspaceManager {
|
|
|
41
41
|
if (Number.isInteger(id)) this.ownedTabIds.add(id);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
idleUrl(workspace
|
|
45
|
-
return this.chrome.runtime.getURL(`
|
|
44
|
+
idleUrl(workspace) {
|
|
45
|
+
return this.chrome.runtime.getURL(`helm.html?${queryString(workspace)}`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
identityTitle(tabId) {
|
|
49
|
-
return `
|
|
49
|
+
return `__WEBHELM_TAB_${tabId}__`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
identityUrl(workspace, tabId) {
|
|
53
53
|
const query = new URLSearchParams({
|
|
54
54
|
workspace,
|
|
55
55
|
role: "lease",
|
|
56
|
-
|
|
56
|
+
tabId: String(tabId),
|
|
57
57
|
}).toString();
|
|
58
|
-
return this.chrome.runtime.getURL(`
|
|
58
|
+
return this.chrome.runtime.getURL(`helm.html?${query}`);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
parseManagedUrl(url) {
|
|
62
62
|
try {
|
|
63
63
|
const parsed = new URL(url);
|
|
64
|
-
const own = new URL(this.chrome.runtime.getURL("
|
|
65
|
-
if (parsed.origin !== own.origin ||
|
|
64
|
+
const own = new URL(this.chrome.runtime.getURL("helm.html"));
|
|
65
|
+
if (parsed.origin !== own.origin ||
|
|
66
|
+
!["/helm.html", "/workspace.html"].includes(parsed.pathname)) return null;
|
|
66
67
|
return {
|
|
67
68
|
workspace: parsed.searchParams.get("workspace") || "",
|
|
68
69
|
role: parsed.searchParams.get("role") || "",
|
|
69
|
-
|
|
70
|
+
tabId: parsed.searchParams.get("tabId") || "",
|
|
70
71
|
};
|
|
71
72
|
} catch {
|
|
72
73
|
return null;
|
|
@@ -370,7 +371,7 @@ export class WorkspaceManager {
|
|
|
370
371
|
// Chrome groups are created by grouping a tab. Keep one seed tab so the
|
|
371
372
|
// workspace has an identity, then grow the physical pool on demand.
|
|
372
373
|
const created = [await this.chrome.tabs.create({
|
|
373
|
-
url: this.idleUrl(name
|
|
374
|
+
url: this.idleUrl(name),
|
|
374
375
|
active: false,
|
|
375
376
|
})];
|
|
376
377
|
for (const tab of created) this.markOwned(tab.id);
|
|
@@ -466,6 +467,44 @@ export class WorkspaceManager {
|
|
|
466
467
|
return { workspaces: result };
|
|
467
468
|
}
|
|
468
469
|
|
|
470
|
+
// The legacy default group is only renamed after all its tabs return idle.
|
|
471
|
+
// Preserve configured custom workspaces, old leased tabs and Chrome tab IDs.
|
|
472
|
+
async migrateDefaultWorkspace(activeLegacySessions = 0) {
|
|
473
|
+
const oldName = "Harness", newName = "WebHelm";
|
|
474
|
+
const config = await this.loadConfig();
|
|
475
|
+
if (!config.workspaces[oldName]) return {name: newName, migrated: false};
|
|
476
|
+
if (config.workspaces[newName]) return {name: newName, migrated: false, legacyPreserved: true};
|
|
477
|
+
if (activeLegacySessions > 0) return {name: oldName, deferred: "active-sessions"};
|
|
478
|
+
const group = await this.workspaceGroup(oldName);
|
|
479
|
+
const tabs = group ? await this.groupTabs(group.id) : [];
|
|
480
|
+
if (group && this.classify(oldName, tabs).leased.length)
|
|
481
|
+
return {name: oldName, deferred: "leased-tabs"};
|
|
482
|
+
|
|
483
|
+
// URL ownership and group identity change together. If navigation fails,
|
|
484
|
+
// restore both before retrying so no lease is silently adopted.
|
|
485
|
+
const updated = [];
|
|
486
|
+
try {
|
|
487
|
+
// Mark existing legacy tabs as owned before their URLs change. Chrome
|
|
488
|
+
// can emit grouping callbacks mid-migration.
|
|
489
|
+
for (const tab of tabs) this.markOwned(tab.id);
|
|
490
|
+
if (group) await this.chrome.tabGroups.update(group.id, {title: newName});
|
|
491
|
+
for (const tab of tabs) {
|
|
492
|
+
await this.chrome.tabs.update(tab.id, {url: this.idleUrl(newName), active: false});
|
|
493
|
+
updated.push(tab);
|
|
494
|
+
}
|
|
495
|
+
config.workspaces[newName] = config.workspaces[oldName];
|
|
496
|
+
delete config.workspaces[oldName];
|
|
497
|
+
await this.saveConfig(config);
|
|
498
|
+
return {name: newName, migrated: true, migratedTabs: updated.length};
|
|
499
|
+
} catch(error) {
|
|
500
|
+
if (group) await this.chrome.tabGroups.update(group.id, {title: oldName}).catch(() => {});
|
|
501
|
+
for (const tab of updated) {
|
|
502
|
+
await this.chrome.tabs.update(tab.id,{url: tab.url,active:false}).catch(() => {});
|
|
503
|
+
}
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
469
508
|
async ensure(name, defaultPoolSize = 8) {
|
|
470
509
|
const normalizedName = normalizeWorkspaceName(name);
|
|
471
510
|
const { entry } = await this.configuredWorkspace(normalizedName);
|
|
@@ -499,7 +538,7 @@ export class WorkspaceManager {
|
|
|
499
538
|
let tab = idle[0];
|
|
500
539
|
if (!tab) {
|
|
501
540
|
tab = await this.chrome.tabs.create({
|
|
502
|
-
url: this.idleUrl(normalizedName
|
|
541
|
+
url: this.idleUrl(normalizedName),
|
|
503
542
|
active: false,
|
|
504
543
|
});
|
|
505
544
|
this.markOwned(tab.id);
|
|
@@ -518,7 +557,7 @@ export class WorkspaceManager {
|
|
|
518
557
|
tabId: updated.id,
|
|
519
558
|
identityTitle,
|
|
520
559
|
identityUrl,
|
|
521
|
-
idleUrl: this.idleUrl(normalizedName
|
|
560
|
+
idleUrl: this.idleUrl(normalizedName),
|
|
522
561
|
active: Boolean(updated.active),
|
|
523
562
|
};
|
|
524
563
|
}
|
|
@@ -540,11 +579,11 @@ export class WorkspaceManager {
|
|
|
540
579
|
let tab = idle[0];
|
|
541
580
|
if (!tab) {
|
|
542
581
|
tab = await this.chrome.tabs.create({
|
|
543
|
-
url: this.idleUrl(normalizedName
|
|
582
|
+
url: this.idleUrl(normalizedName),
|
|
544
583
|
active: false,
|
|
545
584
|
});
|
|
546
585
|
// Group reconciliation can fire before the extension idle URL is visible.
|
|
547
|
-
// Mark ownership first so a legitimate
|
|
586
|
+
// Mark ownership first so a legitimate WebHelm lease is not ejected.
|
|
548
587
|
this.markOwned(tab.id);
|
|
549
588
|
await this.chrome.tabs.group({ groupId: ready.groupId, tabIds: [tab.id] });
|
|
550
589
|
tab = await this.waitForIdleTab(tab.id, normalizedName);
|
|
@@ -575,7 +614,7 @@ export class WorkspaceManager {
|
|
|
575
614
|
} else {
|
|
576
615
|
try {
|
|
577
616
|
await this.chrome.tabs.update(wanted, {
|
|
578
|
-
url: this.idleUrl(normalizedName
|
|
617
|
+
url: this.idleUrl(normalizedName),
|
|
579
618
|
active: false,
|
|
580
619
|
});
|
|
581
620
|
await this.waitForIdleTab(wanted, normalizedName);
|
|
@@ -584,7 +623,7 @@ export class WorkspaceManager {
|
|
|
584
623
|
// the extension idle page. Preserve pool capacity by creating a clean
|
|
585
624
|
// idle replacement in the same group before removing the stale tab.
|
|
586
625
|
const replacement = await this.chrome.tabs.create({
|
|
587
|
-
url: this.idleUrl(normalizedName
|
|
626
|
+
url: this.idleUrl(normalizedName),
|
|
588
627
|
active: false,
|
|
589
628
|
});
|
|
590
629
|
this.markOwned(replacement.id);
|
|
@@ -650,6 +689,7 @@ export class WorkspaceManager {
|
|
|
650
689
|
if (method === "workspace.ensure") return await this.ensure(args.name, args.poolSize);
|
|
651
690
|
if (method === "workspace.status") return await this.status(args.name);
|
|
652
691
|
if (method === "workspace.list") return await this.list();
|
|
692
|
+
if (method === "workspace.migrateDefault") return await this.migrateDefaultWorkspace(Number(args.activeLegacySessions || 0));
|
|
653
693
|
if (method === "workspace.acquire") return await this.acquire(args.name, args.url);
|
|
654
694
|
if (method === "workspace.acquireIdentity") return await this.acquireIdentity(args.name);
|
|
655
695
|
if (method === "workspace.release") return await this.release(args.name, args.tabId);
|
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
const params = new URLSearchParams(location.search);
|
|
2
|
+
const workspace = params.get("workspace") || "";
|
|
2
3
|
const role = params.get("role") || "";
|
|
3
|
-
|
|
4
|
+
// A lease carries the actual Chrome tabId for one-time CDP target discovery.
|
|
5
|
+
const tabId = params.get("tabId") || "";
|
|
6
|
+
const validTabId = /^\d+$/.test(tabId);
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
// Keep this exact lease title: WebHelm's tab identity protocol depends on it.
|
|
9
|
+
document.title = role === "lease" && validTabId
|
|
10
|
+
? `${location.pathname.endsWith("/workspace.html") ? "__BW_TAB_" : "__WEBHELM_TAB_"}${tabId}__`
|
|
11
|
+
: role === "idle" && workspace
|
|
12
|
+
? "WebHelm · Idle"
|
|
13
|
+
: "WebHelm";
|
|
14
|
+
|
|
15
|
+
const setText = (id, value) => {
|
|
16
|
+
const element = document.getElementById(id);
|
|
17
|
+
if (element) element.textContent = value;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
setText("workspace-name", workspace || "Not assigned");
|
|
21
|
+
|
|
22
|
+
if (role === "idle" && workspace) {
|
|
23
|
+
setText("status-label", "Idle · Awaiting next task");
|
|
24
|
+
setText("description", "This tab is in the WebHelm pool and will be reused automatically when a task needs it.");
|
|
25
|
+
setText("help-text", "No action required. Leave this tab open. This status describes the tab, not daemon connectivity.");
|
|
26
|
+
} else if (role === "lease" && validTabId && workspace) {
|
|
27
|
+
setText("status-label", "Reserved for a task");
|
|
28
|
+
setText("description", "WebHelm has assigned this tab to a browser session.");
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function summarizeWorkspaces(workspaces, sessions = []) {
|
|
2
|
+
if (!Array.isArray(workspaces)) throw new Error('Workspace list is invalid');
|
|
3
|
+
const configured = workspaces.map(workspace => ({
|
|
4
|
+
name: workspace.name,
|
|
5
|
+
initialized: Boolean(workspace.initialized),
|
|
6
|
+
poolSize: Number.isInteger(workspace.poolSize) ? workspace.poolSize : null,
|
|
7
|
+
idleTabs: workspace.initialized ? workspace.idle : null,
|
|
8
|
+
leasedTabs: workspace.initialized ? workspace.leased : null,
|
|
9
|
+
available: workspace.initialized
|
|
10
|
+
? Math.max(0, workspace.poolSize - workspace.leased) : null,
|
|
11
|
+
sessions: sessions.filter(session => session.workspace === workspace.name),
|
|
12
|
+
reason: workspace.reason || null,
|
|
13
|
+
}));
|
|
14
|
+
const configuredNames = new Set(configured.map(workspace => workspace.name));
|
|
15
|
+
// A running Session can outlive a deleted workspace group. Do not hide it.
|
|
16
|
+
for (const session of sessions) {
|
|
17
|
+
if (configuredNames.has(session.workspace)) continue;
|
|
18
|
+
configuredNames.add(session.workspace);
|
|
19
|
+
configured.push({
|
|
20
|
+
name: session.workspace, initialized: false, poolSize: null, idleTabs: null,
|
|
21
|
+
leasedTabs: null, available: null, sessions: sessions.filter(s => s.workspace === session.workspace),
|
|
22
|
+
reason: 'not-configured',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const known = configured.filter(workspace => workspace.initialized && workspace.available !== null);
|
|
26
|
+
return {
|
|
27
|
+
workspaces: configured,
|
|
28
|
+
totalAvailable: configured.length && known.length === configured.length
|
|
29
|
+
? known.reduce((total, workspace) => total + workspace.available, 0) : null,
|
|
30
|
+
totalPhysicalIdle: known.length === configured.length
|
|
31
|
+
? known.reduce((total, workspace) => total + workspace.idleTabs, 0) : null,
|
|
32
|
+
totalSessions: sessions.length,
|
|
33
|
+
partial: known.length !== configured.length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
2
4
|
<meta charset="utf-8">
|
|
3
|
-
<title>
|
|
5
|
+
<title>WebHelm · Legacy Tab</title>
|
|
4
6
|
<script type="module" src="workspace-page.mjs"></script>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<p>This is a legacy WebHelm tab. It will return to the managed pool when its session ends.</p>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
|
@@ -8,7 +8,11 @@ import {LOCAL_ROOT} from './config-paths.mjs';
|
|
|
8
8
|
export const EXTENSION_ID='kkkpgmhpoeceojalkpfdidffdfnlgdog';
|
|
9
9
|
export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(LOCAL_ROOT,'extension'));
|
|
10
10
|
const SOURCE=fileURLToPath(new URL('./extension/',import.meta.url));
|
|
11
|
-
const FILES=[
|
|
11
|
+
const FILES=[
|
|
12
|
+
'manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','helm.html','workspace-page.mjs',
|
|
13
|
+
'control-channel.mjs','workspace-summary.mjs','popup.html','popup.mjs',
|
|
14
|
+
...[16,32,48,128].map(size => `icons/webhelm-${size}.png`),
|
|
15
|
+
];
|
|
12
16
|
|
|
13
17
|
function manifest(dir){
|
|
14
18
|
const data=JSON.parse(fs.readFileSync(path.join(dir,'manifest.json'),'utf8'));
|
|
@@ -19,9 +23,20 @@ function manifest(dir){
|
|
|
19
23
|
return data;
|
|
20
24
|
}
|
|
21
25
|
export const extensionVersion=manifest(SOURCE).version;
|
|
26
|
+
function canonicalPath(value) {
|
|
27
|
+
try { return fs.realpathSync(value || ''); }
|
|
28
|
+
catch { return path.resolve(value || ''); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function listRelativeFiles(dir, prefix='') {
|
|
32
|
+
return fs.readdirSync(path.join(dir,prefix),{withFileTypes:true}).flatMap(entry => {
|
|
33
|
+
const relative=path.join(prefix,entry.name);
|
|
34
|
+
return entry.isDirectory() ? listRelativeFiles(dir,relative) : [relative];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
22
37
|
function identical(dir){
|
|
23
38
|
return fs.existsSync(path.join(dir,'manifest.json'))&&
|
|
24
|
-
JSON.stringify(
|
|
39
|
+
JSON.stringify(listRelativeFiles(dir).sort())===JSON.stringify([...FILES].sort())&&
|
|
25
40
|
FILES.every(name=>fs.readFileSync(path.join(dir,name)).equals(fs.readFileSync(path.join(SOURCE,name))));
|
|
26
41
|
}
|
|
27
42
|
// Actions checkout contains this self-managed directory, but Git must never publish it.
|
|
@@ -44,7 +59,10 @@ export function stageExtension(dir=EXTENSION_HOME){
|
|
|
44
59
|
const stage=fs.mkdtempSync(path.join(root,'.webhelm-extension-stage-'));
|
|
45
60
|
let backup;
|
|
46
61
|
try {
|
|
47
|
-
for(const filename of FILES)
|
|
62
|
+
for(const filename of FILES){
|
|
63
|
+
fs.mkdirSync(path.dirname(path.join(stage,filename)),{recursive:true});
|
|
64
|
+
fs.copyFileSync(path.join(SOURCE,filename),path.join(stage,filename));
|
|
65
|
+
}
|
|
48
66
|
if(!identical(stage))throw Error('WebHelm extension staged content mismatch');
|
|
49
67
|
if(fs.existsSync(target)){
|
|
50
68
|
backup=path.join(root,`.webhelm-extension-backup-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
@@ -67,7 +85,7 @@ export async function ensureExtensionLoaded(browser, {skip=false,extensionDir=EX
|
|
|
67
85
|
try{
|
|
68
86
|
const state=await browser.send('Extensions.getExtensions');
|
|
69
87
|
modern=true;installed=state.extensions?.find(e=>e.id===EXTENSION_ID);
|
|
70
|
-
if(!installed || !installed.enabled || installed.version!==version ||
|
|
88
|
+
if(!installed || !installed.enabled || installed.version!==version || canonicalPath(installed.path)!==canonicalPath(dir)){
|
|
71
89
|
if(!allowUpdate)throw Error('Extension reload deferred: active browser Sessions must finish');
|
|
72
90
|
const loaded=await browser.send('Extensions.loadUnpacked',{path:dir});
|
|
73
91
|
if(loaded.id!==EXTENSION_ID)throw Error('Chrome loaded an unexpected extension ID');
|
|
@@ -83,7 +101,7 @@ export async function ensureExtensionLoaded(browser, {skip=false,extensionDir=EX
|
|
|
83
101
|
let target=targetInfos.find(t=>t.type==='service_worker'&&t.url.startsWith(prefix));
|
|
84
102
|
let temporary=false;
|
|
85
103
|
if(!target){
|
|
86
|
-
const created=await browser.send('Target.createTarget',{url:`${prefix}
|
|
104
|
+
const created=await browser.send('Target.createTarget',{url:`${prefix}helm.html?webhelm_wakeup=1`,background:true});
|
|
87
105
|
target={targetId:created.targetId};temporary=true;
|
|
88
106
|
}
|
|
89
107
|
let attached;
|