webhelm 1.0.2 → 1.0.4
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 +2 -2
- package/package.json +1 -1
- package/runtime/chrome.mjs +22 -3
- package/runtime/cli.mjs +8 -3
- package/runtime/config-paths.mjs +9 -2
- package/runtime/extension/manifest.json +25 -0
- package/runtime/extension/service-worker.mjs +117 -0
- package/runtime/extension/workspace-manager.mjs +661 -0
- package/runtime/extension/workspace-page.mjs +7 -0
- package/runtime/extension/workspace.html +8 -0
- package/runtime/extension-manager.mjs +105 -0
- package/runtime/migrate.mjs +34 -1
- package/runtime/node-daemon.mjs +8 -3
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
| Repository | Contents | Updates |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| [`lalalic/webhelm`](https://github.com/lalalic/webhelm) | npm Runtime/CLI, Skill (`skills/webhelm`),
|
|
9
|
+
| [`lalalic/webhelm`](https://github.com/lalalic/webhelm) | npm Runtime/CLI (including the Daemon-managed Chrome Extension), Skill (`skills/webhelm`), tests | npm `webhelm@latest`; Skill source separately |
|
|
10
10
|
| [`lalalic/webhelm-actions`](https://github.com/lalalic/webhelm-actions) | Site manifests, ChatGPT/Z.ai/DeepSeek/XHS/etc. Actions | Git checkout at `~/.local/webhelm` |
|
|
11
11
|
|
|
12
12
|
The npm package **does not include** the Skill or site Actions. The Skill's executable uses `npm exec --yes --package=webhelm@latest -- webhelm` to avoid inherited npm lifecycle variable conflicts; its contents may update without a Runtime release.
|
|
@@ -27,7 +27,7 @@ npx --yes webhelm@latest session stop SESSION_ID
|
|
|
27
27
|
# Long-running worker managers: session heartbeat SESSION_ID every 60 seconds
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Use `npx skills add lalalic/webhelm --skill webhelm` to install agent guidance
|
|
30
|
+
Use `npx skills add lalalic/webhelm --skill webhelm` to install agent guidance. The Extension is bundled with the npm Runtime and staged by WebHelm Daemon at `~/.local/webhelm/extension`. The Skill lives **in this repo** at `skills/webhelm/SKILL.md`. No Chrome Web Store publication or manual Extension installation is required.
|
|
31
31
|
|
|
32
32
|
The Actions checkout is `~/.local/webhelm`, with site modules at `~/.local/webhelm/platforms`. Setup clones if absent, then reuses that checkout without discarding local modifications. Run `platform sync` for new commits. For a verified local Action fix, `platform publish --message "..."` tests, commits and pushes only the action changes to the separate repo.
|
|
33
33
|
|
package/package.json
CHANGED
package/runtime/chrome.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
import { spawn, execFileSync } from 'node:child_process';
|
|
5
5
|
|
|
6
|
-
import { CONFIG_ROOT, PROFILE_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
6
|
+
import { CONFIG_ROOT, PROFILE_DIR, OLD_PROFILE_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
7
|
+
import { stageExtension } from './extension-manager.mjs';
|
|
7
8
|
const ROOT = CONFIG_ROOT;
|
|
8
9
|
const PROFILE = PROFILE_DIR;
|
|
9
10
|
const STATE = path.join(ROOT, 'managed-chrome.json');
|
|
@@ -90,6 +91,24 @@ async function lockOwnership(fn) {
|
|
|
90
91
|
try {return await fn();}
|
|
91
92
|
finally {fs.closeSync(fd);try {fs.unlinkSync(LOCK);}catch{}}
|
|
92
93
|
}
|
|
94
|
+
export function chromeMajorFromVersion(versionText){
|
|
95
|
+
const match=String(versionText).match(/(?:Chrome|Chromium)\s+(?:for Testing\s+)?(\d+)\./);
|
|
96
|
+
const major=match?Number(match[1]):NaN;
|
|
97
|
+
if(!Number.isSafeInteger(major)||major<50)throw Error(`Cannot determine managed Chrome version: ${versionText}`);
|
|
98
|
+
return major;
|
|
99
|
+
}
|
|
100
|
+
function chromeMajor(chromePath){
|
|
101
|
+
return chromeMajorFromVersion(execFileSync(chromePath,['--version'],{encoding:'utf8',timeout:5000}).trim());
|
|
102
|
+
}
|
|
103
|
+
export function chromeLaunchArgs(profile,port,extensionPath,major){
|
|
104
|
+
const args=[`--user-data-dir=${profile}`,`--remote-debugging-port=${port}`];
|
|
105
|
+
// Chrome 138 can load unpacked extensions with a compatibility flag.
|
|
106
|
+
// Chrome 139+ must rely on Extensions.loadUnpacked through CDP instead:
|
|
107
|
+
// passing --load-extension can leave a stale, non-running extension entry.
|
|
108
|
+
if(major<=138)args.push('--disable-features=DisableLoadExtensionCommandLineSwitch',`--load-extension=${extensionPath}`);
|
|
109
|
+
args.push('--no-first-run','--no-default-browser-check','about:blank');
|
|
110
|
+
return args;
|
|
111
|
+
}
|
|
93
112
|
export async function ensureManagedChrome(){
|
|
94
113
|
// Explicit CDP endpoints also support hermetic mock-browser tests.
|
|
95
114
|
const specified=process.env.WEBHELM_CDP_URL;
|
|
@@ -103,10 +122,10 @@ export async function ensureManagedChrome(){
|
|
|
103
122
|
if(override)return {mode:'override',reused:true,endpoint:override,profile_dir:PROFILE};
|
|
104
123
|
const port=await choosePort();
|
|
105
124
|
const chrome=browserExecutable();
|
|
125
|
+
const extension=stageExtension();
|
|
106
126
|
const log=fs.openSync(path.join(ROOT,'managed-chrome.log'),'a');
|
|
107
127
|
fs.mkdirSync(PROFILE,{recursive:true});
|
|
108
|
-
const child=spawn(chrome,
|
|
109
|
-
'--no-first-run','--no-default-browser-check','about:blank'],
|
|
128
|
+
const child=spawn(chrome,chromeLaunchArgs(PROFILE,port,extension.path,chromeMajor(chrome)),
|
|
110
129
|
{detached:true,stdio:['ignore',log,log]});
|
|
111
130
|
child.unref();
|
|
112
131
|
fs.closeSync(log);
|
package/runtime/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
7
7
|
import { ensureDaemon, request, handle, SOCKET } from './node-daemon.mjs';
|
|
8
8
|
import { PLATFORMS, listPlatforms, platformProfile, actionPath, validateConfig } from './platforms.mjs';
|
|
9
9
|
import { ACTIONS_DIR, ensureActions, syncActions, publishActions } from './actions-repo.mjs';
|
|
10
|
-
import { migrateConfig } from './migrate.mjs';
|
|
10
|
+
import { migrateConfig, migrateChromeProfile } from './migrate.mjs';
|
|
11
11
|
|
|
12
12
|
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
const emit = value => console.log(JSON.stringify(value));
|
|
@@ -99,7 +99,12 @@ async function runPlatform(args) {
|
|
|
99
99
|
async function main(args = process.argv.slice(2)) {
|
|
100
100
|
const [group, command] = args;
|
|
101
101
|
if (!group || ['--help', '-h', 'help'].includes(group) || has(args, '--help') || has(args, '-h')) return help();
|
|
102
|
-
if (group === 'migrate')
|
|
102
|
+
if (group === 'migrate') {
|
|
103
|
+
await ensureActions(); // ~/.local/webhelm is the Actions Git checkout, never create an empty replacement.
|
|
104
|
+
const legacy=await migrateConfig();
|
|
105
|
+
const chrome=await migrateChromeProfile();
|
|
106
|
+
return emit({legacy,chrome});
|
|
107
|
+
}
|
|
103
108
|
if (group === 'setup') {
|
|
104
109
|
const repo=await ensureActions();
|
|
105
110
|
const chrome=await ensureDaemon();
|
|
@@ -107,7 +112,7 @@ async function main(args = process.argv.slice(2)) {
|
|
|
107
112
|
}
|
|
108
113
|
if (group === 'status') {
|
|
109
114
|
const daemon = await ensureDaemon();
|
|
110
|
-
return emit({version:
|
|
115
|
+
return emit({version:daemon.version, platform_dir:PLATFORMS, root:ROOT,
|
|
111
116
|
cli:fileURLToPath(import.meta.url),
|
|
112
117
|
admin_helper:process.env.WEBHELM_ADMIN_HELPER || (fs.existsSync(path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs')) ? path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs') : path.join(ROOT,'admin.mjs')),
|
|
113
118
|
managed_chrome:daemon.managed_chrome,
|
package/runtime/config-paths.mjs
CHANGED
|
@@ -4,11 +4,18 @@ import fs from 'node:fs';
|
|
|
4
4
|
|
|
5
5
|
export const CONFIG_ROOT = path.join(os.homedir(), '.config', 'webhelm');
|
|
6
6
|
export const LEGACY_ROOT = path.join(os.homedir(), '.config', 'browser-workspace');
|
|
7
|
-
export const
|
|
7
|
+
export const LOCAL_ROOT = path.join(os.homedir(), '.local', 'webhelm');
|
|
8
|
+
export const OLD_PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
|
|
9
|
+
export const PROFILE_DIR = path.join(LOCAL_ROOT, 'chrome');
|
|
8
10
|
export const RUNTIME_DIR = path.join(CONFIG_ROOT, 'runtime');
|
|
9
11
|
|
|
10
12
|
// Avoid starting a second FIFO dispatcher or empty Chrome profile against legacy data.
|
|
11
13
|
export function requireMigratedConfig() {
|
|
12
|
-
if (fs.existsSync(path.join(LEGACY_ROOT, 'chrome')) && !fs.existsSync(PROFILE_DIR))
|
|
14
|
+
if (fs.existsSync(path.join(LEGACY_ROOT, 'chrome')) && !fs.existsSync(OLD_PROFILE_DIR) && !fs.existsSync(PROFILE_DIR))
|
|
13
15
|
throw Error(`Legacy Chrome profile exists at ${LEGACY_ROOT}. Stop old Chrome and WebHelm daemon, then run webhelm migrate.`);
|
|
16
|
+
if (fs.existsSync(OLD_PROFILE_DIR) && !fs.existsSync(PROFILE_DIR))
|
|
17
|
+
throw Error(`Chrome profile is still at ${OLD_PROFILE_DIR}. Stop active Sessions, WebHelm daemon and managed Chrome, then run webhelm migrate.`);
|
|
18
|
+
if (fs.existsSync(OLD_PROFILE_DIR) && fs.existsSync(PROFILE_DIR) &&
|
|
19
|
+
fs.realpathSync(OLD_PROFILE_DIR)!==fs.realpathSync(PROFILE_DIR))
|
|
20
|
+
throw Error('Two distinct WebHelm Chrome Profiles exist; refusing to use either until reconciled.');
|
|
14
21
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Browser Workspace",
|
|
4
|
+
"version": "0.3.3",
|
|
5
|
+
"description": "Own named Chrome tab-group workspaces and lazy reusable tab pools for browser agents.",
|
|
6
|
+
"permissions": [
|
|
7
|
+
"storage",
|
|
8
|
+
"tabs",
|
|
9
|
+
"tabGroups",
|
|
10
|
+
"webNavigation",
|
|
11
|
+
"contextMenus"
|
|
12
|
+
],
|
|
13
|
+
"host_permissions": [
|
|
14
|
+
"http://*/*",
|
|
15
|
+
"https://*/*"
|
|
16
|
+
],
|
|
17
|
+
"background": {
|
|
18
|
+
"service_worker": "service-worker.mjs",
|
|
19
|
+
"type": "module"
|
|
20
|
+
},
|
|
21
|
+
"action": {
|
|
22
|
+
"default_title": "Browser Workspace"
|
|
23
|
+
},
|
|
24
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtovrqHYRxYDBSofmlykO8RDtsePhg8KV1fmq07nnhtbmYiNXAnIKrgRyLHeZv+cwm9DSgP5Ig7FDJ7Q6XrSi+Z1FpU6Y/SJkX5Rozo0oAJcX1ggoGD3vxpfHZThSSG/vRSnD8pH3XFf2nDUD7QP6GXc8dX9lN5pslRUNh1J4UCAOcu2UpPSsvpA+wPPTZr2JYzm3AevFXkQM6y0B+4exgw/Hy21ULST4nLWZihuCSu+gfWnftiTNzUPHtseRJcrpd0zT0R46d+OC+JRwXMVBpYSksDDtOXgERwMBSZf+vvskrEqYoXKN8Z9+Rrr7y9PTOvlwQioVLEDEjr9e7RIegQIDAQAB"
|
|
25
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { WorkspaceManager } from "./workspace-manager.mjs";
|
|
2
|
+
|
|
3
|
+
const manager = new WorkspaceManager(chrome);
|
|
4
|
+
const closingWindowIds = new Set();
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
manager.restoreConfiguredWorkspaces().catch((error) => {
|
|
8
|
+
console.warn("Browser Workspace failed to restore configured workspaces", error);
|
|
9
|
+
});
|
|
10
|
+
|
|
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
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
27
|
+
ensureContextMenus().catch((error) => {
|
|
28
|
+
console.warn("Browser Workspace failed to create extension menu", error);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
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);
|
|
40
|
+
});
|
|
41
|
+
|
|
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
|
+
});
|
|
48
|
+
|
|
49
|
+
globalThis.browserWorkspaceManagerRpc = async (request) => {
|
|
50
|
+
try {
|
|
51
|
+
return { ok: true, result: await manager.rpc(request) };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: {
|
|
56
|
+
message: error?.message || String(error),
|
|
57
|
+
name: error?.name || "Error",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
|
64
|
+
globalThis.browserWorkspaceManagerRpc(request).then(sendResponse);
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
chrome.tabs.onCreated.addListener((tab) => {
|
|
69
|
+
manager.inheritWorkspaceForCreatedTab(tab).catch((error) => {
|
|
70
|
+
console.warn("Browser Workspace failed to inherit child tab group", error);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
manager.reconcileCreatedTab(tab.id).catch((error) => {
|
|
75
|
+
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);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}, 150);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
chrome.webNavigation.onCreatedNavigationTarget.addListener((details) => {
|
|
83
|
+
manager.inheritNavigationTarget(details).catch((error) => {
|
|
84
|
+
console.warn("Browser Workspace failed to inherit navigation target", error);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
|
|
89
|
+
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);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
96
|
+
manager.noteActivatedTab(activeInfo).catch((error) => {
|
|
97
|
+
console.warn("Browser Workspace failed to record active tab context", error);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
chrome.tabs.onRemoved.addListener((_tabId, removeInfo) => {
|
|
102
|
+
if (!removeInfo?.isWindowClosing) return;
|
|
103
|
+
closingWindowIds.add(removeInfo.windowId);
|
|
104
|
+
setTimeout(() => closingWindowIds.delete(removeInfo.windowId), 1000);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
chrome.tabGroups.onRemoved.addListener((group) => {
|
|
108
|
+
// Chrome also emits onRemoved while moving a group between windows, and
|
|
109
|
+
// while a whole window is closing. Defer one turn so a moved group can
|
|
110
|
+
// reappear, and preserve persisted workspaces across normal window closes.
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
if (closingWindowIds.has(group.windowId)) return;
|
|
113
|
+
manager.removeDeletedGroup(group).catch((error) => {
|
|
114
|
+
console.warn("Browser Workspace failed to remove deleted workspace", error);
|
|
115
|
+
});
|
|
116
|
+
}, 100);
|
|
117
|
+
});
|
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
const STORAGE_KEY = "browserWorkspaceManager.v1";
|
|
2
|
+
const MIN_POOL_SIZE = 1;
|
|
3
|
+
const MAX_POOL_SIZE = 64;
|
|
4
|
+
const EXTENSION_MANAGEMENT_RECENCY_MS = 15_000;
|
|
5
|
+
|
|
6
|
+
export function normalizeWorkspaceName(value) {
|
|
7
|
+
const name = String(value || "").trim();
|
|
8
|
+
if (!name) throw new Error("Workspace name is required");
|
|
9
|
+
if (name.length > 80) throw new Error("Workspace name must be at most 80 characters");
|
|
10
|
+
return name;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizePoolSize(value, fallback = 8) {
|
|
14
|
+
const number = Number(value ?? fallback);
|
|
15
|
+
if (!Number.isInteger(number) || number < MIN_POOL_SIZE || number > MAX_POOL_SIZE) {
|
|
16
|
+
throw new Error(`Pool size must be an integer between ${MIN_POOL_SIZE} and ${MAX_POOL_SIZE}`);
|
|
17
|
+
}
|
|
18
|
+
return number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function queryString(workspace, slot) {
|
|
22
|
+
return new URLSearchParams({ workspace, role: "idle", slot: String(slot) }).toString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class WorkspaceManager {
|
|
26
|
+
constructor(chromeApi) {
|
|
27
|
+
this.chrome = chromeApi;
|
|
28
|
+
this.recentExtensionManagementByWindow = new Map();
|
|
29
|
+
this.ownedTabIds = new Set();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
isOwnedTab(tab) {
|
|
33
|
+
const tabId = Number(tab?.id);
|
|
34
|
+
if (Number.isInteger(tabId) && this.ownedTabIds.has(tabId)) return true;
|
|
35
|
+
const meta = this.parseManagedUrl(this.tabUrl(tab));
|
|
36
|
+
return Boolean(meta?.workspace);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
markOwned(tabId) {
|
|
40
|
+
const id = Number(tabId);
|
|
41
|
+
if (Number.isInteger(id)) this.ownedTabIds.add(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
idleUrl(workspace, slot) {
|
|
45
|
+
return this.chrome.runtime.getURL(`workspace.html?${queryString(workspace, slot)}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
identityTitle(tabId) {
|
|
49
|
+
return `__BW_TAB_${tabId}__`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
identityUrl(workspace, tabId) {
|
|
53
|
+
const query = new URLSearchParams({
|
|
54
|
+
workspace,
|
|
55
|
+
role: "lease",
|
|
56
|
+
slot: String(tabId),
|
|
57
|
+
}).toString();
|
|
58
|
+
return this.chrome.runtime.getURL(`workspace.html?${query}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
parseManagedUrl(url) {
|
|
62
|
+
try {
|
|
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;
|
|
66
|
+
return {
|
|
67
|
+
workspace: parsed.searchParams.get("workspace") || "",
|
|
68
|
+
role: parsed.searchParams.get("role") || "",
|
|
69
|
+
slot: parsed.searchParams.get("slot") || "",
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async loadConfig() {
|
|
77
|
+
const stored = (await this.chrome.storage.local.get(STORAGE_KEY))[STORAGE_KEY];
|
|
78
|
+
return stored && typeof stored === "object" ? stored : { workspaces: {} };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async saveConfig(config) {
|
|
82
|
+
await this.chrome.storage.local.set({ [STORAGE_KEY]: config });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async configuredWorkspace(name) {
|
|
86
|
+
const config = await this.loadConfig();
|
|
87
|
+
return { config, entry: config.workspaces[name] || null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async setPoolSize(name, poolSize) {
|
|
91
|
+
const { config, entry } = await this.configuredWorkspace(name);
|
|
92
|
+
config.workspaces[name] = { ...(entry || {}), poolSize };
|
|
93
|
+
await this.saveConfig(config);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async removeConfig(name) {
|
|
97
|
+
const config = await this.loadConfig();
|
|
98
|
+
delete config.workspaces[name];
|
|
99
|
+
await this.saveConfig(config);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async removeDeletedGroup(group) {
|
|
103
|
+
const name = String(group?.title || "").trim();
|
|
104
|
+
if (!name) return null;
|
|
105
|
+
|
|
106
|
+
const { entry } = await this.configuredWorkspace(name);
|
|
107
|
+
if (!entry) return null;
|
|
108
|
+
|
|
109
|
+
const remaining = await this.workspaceGroup(name);
|
|
110
|
+
if (remaining) return null;
|
|
111
|
+
|
|
112
|
+
await this.removeConfig(name);
|
|
113
|
+
return { name, deleted: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async workspaceGroup(name) {
|
|
117
|
+
const groups = await this.chrome.tabGroups.query({});
|
|
118
|
+
const matches = groups.filter((group) => group.title === name);
|
|
119
|
+
if (matches.length > 1) {
|
|
120
|
+
throw new Error(`Workspace ${name} is ambiguous: ${matches.length} groups have that title`);
|
|
121
|
+
}
|
|
122
|
+
return matches[0] || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async groupTabs(groupId) {
|
|
126
|
+
if (!Number.isInteger(groupId) || groupId < 0) return [];
|
|
127
|
+
return await this.chrome.tabs.query({ groupId });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
tabUrl(tab) {
|
|
131
|
+
return String(tab?.pendingUrl || tab?.url || "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
isExtensionManagementUrl(url) {
|
|
135
|
+
return /^chrome:\/\/extensions(?:\/|\?|#|$)/.test(String(url || ""));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
isExtensionPageUrl(url) {
|
|
139
|
+
return /^chrome-extension:\/\//.test(String(url || ""));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async noteActivatedTab(activeInfo, now = Date.now()) {
|
|
143
|
+
const tabId = Number(activeInfo?.tabId);
|
|
144
|
+
const windowId = Number(activeInfo?.windowId);
|
|
145
|
+
if (!Number.isInteger(tabId) || !Number.isInteger(windowId)) return null;
|
|
146
|
+
if (typeof this.chrome.tabs.get !== "function") return null;
|
|
147
|
+
|
|
148
|
+
const tab = await this.chrome.tabs.get(tabId);
|
|
149
|
+
if (!this.isExtensionManagementUrl(this.tabUrl(tab))) return null;
|
|
150
|
+
|
|
151
|
+
this.recentExtensionManagementByWindow.set(windowId, now);
|
|
152
|
+
return { windowId, tabId, recordedAt: now };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async uniqueConfiguredWorkspace() {
|
|
156
|
+
const config = await this.loadConfig();
|
|
157
|
+
const names = Object.keys(config.workspaces || {});
|
|
158
|
+
if (!names.length) return null;
|
|
159
|
+
|
|
160
|
+
const groups = await this.chrome.tabGroups.query({});
|
|
161
|
+
const candidates = [];
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
const matches = groups.filter((group) => group.title === name);
|
|
164
|
+
if (matches.length > 1) {
|
|
165
|
+
throw new Error(`Workspace ${name} is ambiguous: ${matches.length} groups have that title`);
|
|
166
|
+
}
|
|
167
|
+
if (matches[0]) {
|
|
168
|
+
candidates.push({
|
|
169
|
+
group: matches[0],
|
|
170
|
+
entry: config.workspaces[name],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async inheritNavigationTarget(details) {
|
|
178
|
+
const sourceTabId = Number(details?.sourceTabId);
|
|
179
|
+
const tabId = Number(details?.tabId);
|
|
180
|
+
if (!Number.isInteger(sourceTabId) || !Number.isInteger(tabId)) return null;
|
|
181
|
+
|
|
182
|
+
const [source, target] = await Promise.all([
|
|
183
|
+
this.chrome.tabs.get(sourceTabId),
|
|
184
|
+
this.chrome.tabs.get(tabId),
|
|
185
|
+
]);
|
|
186
|
+
if (!this.isOwnedTab(source)) return null;
|
|
187
|
+
|
|
188
|
+
const groupId = Number(source.groupId);
|
|
189
|
+
if (!Number.isInteger(groupId) || groupId < 0) return null;
|
|
190
|
+
|
|
191
|
+
const groups = await this.chrome.tabGroups.query({});
|
|
192
|
+
const group = groups.find((candidate) => candidate.id === groupId);
|
|
193
|
+
if (!group?.title) return null;
|
|
194
|
+
|
|
195
|
+
const { entry } = await this.configuredWorkspace(group.title);
|
|
196
|
+
if (!entry) return null;
|
|
197
|
+
|
|
198
|
+
this.markOwned(tabId);
|
|
199
|
+
await this.chrome.tabs.group({ groupId, tabIds: [tabId] });
|
|
200
|
+
|
|
201
|
+
const tabs = await this.groupTabs(groupId);
|
|
202
|
+
if (tabs.length > entry.poolSize) {
|
|
203
|
+
const { idle } = this.classify(group.title, tabs);
|
|
204
|
+
const removable = idle.find((candidate) => candidate.id !== tabId);
|
|
205
|
+
if (removable) await this.chrome.tabs.remove(removable.id);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await this.chrome.tabGroups.update(groupId, {
|
|
209
|
+
title: group.title,
|
|
210
|
+
collapsed: true,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
workspace: group.title,
|
|
215
|
+
groupId,
|
|
216
|
+
tabId,
|
|
217
|
+
sourceTabId,
|
|
218
|
+
adoptionReason: "navigation-target",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async inheritWorkspaceForCreatedTab(tab) {
|
|
223
|
+
const tabId = Number(tab?.id);
|
|
224
|
+
const openerTabId = Number(tab?.openerTabId);
|
|
225
|
+
if (!Number.isInteger(tabId)) return null;
|
|
226
|
+
|
|
227
|
+
const allTabs = await this.chrome.tabs.query({});
|
|
228
|
+
const tabsById = new Map(allTabs.map((candidate) => [candidate.id, candidate]));
|
|
229
|
+
const groups = await this.chrome.tabGroups.query({});
|
|
230
|
+
const groupsById = new Map(groups.map((candidate) => [candidate.id, candidate]));
|
|
231
|
+
|
|
232
|
+
let ancestor = Number.isInteger(openerTabId) ? tabsById.get(openerTabId) : null;
|
|
233
|
+
const visited = new Set();
|
|
234
|
+
let inherited = null;
|
|
235
|
+
let extensionManagementAncestor = null;
|
|
236
|
+
|
|
237
|
+
while (ancestor && !visited.has(ancestor.id)) {
|
|
238
|
+
visited.add(ancestor.id);
|
|
239
|
+
|
|
240
|
+
if (this.isExtensionManagementUrl(this.tabUrl(ancestor))) {
|
|
241
|
+
extensionManagementAncestor = ancestor;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (this.isOwnedTab(ancestor) && Number.isInteger(ancestor.groupId) && ancestor.groupId >= 0) {
|
|
245
|
+
const group = groupsById.get(ancestor.groupId);
|
|
246
|
+
if (group?.title) {
|
|
247
|
+
const { entry } = await this.configuredWorkspace(group.title);
|
|
248
|
+
if (entry) {
|
|
249
|
+
inherited = { ancestor, group, entry };
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const parentId = Number(ancestor.openerTabId);
|
|
256
|
+
ancestor = Number.isInteger(parentId) ? tabsById.get(parentId) : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let adoptionReason = "workspace-opener";
|
|
260
|
+
if (!inherited && this.isExtensionPageUrl(this.tabUrl(tab))) {
|
|
261
|
+
const windowId = Number(tab?.windowId);
|
|
262
|
+
const recentManagementAt = Number.isInteger(windowId)
|
|
263
|
+
? this.recentExtensionManagementByWindow.get(windowId)
|
|
264
|
+
: null;
|
|
265
|
+
const hasRecentManagementContext =
|
|
266
|
+
Number.isFinite(recentManagementAt) &&
|
|
267
|
+
Date.now() - recentManagementAt <= EXTENSION_MANAGEMENT_RECENCY_MS;
|
|
268
|
+
|
|
269
|
+
if (extensionManagementAncestor || hasRecentManagementContext) {
|
|
270
|
+
const candidate = await this.uniqueConfiguredWorkspace();
|
|
271
|
+
if (candidate) {
|
|
272
|
+
inherited = {
|
|
273
|
+
ancestor: extensionManagementAncestor,
|
|
274
|
+
group: candidate.group,
|
|
275
|
+
entry: candidate.entry,
|
|
276
|
+
};
|
|
277
|
+
adoptionReason = extensionManagementAncestor
|
|
278
|
+
? "extension-management-opener"
|
|
279
|
+
: "recent-extension-management";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!inherited) {
|
|
285
|
+
if (this.isOwnedTab(tab)) return {
|
|
286
|
+
tabId,
|
|
287
|
+
owned: true,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const groupId = Number(tab?.groupId);
|
|
291
|
+
const group = Number.isInteger(groupId) && groupId >= 0
|
|
292
|
+
? groupsById.get(groupId)
|
|
293
|
+
: null;
|
|
294
|
+
if (group?.title) {
|
|
295
|
+
const { entry } = await this.configuredWorkspace(group.title);
|
|
296
|
+
const meta = this.parseManagedUrl(this.tabUrl(tab));
|
|
297
|
+
if (entry && meta?.workspace !== group.title) {
|
|
298
|
+
await this.chrome.tabs.ungroup([tabId]);
|
|
299
|
+
return {
|
|
300
|
+
workspace: group.title,
|
|
301
|
+
groupId,
|
|
302
|
+
tabId,
|
|
303
|
+
releasedUnexpected: true,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const { ancestor: workspaceAncestor, group, entry } = inherited;
|
|
311
|
+
this.markOwned(tabId);
|
|
312
|
+
await this.chrome.tabs.group({ groupId: group.id, tabIds: [tabId] });
|
|
313
|
+
|
|
314
|
+
const tabs = await this.groupTabs(group.id);
|
|
315
|
+
if (tabs.length > entry.poolSize) {
|
|
316
|
+
const { idle } = this.classify(group.title, tabs);
|
|
317
|
+
const removable = idle.find((candidate) => candidate.id !== tabId);
|
|
318
|
+
if (removable) await this.chrome.tabs.remove(removable.id);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await this.chrome.tabGroups.update(group.id, {
|
|
322
|
+
title: group.title,
|
|
323
|
+
collapsed: true,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const result = {
|
|
327
|
+
workspace: group.title,
|
|
328
|
+
groupId: group.id,
|
|
329
|
+
tabId,
|
|
330
|
+
inheritedFromTabId: Number.isInteger(openerTabId) ? openerTabId : null,
|
|
331
|
+
workspaceAncestorTabId: workspaceAncestor?.id ?? null,
|
|
332
|
+
};
|
|
333
|
+
if (adoptionReason !== "workspace-opener") result.adoptionReason = adoptionReason;
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async reconcileCreatedTab(tabId) {
|
|
338
|
+
const id = Number(tabId);
|
|
339
|
+
if (!Number.isInteger(id)) return null;
|
|
340
|
+
const tab = await this.chrome.tabs.get(id);
|
|
341
|
+
return await this.inheritWorkspaceForCreatedTab(tab);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
classify(name, tabs) {
|
|
345
|
+
const idle = [];
|
|
346
|
+
const leased = [];
|
|
347
|
+
for (const tab of tabs) {
|
|
348
|
+
const meta = this.parseManagedUrl(tab.url || "");
|
|
349
|
+
if (meta?.workspace === name && ["idle", "marker"].includes(meta.role)) idle.push(tab);
|
|
350
|
+
else leased.push(tab);
|
|
351
|
+
}
|
|
352
|
+
return { idle, leased };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async waitForIdleTab(tabId, workspace, timeoutMs = 3000) {
|
|
356
|
+
const deadline = Date.now() + timeoutMs;
|
|
357
|
+
for (;;) {
|
|
358
|
+
const tabs = await this.chrome.tabs.query({});
|
|
359
|
+
const tab = tabs.find((candidate) => candidate.id === tabId);
|
|
360
|
+
const meta = this.parseManagedUrl(tab?.url || "");
|
|
361
|
+
if (tab && meta?.workspace === workspace && meta.role === "idle") return tab;
|
|
362
|
+
if (Date.now() >= deadline) {
|
|
363
|
+
throw new Error(`Timed out waiting for workspace ${workspace} idle tab ${tabId}`);
|
|
364
|
+
}
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async createGroup(name, poolSize) {
|
|
370
|
+
// Chrome groups are created by grouping a tab. Keep one seed tab so the
|
|
371
|
+
// workspace has an identity, then grow the physical pool on demand.
|
|
372
|
+
const created = [await this.chrome.tabs.create({
|
|
373
|
+
url: this.idleUrl(name, 0),
|
|
374
|
+
active: false,
|
|
375
|
+
})];
|
|
376
|
+
for (const tab of created) this.markOwned(tab.id);
|
|
377
|
+
const groupId = await this.chrome.tabs.group({ tabIds: created.map((tab) => tab.id) });
|
|
378
|
+
await this.chrome.tabGroups.update(groupId, { title: name, collapsed: true });
|
|
379
|
+
for (const tab of created) await this.waitForIdleTab(tab.id, name);
|
|
380
|
+
return await this.status(name);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async ensureWorkspace(name, poolSize = null) {
|
|
384
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
385
|
+
const { entry } = await this.configuredWorkspace(normalizedName);
|
|
386
|
+
const wanted = normalizePoolSize(poolSize, entry?.poolSize ?? 8);
|
|
387
|
+
if (!entry || entry.poolSize !== wanted) await this.setPoolSize(normalizedName, wanted);
|
|
388
|
+
|
|
389
|
+
const group = await this.workspaceGroup(normalizedName);
|
|
390
|
+
if (!group) return await this.createGroup(normalizedName, wanted);
|
|
391
|
+
|
|
392
|
+
await this.chrome.tabGroups.update(group.id, { title: normalizedName, collapsed: true });
|
|
393
|
+
await this.reconcilePool(normalizedName, group.id, wanted);
|
|
394
|
+
return await this.status(normalizedName);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async reconcilePool(name, groupId, poolSize) {
|
|
398
|
+
const tabs = await this.groupTabs(groupId);
|
|
399
|
+
const { idle } = this.classify(name, tabs);
|
|
400
|
+
|
|
401
|
+
// poolSize is a maximum concurrent capacity, not a target tab count.
|
|
402
|
+
// Only remove surplus idle tabs when shrinking; never refill a lazy pool.
|
|
403
|
+
if (tabs.length > poolSize && idle.length) {
|
|
404
|
+
const removable = Math.min(idle.length, tabs.length - poolSize);
|
|
405
|
+
await this.chrome.tabs.remove(idle.slice(0, removable).map((tab) => tab.id));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
await this.chrome.tabGroups.update(groupId, { title: name, collapsed: true });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async status(name) {
|
|
412
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
413
|
+
const { entry } = await this.configuredWorkspace(normalizedName);
|
|
414
|
+
if (!entry) return { name: normalizedName, initialized: false };
|
|
415
|
+
|
|
416
|
+
const group = await this.workspaceGroup(normalizedName);
|
|
417
|
+
if (!group) {
|
|
418
|
+
return {
|
|
419
|
+
name: normalizedName,
|
|
420
|
+
initialized: false,
|
|
421
|
+
poolSize: entry.poolSize,
|
|
422
|
+
reason: "group-missing",
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const tabs = await this.groupTabs(group.id);
|
|
427
|
+
const { idle, leased } = this.classify(normalizedName, tabs);
|
|
428
|
+
return {
|
|
429
|
+
name: normalizedName,
|
|
430
|
+
initialized: true,
|
|
431
|
+
groupId: group.id,
|
|
432
|
+
poolSize: entry.poolSize,
|
|
433
|
+
maxCapacity: entry.poolSize,
|
|
434
|
+
physicalTabs: tabs.length,
|
|
435
|
+
idle: idle.length,
|
|
436
|
+
leased: leased.length,
|
|
437
|
+
tabIds: tabs.map((tab) => tab.id),
|
|
438
|
+
idleTabIds: idle.map((tab) => tab.id),
|
|
439
|
+
leasedTabIds: leased.map((tab) => tab.id),
|
|
440
|
+
tabs: tabs.map((tab) => ({
|
|
441
|
+
tabId: tab.id,
|
|
442
|
+
groupId: tab.groupId,
|
|
443
|
+
title: tab.title || "",
|
|
444
|
+
url: tab.url || "",
|
|
445
|
+
active: Boolean(tab.active),
|
|
446
|
+
})),
|
|
447
|
+
collapsed: Boolean(group.collapsed),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async restoreConfiguredWorkspaces() {
|
|
452
|
+
const config = await this.loadConfig();
|
|
453
|
+
const restored = [];
|
|
454
|
+
for (const name of Object.keys(config.workspaces || {}).sort()) {
|
|
455
|
+
restored.push(await this.ensureWorkspace(name));
|
|
456
|
+
}
|
|
457
|
+
return { workspaces: restored };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async list() {
|
|
461
|
+
const config = await this.loadConfig();
|
|
462
|
+
const result = [];
|
|
463
|
+
for (const name of Object.keys(config.workspaces).sort()) {
|
|
464
|
+
result.push(await this.status(name));
|
|
465
|
+
}
|
|
466
|
+
return { workspaces: result };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async ensure(name, defaultPoolSize = 8) {
|
|
470
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
471
|
+
const { entry } = await this.configuredWorkspace(normalizedName);
|
|
472
|
+
if (entry) return await this.ensureWorkspace(normalizedName);
|
|
473
|
+
return await this.ensureWorkspace(
|
|
474
|
+
normalizedName,
|
|
475
|
+
normalizePoolSize(defaultPoolSize),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async create(name, poolSize = 8) {
|
|
480
|
+
return await this.ensureWorkspace(name, normalizePoolSize(poolSize));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async resize(name, poolSize) {
|
|
484
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
485
|
+
const wanted = normalizePoolSize(poolSize);
|
|
486
|
+
await this.setPoolSize(normalizedName, wanted);
|
|
487
|
+
return await this.ensureWorkspace(normalizedName, wanted);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async acquireIdentity(name) {
|
|
491
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
492
|
+
const ready = await this.ensureWorkspace(normalizedName);
|
|
493
|
+
const tabs = await this.groupTabs(ready.groupId);
|
|
494
|
+
const { idle, leased } = this.classify(normalizedName, tabs);
|
|
495
|
+
if (!idle.length && leased.length >= ready.poolSize) {
|
|
496
|
+
throw new Error(`Workspace ${normalizedName} pool is exhausted at size ${ready.poolSize}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let tab = idle[0];
|
|
500
|
+
if (!tab) {
|
|
501
|
+
tab = await this.chrome.tabs.create({
|
|
502
|
+
url: this.idleUrl(normalizedName, tabs.length),
|
|
503
|
+
active: false,
|
|
504
|
+
});
|
|
505
|
+
this.markOwned(tab.id);
|
|
506
|
+
await this.chrome.tabs.group({ groupId: ready.groupId, tabIds: [tab.id] });
|
|
507
|
+
tab = await this.waitForIdleTab(tab.id, normalizedName);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
this.markOwned(tab.id);
|
|
511
|
+
const identityTitle = this.identityTitle(tab.id);
|
|
512
|
+
const identityUrl = this.identityUrl(normalizedName, tab.id);
|
|
513
|
+
const updated = await this.chrome.tabs.update(tab.id, { url: identityUrl, active: false });
|
|
514
|
+
await this.chrome.tabGroups.update(ready.groupId, { title: normalizedName, collapsed: true });
|
|
515
|
+
return {
|
|
516
|
+
workspace: normalizedName,
|
|
517
|
+
groupId: ready.groupId,
|
|
518
|
+
tabId: updated.id,
|
|
519
|
+
identityTitle,
|
|
520
|
+
identityUrl,
|
|
521
|
+
idleUrl: this.idleUrl(normalizedName, updated.id),
|
|
522
|
+
active: Boolean(updated.active),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async acquire(name, url) {
|
|
527
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
528
|
+
const parsed = new URL(String(url || ""));
|
|
529
|
+
if (!["http:", "https:", "chrome-extension:"].includes(parsed.protocol)) {
|
|
530
|
+
throw new Error("Workspace acquire requires an http(s) or chrome-extension URL");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const ready = await this.ensureWorkspace(normalizedName);
|
|
534
|
+
const tabs = await this.groupTabs(ready.groupId);
|
|
535
|
+
const { idle, leased } = this.classify(normalizedName, tabs);
|
|
536
|
+
if (!idle.length && leased.length >= ready.poolSize) {
|
|
537
|
+
throw new Error(`Workspace ${normalizedName} pool is exhausted at size ${ready.poolSize}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let tab = idle[0];
|
|
541
|
+
if (!tab) {
|
|
542
|
+
tab = await this.chrome.tabs.create({
|
|
543
|
+
url: this.idleUrl(normalizedName, tabs.length),
|
|
544
|
+
active: false,
|
|
545
|
+
});
|
|
546
|
+
// Group reconciliation can fire before the extension idle URL is visible.
|
|
547
|
+
// Mark ownership first so a legitimate Browser Harness lease is not ejected.
|
|
548
|
+
this.markOwned(tab.id);
|
|
549
|
+
await this.chrome.tabs.group({ groupId: ready.groupId, tabIds: [tab.id] });
|
|
550
|
+
tab = await this.waitForIdleTab(tab.id, normalizedName);
|
|
551
|
+
}
|
|
552
|
+
this.markOwned(tab.id);
|
|
553
|
+
const updated = await this.chrome.tabs.update(tab.id, { url: parsed.href, active: false });
|
|
554
|
+
await this.chrome.tabGroups.update(ready.groupId, { title: normalizedName, collapsed: true });
|
|
555
|
+
return {
|
|
556
|
+
workspace: normalizedName,
|
|
557
|
+
groupId: ready.groupId,
|
|
558
|
+
tabId: updated.id,
|
|
559
|
+
url: updated.url || parsed.href,
|
|
560
|
+
active: Boolean(updated.active),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async release(name, tabId) {
|
|
565
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
566
|
+
const ready = await this.ensureWorkspace(normalizedName);
|
|
567
|
+
const wanted = Number(tabId);
|
|
568
|
+
const tabs = await this.groupTabs(ready.groupId);
|
|
569
|
+
if (!tabs.some((tab) => tab.id === wanted)) {
|
|
570
|
+
throw new Error(`Tab ${wanted} is not owned by workspace ${normalizedName}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (tabs.length > ready.poolSize) {
|
|
574
|
+
await this.chrome.tabs.remove(wanted);
|
|
575
|
+
} else {
|
|
576
|
+
try {
|
|
577
|
+
await this.chrome.tabs.update(wanted, {
|
|
578
|
+
url: this.idleUrl(normalizedName, wanted),
|
|
579
|
+
active: false,
|
|
580
|
+
});
|
|
581
|
+
await this.waitForIdleTab(wanted, normalizedName);
|
|
582
|
+
} catch {
|
|
583
|
+
// Legacy/unowned leases may refuse or fail to finish navigation back to
|
|
584
|
+
// the extension idle page. Preserve pool capacity by creating a clean
|
|
585
|
+
// idle replacement in the same group before removing the stale tab.
|
|
586
|
+
const replacement = await this.chrome.tabs.create({
|
|
587
|
+
url: this.idleUrl(normalizedName, wanted),
|
|
588
|
+
active: false,
|
|
589
|
+
});
|
|
590
|
+
this.markOwned(replacement.id);
|
|
591
|
+
await this.chrome.tabs.group({ groupId: ready.groupId, tabIds: [replacement.id] });
|
|
592
|
+
await this.waitForIdleTab(replacement.id, normalizedName);
|
|
593
|
+
await this.chrome.tabs.remove(wanted);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
await this.chrome.tabGroups.update(ready.groupId, { title: normalizedName, collapsed: true });
|
|
597
|
+
return { workspace: normalizedName, tabId: wanted, released: true };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async releaseAll() {
|
|
601
|
+
const config = await this.loadConfig();
|
|
602
|
+
const released = [];
|
|
603
|
+
|
|
604
|
+
for (const name of Object.keys(config.workspaces || {}).sort()) {
|
|
605
|
+
const group = await this.workspaceGroup(name);
|
|
606
|
+
if (!group) continue;
|
|
607
|
+
|
|
608
|
+
// Snapshot first because release() mutates the group as tabs return idle
|
|
609
|
+
// or are removed when the group is above its configured capacity.
|
|
610
|
+
const tabs = await this.groupTabs(group.id);
|
|
611
|
+
const { leased } = this.classify(name, tabs);
|
|
612
|
+
for (const tab of leased) {
|
|
613
|
+
await this.release(name, tab.id);
|
|
614
|
+
released.push({ workspace: name, tabId: tab.id });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Chrome destroys a tab group when it becomes empty. Release-all must
|
|
618
|
+
// preserve workspace identity, so re-ensure the workspace after the
|
|
619
|
+
// release pass and leave at least one idle seed tab if necessary.
|
|
620
|
+
await this.ensureWorkspace(name);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
released,
|
|
625
|
+
releasedCount: released.length,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async delete(name, force = false) {
|
|
630
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
631
|
+
const state = await this.status(normalizedName);
|
|
632
|
+
if (!state.initialized) {
|
|
633
|
+
await this.removeConfig(normalizedName);
|
|
634
|
+
return { name: normalizedName, deleted: true };
|
|
635
|
+
}
|
|
636
|
+
if (state.leasedTabIds.length && !force) {
|
|
637
|
+
throw new Error(`Workspace ${normalizedName} has ${state.leasedTabIds.length} leased tabs`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const tabs = await this.groupTabs(state.groupId);
|
|
641
|
+
if (tabs.length) await this.chrome.tabs.remove(tabs.map((tab) => tab.id));
|
|
642
|
+
await this.removeConfig(normalizedName);
|
|
643
|
+
return { name: normalizedName, deleted: true };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async rpc(request) {
|
|
647
|
+
const method = String(request?.method || "");
|
|
648
|
+
const args = request?.args || {};
|
|
649
|
+
if (method === "workspace.create") return await this.create(args.name, args.poolSize);
|
|
650
|
+
if (method === "workspace.ensure") return await this.ensure(args.name, args.poolSize);
|
|
651
|
+
if (method === "workspace.status") return await this.status(args.name);
|
|
652
|
+
if (method === "workspace.list") return await this.list();
|
|
653
|
+
if (method === "workspace.acquire") return await this.acquire(args.name, args.url);
|
|
654
|
+
if (method === "workspace.acquireIdentity") return await this.acquireIdentity(args.name);
|
|
655
|
+
if (method === "workspace.release") return await this.release(args.name, args.tabId);
|
|
656
|
+
if (method === "workspace.releaseAll") return await this.releaseAll();
|
|
657
|
+
if (method === "workspace.resize") return await this.resize(args.name, args.poolSize);
|
|
658
|
+
if (method === "workspace.delete") return await this.delete(args.name, Boolean(args.force));
|
|
659
|
+
throw new Error(`Unknown workspace method: ${method}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>Browser Workspace</title>
|
|
4
|
+
<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>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
|
|
7
|
+
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
|
+
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
|
+
|
|
12
|
+
function manifest(dir){
|
|
13
|
+
const data=JSON.parse(fs.readFileSync(path.join(dir,'manifest.json'),'utf8'));
|
|
14
|
+
const digest=crypto.createHash('sha256').update(Buffer.from(data.key,'base64')).digest('hex').slice(0,32);
|
|
15
|
+
const id=[...digest].map(c=>String.fromCharCode(97+parseInt(c,16))).join('');
|
|
16
|
+
if(id!==EXTENSION_ID || data.manifest_version!==3 || !/^\d+\.\d+\.\d+$/.test(data.version))
|
|
17
|
+
throw Error('Unexpected WebHelm extension identity, manifest version, or version number');
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
export const extensionVersion=manifest(SOURCE).version;
|
|
21
|
+
function identical(dir){
|
|
22
|
+
return fs.existsSync(path.join(dir,'manifest.json'))&&
|
|
23
|
+
JSON.stringify(fs.readdirSync(dir).sort())===JSON.stringify([...FILES].sort())&&
|
|
24
|
+
FILES.every(name=>fs.readFileSync(path.join(dir,name)).equals(fs.readFileSync(path.join(SOURCE,name))));
|
|
25
|
+
}
|
|
26
|
+
// Actions checkout contains this self-managed directory, but Git must never publish it.
|
|
27
|
+
function excludeFromActionsGit(dir){
|
|
28
|
+
const root=path.dirname(dir), info=path.join(root,'.git/info/exclude');
|
|
29
|
+
if(!fs.existsSync(info))return;
|
|
30
|
+
const contents=fs.readFileSync(info,'utf8');
|
|
31
|
+
const additions=['/chrome/','/extension/','/.webhelm-extension-stage-*/','/.webhelm-extension-backup-*/'].filter(rule=>!contents.split(/\r?\n/).includes(rule));
|
|
32
|
+
if(additions.length)fs.appendFileSync(info,'\n'+additions.join('\n')+'\n');
|
|
33
|
+
}
|
|
34
|
+
export function stageExtension(dir=EXTENSION_HOME){
|
|
35
|
+
const target=path.resolve(dir);
|
|
36
|
+
const bundled=manifest(SOURCE);
|
|
37
|
+
if(fs.existsSync(target)&&fs.lstatSync(target).isSymbolicLink())throw Error('Refusing to replace extension symlink');
|
|
38
|
+
if(fs.existsSync(target)&&identical(target))return {path:target,version:bundled.version,updated:false};
|
|
39
|
+
if(fs.existsSync(target))manifest(target); // Never replace unrelated content.
|
|
40
|
+
const root=path.dirname(target);
|
|
41
|
+
fs.mkdirSync(root,{recursive:true,mode:0o700});
|
|
42
|
+
excludeFromActionsGit(target);
|
|
43
|
+
const stage=fs.mkdtempSync(path.join(root,'.webhelm-extension-stage-'));
|
|
44
|
+
let backup;
|
|
45
|
+
try {
|
|
46
|
+
for(const filename of FILES)fs.copyFileSync(path.join(SOURCE,filename),path.join(stage,filename));
|
|
47
|
+
if(!identical(stage))throw Error('WebHelm extension staged content mismatch');
|
|
48
|
+
if(fs.existsSync(target)){
|
|
49
|
+
backup=path.join(root,`.webhelm-extension-backup-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
50
|
+
fs.renameSync(target,backup);
|
|
51
|
+
}
|
|
52
|
+
try {fs.renameSync(stage,target);}catch(error){if(backup)fs.renameSync(backup,target);throw error;}
|
|
53
|
+
if(backup)fs.rmSync(backup,{recursive:true,force:true});
|
|
54
|
+
return {path:target,version:bundled.version,updated:true};
|
|
55
|
+
}finally{if(fs.existsSync(stage))fs.rmSync(stage,{recursive:true,force:true});}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Modern Chrome exposes this browser-level protocol. Older Chrome (including
|
|
59
|
+
// Big Sur's Chrome 138) requires the managed startup flags instead.
|
|
60
|
+
export async function ensureExtensionLoaded(browser, {skip=false,extensionDir=EXTENSION_HOME,allowUpdate=true}={}){
|
|
61
|
+
if(skip)return {skipped:true};
|
|
62
|
+
if(!allowUpdate && (!fs.existsSync(path.join(extensionDir,'manifest.json')) || !identical(extensionDir)))
|
|
63
|
+
throw Error('Extension update deferred: active browser Sessions must finish before staging new content');
|
|
64
|
+
const {path:dir,version}=allowUpdate?stageExtension(extensionDir):{path:extensionDir,version:extensionVersion};
|
|
65
|
+
let modern=false, installed;
|
|
66
|
+
try{
|
|
67
|
+
const state=await browser.send('Extensions.getExtensions');
|
|
68
|
+
modern=true;installed=state.extensions?.find(e=>e.id===EXTENSION_ID);
|
|
69
|
+
if(!installed || !installed.enabled || installed.version!==version || path.resolve(installed.path||'')!==dir){
|
|
70
|
+
if(!allowUpdate)throw Error('Extension reload deferred: active browser Sessions must finish');
|
|
71
|
+
const loaded=await browser.send('Extensions.loadUnpacked',{path:dir});
|
|
72
|
+
if(loaded.id!==EXTENSION_ID)throw Error('Chrome loaded an unexpected extension ID');
|
|
73
|
+
}
|
|
74
|
+
}catch(error){
|
|
75
|
+
if(modern || !/Method not available|wasn't found|Method not found|not supported/i.test(String(error.message)))
|
|
76
|
+
throw Error(`WebHelm extension install failed: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
// Inspect the real extension context. An installed-but-asleep MV3 worker may
|
|
79
|
+
// need a temporary extension page, which is never a user's allocated tab.
|
|
80
|
+
const {targetInfos=[]}=await browser.send('Target.getTargets');
|
|
81
|
+
const prefix=`chrome-extension://${EXTENSION_ID}/`;
|
|
82
|
+
let target=targetInfos.find(t=>t.type==='service_worker'&&t.url.startsWith(prefix));
|
|
83
|
+
let temporary=false;
|
|
84
|
+
if(!target){
|
|
85
|
+
const created=await browser.send('Target.createTarget',{url:`${prefix}workspace.html?webhelm_wakeup=1`,background:true});
|
|
86
|
+
target={targetId:created.targetId};temporary=true;
|
|
87
|
+
}
|
|
88
|
+
let attached;
|
|
89
|
+
try{
|
|
90
|
+
({sessionId:attached}=await browser.send('Target.attachToTarget',{targetId:target.targetId,flatten:true}));
|
|
91
|
+
for(let i=0;i<30;i++){
|
|
92
|
+
try{
|
|
93
|
+
const reply=await browser.send('Runtime.evaluate',{expression:'chrome.runtime?.getManifest?.().version',returnByValue:true},attached);
|
|
94
|
+
if(reply.result?.value===version)return {path:dir,version,method:modern?'cdp':'startup-flags'};
|
|
95
|
+
if(reply.result?.value && reply.result.value!==version)
|
|
96
|
+
throw Error(`Chrome has extension ${reply.result.value}; expected ${version}`);
|
|
97
|
+
}catch(error){if(/Chrome has extension/.test(error.message))throw error;}
|
|
98
|
+
await new Promise(r=>setTimeout(r,100));
|
|
99
|
+
}
|
|
100
|
+
throw Error(`Chrome did not initialize managed Extension ${version}. Restart managed Chrome when idle with local extension launch flags.`);
|
|
101
|
+
}finally{
|
|
102
|
+
if(attached)await browser.send('Target.detachFromTarget',{sessionId:attached}).catch(()=>{});
|
|
103
|
+
if(temporary)await browser.send('Target.closeTarget',{targetId:target.targetId}).catch(()=>{});
|
|
104
|
+
}
|
|
105
|
+
}
|
package/runtime/migrate.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import net from 'node:net';
|
|
5
|
-
import { CONFIG_ROOT, LEGACY_ROOT } from './config-paths.mjs';
|
|
5
|
+
import { CONFIG_ROOT, LEGACY_ROOT, OLD_PROFILE_DIR, PROFILE_DIR } from './config-paths.mjs';
|
|
6
6
|
|
|
7
7
|
function liveSocket(socketPath) {
|
|
8
8
|
return new Promise(resolve => {
|
|
@@ -45,3 +45,36 @@ export async function migrateConfig({ legacyRoot = LEGACY_ROOT, configRoot = CON
|
|
|
45
45
|
}
|
|
46
46
|
return { status: 'migrated', config_root: target, compatibility_symlink: legacy };
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
// Move only the Chrome Profile. Durable FIFO and logs stay under ~/.config/webhelm/runtime.
|
|
50
|
+
// Chrome must be stopped because its SingletonLock and keychain-bound profile
|
|
51
|
+
// resources cannot be safely relocated while the browser is still running.
|
|
52
|
+
export async function migrateChromeProfile({oldProfile=OLD_PROFILE_DIR,newProfile=PROFILE_DIR,
|
|
53
|
+
processList=()=>execFileSync('ps',['-axo','command='],{encoding:'utf8'}),
|
|
54
|
+
socketCheck=liveSocket}={}){
|
|
55
|
+
const old=path.resolve(oldProfile),target=path.resolve(newProfile);
|
|
56
|
+
if(old===target)throw Error('Chrome source and destination paths must differ');
|
|
57
|
+
if(fs.existsSync(target)){
|
|
58
|
+
if(!fs.existsSync(old))return {status:'already_migrated',profile_dir:target};
|
|
59
|
+
if(fs.realpathSync(old)===fs.realpathSync(target))return {status:'already_migrated',profile_dir:target,compatibility_symlink:old};
|
|
60
|
+
throw Error(`Different Chrome Profiles at ${old} and ${target}; refusing to overwrite`);
|
|
61
|
+
}
|
|
62
|
+
if(!fs.existsSync(old))return {status:'not_needed',profile_dir:target};
|
|
63
|
+
if(fs.lstatSync(old).isSymbolicLink() || !fs.statSync(old).isDirectory())
|
|
64
|
+
throw Error('Old Chrome Profile is not a regular directory');
|
|
65
|
+
const running=processList().split('\n').filter(s=>s.includes(`--user-data-dir=${old}`)||s.includes(`--user-data-dir=${target}`)||s.includes('node-daemon.mjs daemon') && !s.includes('migrate.mjs'));
|
|
66
|
+
if(running.length)throw Error('Managed Chrome or daemon is running; stop and drain before migrating Profile');
|
|
67
|
+
const socket=path.join(path.dirname(old),'runtime/session-node.sock');
|
|
68
|
+
if(fs.existsSync(socket)&&await socketCheck(socket))throw Error('Session daemon still accepting requests');
|
|
69
|
+
if(!fs.existsSync(path.join(old,'Default')))throw Error('Old Chrome Profile lacks Default; refusing to migrate');
|
|
70
|
+
const parent=path.dirname(target);
|
|
71
|
+
if(fs.existsSync(path.join(parent,'.git'))){
|
|
72
|
+
const info=path.join(parent,'.git/info/exclude');
|
|
73
|
+
const current=fs.existsSync(info)?fs.readFileSync(info,'utf8'):'';
|
|
74
|
+
if(!current.split(/\r?\n/).includes('/chrome/'))fs.appendFileSync(info,'\n/chrome/\n');
|
|
75
|
+
}
|
|
76
|
+
fs.mkdirSync(parent,{recursive:true});
|
|
77
|
+
fs.renameSync(old,target);
|
|
78
|
+
try{fs.symlinkSync(target,old,'dir');}catch(error){fs.renameSync(target,old);throw error;}
|
|
79
|
+
return {status:'migrated',profile_dir:target,compatibility_symlink:old};
|
|
80
|
+
}
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -10,9 +10,11 @@ import { invokePlatformAction } from './platforms.mjs';
|
|
|
10
10
|
import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
|
|
11
11
|
import { idleUrlFromIdentity, sameIdleUrl, staleSession } from './session-lifecycle.mjs';
|
|
12
12
|
import { ensureManagedChrome } from './chrome.mjs';
|
|
13
|
+
import { ensureExtensionLoaded } from './extension-manager.mjs';
|
|
13
14
|
import { PROFILE_DIR, RUNTIME_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
14
15
|
|
|
15
|
-
const ROOT = path.dirname(
|
|
16
|
+
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const PACKAGE_VERSION=JSON.parse(fs.readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
|
|
16
18
|
const HOME = os.homedir();
|
|
17
19
|
export { PROFILE_DIR };
|
|
18
20
|
const DEFAULT_RUNTIME = RUNTIME_DIR;
|
|
@@ -98,6 +100,8 @@ async function connectBrowser() {
|
|
|
98
100
|
}
|
|
99
101
|
});
|
|
100
102
|
await browser.send('Target.setDiscoverTargets',{discover:true});
|
|
103
|
+
try{await ensureExtensionLoaded(browser,{skip:process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE==='1',allowUpdate:sessions.size===0});}
|
|
104
|
+
catch(error){browserWs=null;browser.ws?.close();throw error;}
|
|
101
105
|
connected=true;
|
|
102
106
|
if(!reconnecting){
|
|
103
107
|
reconnecting=true;
|
|
@@ -304,6 +308,7 @@ async function createSession(req={}) {
|
|
|
304
308
|
s.idleUrls.set(targetId,lease.idleUrl);
|
|
305
309
|
s.workspaceSupported=true;
|
|
306
310
|
} else {
|
|
311
|
+
if(process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE!=='1')throw Error('Managed WebHelm Extension unavailable; refusing unmanaged Tab allocation');
|
|
307
312
|
({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
|
|
308
313
|
}
|
|
309
314
|
s.targetId=targetId;
|
|
@@ -533,7 +538,7 @@ async function runPlatform(req) {
|
|
|
533
538
|
self_heal_capable:!auto,result};
|
|
534
539
|
}
|
|
535
540
|
|
|
536
|
-
export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:
|
|
541
|
+
export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true}};case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':{const stopped=[];for(const id of [...sessions.keys()])stopped.push(await stopSession(id));return {stopped_count:stopped.length,error_count:0,stopped,errors:[]}}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
|
|
537
542
|
function serve(conn) {
|
|
538
543
|
let buffer = '';
|
|
539
544
|
conn.setEncoding('utf8');
|
|
@@ -553,6 +558,6 @@ function serve(conn) {
|
|
|
553
558
|
|
|
554
559
|
async function main(){fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});let lockfd;try{lockfd=fs.openSync(LOCK,'wx',0o600);fs.writeFileSync(lockfd,String(process.pid))}catch{throw Error(`Node pilot lock exists: ${LOCK}`)}if(fs.existsSync(SOCKET)){try{const c=net.createConnection(SOCKET);await new Promise((res,rej)=>{c.once('connect',res);c.once('error',rej)});c.destroy();fs.closeSync(lockfd);fs.unlinkSync(LOCK);throw Error(`Node daemon socket already active: ${SOCKET}`)}catch(e){if(e.message.includes('already active'))throw e;fs.unlinkSync(SOCKET)}}socket=net.createServer(serve);await new Promise((res,rej)=>socket.listen(SOCKET,res).once('error',rej));fs.chmodSync(SOCKET,0o600);connectBrowser().catch(()=>{});getChatGPTQueue();const cleanup=async()=>{if(shuttingDown)return;shuttingDown=true;if(chatgptQueue)await chatgptQueue.shutdown().catch(()=>{});await handle({op:'stop-all'}).catch(()=>{});socket?.close();try{if(fs.existsSync(SOCKET))fs.unlinkSync(SOCKET)}catch{}try{fs.closeSync(lockfd);fs.unlinkSync(LOCK)}catch{}process.exit(0)};process.on('SIGTERM',cleanup);process.on('SIGINT',cleanup)}
|
|
555
560
|
async function request(payload,ensure=true){if(ensure)await ensureDaemon();return new Promise((resolve,reject)=>{const c=net.createConnection(SOCKET);let data='';c.setEncoding('utf8');c.on('connect',()=>c.write(JSON.stringify(payload)+'\n'));c.on('data',x=>{data+=x;if(!data.includes('\n'))return;c.end();try{const r=JSON.parse(data);r.error?reject(Error(r.error)):resolve(r)}catch(e){reject(e)}});c.on('error',reject)})}
|
|
556
|
-
export async function ensureDaemon(){
|
|
561
|
+
export async function ensureDaemon(){try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}if(!process.env.WEBHELM_CDP_URL)requireMigratedConfig();fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});if(fs.existsSync(LOCK)){let owner=0;try{owner=Number(fs.readFileSync(LOCK,'utf8').trim())}catch{};let alive=false;if(owner>0)try{process.kill(owner,0);alive=true}catch{};if(alive)throw Error(`daemon owner lock exists but socket is unreachable; refusing duplicate startup (${LOCK})`);fs.unlinkSync(LOCK)}const child=await import('node:child_process');const log=fs.openSync(path.join(RUNTIME,'session-node.log'),'a');child.spawn(process.execPath,[fileURLToPath(import.meta.url),'daemon'],{detached:true,stdio:['ignore',log,log],env:process.env}).unref();for(let i=0;i<100;i++){await delay(100);try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}}throw Error(`WebHelm Node daemon did not start; see ${path.join(RUNTIME,'session-node.log')}`)}
|
|
557
562
|
if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
|
|
558
563
|
export {request,SOCKET,RUNTIME};
|