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.
@@ -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
  });
@@ -18,8 +18,8 @@ export function normalizePoolSize(value, fallback = 8) {
18
18
  return number;
19
19
  }
20
20
 
21
- function queryString(workspace, slot) {
22
- return new URLSearchParams({ workspace, role: "idle", slot: String(slot) }).toString();
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, slot) {
45
- return this.chrome.runtime.getURL(`workspace.html?${queryString(workspace, slot)}`);
44
+ idleUrl(workspace) {
45
+ return this.chrome.runtime.getURL(`helm.html?${queryString(workspace)}`);
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) {
53
53
  const query = new URLSearchParams({
54
54
  workspace,
55
55
  role: "lease",
56
- slot: String(tabId),
56
+ tabId: 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") || "",
69
- slot: parsed.searchParams.get("slot") || "",
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, 0),
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, tabs.length),
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, updated.id),
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, tabs.length),
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 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);
@@ -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, wanted),
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, wanted),
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
- const slot = params.get("slot") || "";
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
- document.title = role === "lease" && /^\d+$/.test(slot)
6
- ? `__BW_TAB_${slot}__`
7
- : "Browser Workspace";
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>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>
@@ -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=['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
+ ];
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(fs.readdirSync(dir).sort())===JSON.stringify([...FILES].sort())&&
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)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
+ }
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 || path.resolve(installed.path||'')!==dir){
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}workspace.html?webhelm_wakeup=1`,background:true});
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;