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
|
@@ -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
|
});
|
|
@@ -42,11 +42,11 @@ export class WorkspaceManager {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
idleUrl(workspace, slot) {
|
|
45
|
-
return this.chrome.runtime.getURL(`
|
|
45
|
+
return this.chrome.runtime.getURL(`helm.html?${queryString(workspace, slot)}`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
identityTitle(tabId) {
|
|
49
|
-
return `
|
|
49
|
+
return `__WEBHELM_TAB_${tabId}__`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
identityUrl(workspace, tabId) {
|
|
@@ -55,14 +55,15 @@ export class WorkspaceManager {
|
|
|
55
55
|
role: "lease",
|
|
56
56
|
slot: 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") || "",
|
|
@@ -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, tab.id), 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);
|
|
@@ -544,7 +583,7 @@ export class WorkspaceManager {
|
|
|
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);
|
|
@@ -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
|
const slot = params.get("slot") || "";
|
|
5
|
+
const validSlot = /^\d+$/.test(slot);
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
// Keep this exact lease title: WebHelm's tab identity protocol depends on it.
|
|
8
|
+
document.title = role === "lease" && validSlot
|
|
9
|
+
? `${location.pathname.endsWith("/workspace.html") ? "__BW_TAB_" : "__WEBHELM_TAB_"}${slot}__`
|
|
10
|
+
: role === "idle" && workspace
|
|
11
|
+
? "WebHelm · Idle"
|
|
12
|
+
: "WebHelm";
|
|
13
|
+
|
|
14
|
+
const setText = (id, value) => {
|
|
15
|
+
const element = document.getElementById(id);
|
|
16
|
+
if (element) element.textContent = value;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
setText("workspace-name", workspace || "Not assigned");
|
|
20
|
+
setText("slot-number", validSlot ? slot : "—");
|
|
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" && validSlot && 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>
|
|
@@ -3,11 +3,16 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import {LOCAL_ROOT} from './config-paths.mjs';
|
|
6
7
|
|
|
7
8
|
export const EXTENSION_ID='kkkpgmhpoeceojalkpfdidffdfnlgdog';
|
|
8
|
-
export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(
|
|
9
|
+
export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(LOCAL_ROOT,'extension'));
|
|
9
10
|
const SOURCE=fileURLToPath(new URL('./extension/',import.meta.url));
|
|
10
|
-
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
|
+
];
|
|
11
16
|
|
|
12
17
|
function manifest(dir){
|
|
13
18
|
const data=JSON.parse(fs.readFileSync(path.join(dir,'manifest.json'),'utf8'));
|
|
@@ -18,9 +23,20 @@ function manifest(dir){
|
|
|
18
23
|
return data;
|
|
19
24
|
}
|
|
20
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
|
+
}
|
|
21
37
|
function identical(dir){
|
|
22
38
|
return fs.existsSync(path.join(dir,'manifest.json'))&&
|
|
23
|
-
JSON.stringify(
|
|
39
|
+
JSON.stringify(listRelativeFiles(dir).sort())===JSON.stringify([...FILES].sort())&&
|
|
24
40
|
FILES.every(name=>fs.readFileSync(path.join(dir,name)).equals(fs.readFileSync(path.join(SOURCE,name))));
|
|
25
41
|
}
|
|
26
42
|
// Actions checkout contains this self-managed directory, but Git must never publish it.
|
|
@@ -43,7 +59,10 @@ export function stageExtension(dir=EXTENSION_HOME){
|
|
|
43
59
|
const stage=fs.mkdtempSync(path.join(root,'.webhelm-extension-stage-'));
|
|
44
60
|
let backup;
|
|
45
61
|
try {
|
|
46
|
-
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
|
+
}
|
|
47
66
|
if(!identical(stage))throw Error('WebHelm extension staged content mismatch');
|
|
48
67
|
if(fs.existsSync(target)){
|
|
49
68
|
backup=path.join(root,`.webhelm-extension-backup-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
@@ -66,7 +85,7 @@ export async function ensureExtensionLoaded(browser, {skip=false,extensionDir=EX
|
|
|
66
85
|
try{
|
|
67
86
|
const state=await browser.send('Extensions.getExtensions');
|
|
68
87
|
modern=true;installed=state.extensions?.find(e=>e.id===EXTENSION_ID);
|
|
69
|
-
if(!installed || !installed.enabled || installed.version!==version ||
|
|
88
|
+
if(!installed || !installed.enabled || installed.version!==version || canonicalPath(installed.path)!==canonicalPath(dir)){
|
|
70
89
|
if(!allowUpdate)throw Error('Extension reload deferred: active browser Sessions must finish');
|
|
71
90
|
const loaded=await browser.send('Extensions.loadUnpacked',{path:dir});
|
|
72
91
|
if(loaded.id!==EXTENSION_ID)throw Error('Chrome loaded an unexpected extension ID');
|
|
@@ -82,7 +101,7 @@ export async function ensureExtensionLoaded(browser, {skip=false,extensionDir=EX
|
|
|
82
101
|
let target=targetInfos.find(t=>t.type==='service_worker'&&t.url.startsWith(prefix));
|
|
83
102
|
let temporary=false;
|
|
84
103
|
if(!target){
|
|
85
|
-
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});
|
|
86
105
|
target={targetId:created.targetId};temporary=true;
|
|
87
106
|
}
|
|
88
107
|
let attached;
|
package/runtime/login-guard.mjs
CHANGED
|
@@ -2,23 +2,57 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {execFileSync} from 'node:child_process';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
5
6
|
|
|
6
7
|
const LABEL='com.lalalic.webhelm.keepalive';
|
|
8
|
+
// launchd must never depend on npm's removable npx cache, or on the Skill
|
|
9
|
+
// launcher being present. Stage an immutable, package-backed runtime locally.
|
|
10
|
+
export function stageRuntimeForLoginGuard({home=os.homedir(),source=fileURLToPath(new URL('../',import.meta.url)),yamlSource=null}={}){
|
|
11
|
+
const manifest=JSON.parse(fs.readFileSync(path.join(source,'package.json'),'utf8'));
|
|
12
|
+
if(manifest.name!=='webhelm'||!/^\d+\.\d+\.\d+$/.test(manifest.version))throw Error('Invalid WebHelm runtime package');
|
|
13
|
+
const parent=path.join(home,'.local/share/webhelm');
|
|
14
|
+
const target=path.join(parent,`runtime-${manifest.version}`);
|
|
15
|
+
const keyfiles=['package.json','bin/webhelm.mjs','runtime/cli.mjs','runtime/node-daemon.mjs','runtime/login-guard.mjs'];
|
|
16
|
+
const equal=()=>keyfiles.every(name=>fs.existsSync(path.join(target,name))&&fs.readFileSync(path.join(target,name)).equals(fs.readFileSync(path.join(source,name))))&&fs.existsSync(path.join(target,'node_modules/yaml/package.json'));
|
|
17
|
+
if(equal())return {path:target,version:manifest.version,updated:false};
|
|
18
|
+
const yamlDir=yamlSource||path.resolve(fileURLToPath(import.meta.resolve('yaml')),'../..');
|
|
19
|
+
if(!fs.existsSync(path.join(yamlDir,'package.json')))throw Error('Bundled Runtime yaml dependency missing');
|
|
20
|
+
fs.mkdirSync(parent,{recursive:true,mode:0o700});
|
|
21
|
+
const tmp=fs.mkdtempSync(path.join(parent,'.webhelm-runtime-stage-'));
|
|
22
|
+
try{
|
|
23
|
+
for(const dirname of ['bin','runtime'])fs.cpSync(path.join(source,dirname),path.join(tmp,dirname),{recursive:true});
|
|
24
|
+
fs.copyFileSync(path.join(source,'package.json'),path.join(tmp,'package.json'));
|
|
25
|
+
fs.mkdirSync(path.join(tmp,'node_modules'),{recursive:true});
|
|
26
|
+
fs.cpSync(yamlDir,path.join(tmp,'node_modules/yaml'),{recursive:true});
|
|
27
|
+
let old=null;
|
|
28
|
+
if(fs.existsSync(target)){
|
|
29
|
+
old=path.join(parent,`.webhelm-runtime-old-${process.pid}-${Date.now()}`);
|
|
30
|
+
fs.renameSync(target,old);
|
|
31
|
+
}
|
|
32
|
+
try{fs.renameSync(tmp,target);}catch(error){if(old)fs.renameSync(old,target);throw error;}
|
|
33
|
+
if(old)fs.rmSync(old,{recursive:true,force:true});
|
|
34
|
+
return {path:target,version:manifest.version,updated:true};
|
|
35
|
+
}finally{if(fs.existsSync(tmp))fs.rmSync(tmp,{recursive:true,force:true});}
|
|
36
|
+
}
|
|
37
|
+
|
|
7
38
|
const escapeXml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
8
|
-
export function loginGuardDefinition(home=os.homedir()) {
|
|
9
|
-
const cli=
|
|
39
|
+
export function loginGuardDefinition(home=os.homedir(),{cliPath=path.join(home,'.local/bin/webhelm'),nodePath=null}={}) {
|
|
40
|
+
const cli=cliPath;
|
|
41
|
+
const program=nodePath||cli;
|
|
42
|
+
const args=nodePath?`<string>${escapeXml(cli)}</string><string>ensure</string>`:'<string>ensure</string>';
|
|
10
43
|
const environment=[path.join(home,'.local/bin'),path.join(home,'.local/opt/node22/bin'),
|
|
11
44
|
'/opt/homebrew/bin','/usr/local/bin','/usr/bin','/bin'].join(':');
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
45
|
+
// Use the cached, tested npm Runtime directly to avoid registry/network
|
|
46
|
+
// dependency at login or when Chrome has just auto-updated. This must call
|
|
47
|
+
// 'ensure', not 'status': a live daemon does not imply a live Chrome.
|
|
48
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${LABEL}</string>\n<key>ProgramArguments</key><array><string>${escapeXml(program)}</string>${args}</array>\n<key>EnvironmentVariables</key><dict><key>PATH</key><string>${escapeXml(environment)}</string><key>HOME</key><string>${escapeXml(home)}</string></dict>\n<key>RunAtLoad</key><true/>\n<key>StartInterval</key><integer>30</integer>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
|
|
15
49
|
}
|
|
16
|
-
export function installLoginGuard({home=os.homedir(), platform=process.platform, apply=true}={}){
|
|
50
|
+
export function installLoginGuard({home=os.homedir(), platform=process.platform, apply=true,cliPath,nodePath}={}){
|
|
17
51
|
if(platform!=='darwin')return {status:'unsupported',label:LABEL};
|
|
18
|
-
const cli=path.join(home,'.local/bin/webhelm');
|
|
19
|
-
if(!fs.existsSync(cli))return {status:'skipped',reason:'
|
|
52
|
+
const cli=cliPath||path.join(home,'.local/bin/webhelm');
|
|
53
|
+
if(!fs.existsSync(cli))return {status:'skipped',reason:'WebHelm runtime launcher missing',label:LABEL};
|
|
20
54
|
const dest=path.join(home,'Library/LaunchAgents',`${LABEL}.plist`);
|
|
21
|
-
const content=loginGuardDefinition(home);
|
|
55
|
+
const content=loginGuardDefinition(home,{cliPath,nodePath});
|
|
22
56
|
fs.mkdirSync(path.dirname(dest),{recursive:true});
|
|
23
57
|
if(fs.existsSync(dest)){
|
|
24
58
|
const old=fs.readFileSync(dest,'utf8');
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
// close a user's Chrome windows to recover the managed browser.
|
|
3
3
|
export function createManagedChromeGuard(check, {intervalMs=15000, onError=()=>{}}={}) {
|
|
4
4
|
if(typeof check!=='function')throw TypeError('health check must be a function');
|
|
5
|
-
let timer=null,
|
|
5
|
+
let timer=null, inFlight=null, stopped=false;
|
|
6
6
|
let last={status:'not_checked'};
|
|
7
|
-
const tick=
|
|
8
|
-
if(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
const tick=()=>{
|
|
8
|
+
if(stopped)return Promise.resolve(last);
|
|
9
|
+
if(inFlight)return inFlight;
|
|
10
|
+
inFlight=(async()=>{
|
|
11
|
+
try{last={status:'healthy',...(await check())};}
|
|
12
|
+
catch(error){last={status:'degraded',error:String(error?.message||error)};onError(error);}
|
|
13
|
+
finally{inFlight=null;}
|
|
14
|
+
return last;
|
|
15
|
+
})();
|
|
16
|
+
return inFlight;
|
|
14
17
|
};
|
|
15
18
|
return {
|
|
16
19
|
tick,
|