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.
@@ -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
- manager.restoreConfiguredWorkspaces().catch((error) => {
8
- console.warn("Browser Workspace failed to restore configured workspaces", error);
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
- const RELEASE_ALL_MENU_ID = "browser-workspace.release-all";
12
-
13
- async function ensureContextMenus() {
14
- try {
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
- ensureContextMenus().catch((error) => {
28
- console.warn("Browser Workspace failed to create extension menu", error);
29
- });
54
+ removeLegacyContextMenu().catch(() => {});
55
+ scheduleBadge();
30
56
  });
31
-
32
57
  chrome.runtime.onStartup.addListener(() => {
33
- ensureContextMenus().catch((error) => {
34
- console.warn("Browser Workspace failed to create extension menu", error);
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
- chrome.contextMenus.onClicked.addListener((info) => {
43
- if (info.menuItemId !== RELEASE_ALL_MENU_ID) return;
44
- manager.releaseAll().catch((error) => {
45
- console.warn("Browser Workspace failed to release all tabs", error);
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
- return { ok: true, result: await manager.rpc(request) };
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, _sender, sendResponse) => {
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("Browser Workspace failed to inherit child tab group", error);
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("Browser Workspace failed delayed created-tab reconciliation", error);
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("Browser Workspace failed to inherit navigation target", error);
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("Browser Workspace failed to reconcile tab group ownership", error);
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("Browser Workspace failed to record active tab context", error);
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("Browser Workspace failed to remove deleted workspace", error);
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(`workspace.html?${queryString(workspace, slot)}`);
45
+ return this.chrome.runtime.getURL(`helm.html?${queryString(workspace, slot)}`);
46
46
  }
47
47
 
48
48
  identityTitle(tabId) {
49
- return `__BW_TAB_${tabId}__`;
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(`workspace.html?${query}`);
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("workspace.html"));
65
- if (parsed.origin !== own.origin || parsed.pathname !== own.pathname) return null;
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 Browser Harness lease is not ejected.
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
- document.title = role === "lease" && /^\d+$/.test(slot)
6
- ? `__BW_TAB_${slot}__`
7
- : "Browser Workspace";
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>Browser Workspace</title>
5
+ <title>WebHelm · Legacy Tab</title>
4
6
  <script type="module" src="workspace-page.mjs"></script>
5
- <style>
6
- body { font-family: system-ui, sans-serif; margin: 24px; }
7
- </style>
8
- <p>This tab belongs to a managed Browser Workspace pool.</p>
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(os.homedir(),'.local/webhelm/extension'));
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=['manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','workspace-page.mjs'];
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(fs.readdirSync(dir).sort())===JSON.stringify([...FILES].sort())&&
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)fs.copyFileSync(path.join(SOURCE,filename),path.join(stage,filename));
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 || path.resolve(installed.path||'')!==dir){
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}workspace.html?webhelm_wakeup=1`,background:true});
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;
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]));
8
- export function loginGuardDefinition(home=os.homedir()) {
9
- const cli=path.join(home,'.local/bin/webhelm');
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
- // The short-lived status check starts a missing daemon, and is a no-op if
13
- // it already owns the IPC socket. Node daemon independently repairs Chrome.
14
- 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(cli)}</string><string>status</string></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>300</integer>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
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:'webhelm Skill launcher missing',label:LABEL};
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, running=false, stopped=false;
5
+ let timer=null, inFlight=null, stopped=false;
6
6
  let last={status:'not_checked'};
7
- const tick=async()=>{
8
- if(running||stopped)return last;
9
- running=true;
10
- try{last={status:'healthy',...(await check())};}
11
- catch(error){last={status:'degraded',error:String(error?.message||error)};onError(error);}
12
- finally{running=false;}
13
- return last;
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,