openmd-cli 0.1.0__py3-none-any.whl
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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2742 @@
|
|
|
1
|
+
// OpenCLI Browser Bridge 1.0.27 (Apache-2.0, https://github.com/jackwener/opencli),
|
|
2
|
+
// vendored by scripts/vendor_opencli_bridge.py. Modified by Openmd: see VENDOR.md.
|
|
3
|
+
const OPENMD_BRIDGE_VERSION = "1.0.27";
|
|
4
|
+
const CHROME_METHODS = {
|
|
5
|
+
"tabs.query": "(queryInfo: object) => Tab[]",
|
|
6
|
+
"tabs.get": "(tabId: number) => Tab",
|
|
7
|
+
"tabs.create": "(createProperties: object) => Tab",
|
|
8
|
+
"tabs.duplicate": "(tabId: number) => Tab",
|
|
9
|
+
"tabs.update": "(tabId: number, updateProperties: object) => Tab",
|
|
10
|
+
"tabs.move": "(tabIds: number | number[], moveProperties: object) => Tab | Tab[]",
|
|
11
|
+
"tabs.remove": "(tabIds: number | number[]) => void",
|
|
12
|
+
"tabs.group": "(options: {tabIds: number[], groupId?: number, createProperties?: {windowId?: number}}) => number",
|
|
13
|
+
"tabs.ungroup": "(tabIds: number | number[]) => void",
|
|
14
|
+
"tabGroups.query": "(queryInfo: object) => TabGroup[]",
|
|
15
|
+
"tabGroups.get": "(groupId: number) => TabGroup",
|
|
16
|
+
"tabGroups.update": "(groupId: number, updateProperties: {title?: string, color?: string, collapsed?: boolean}) => TabGroup",
|
|
17
|
+
"tabGroups.move": "(groupId: number, moveProperties: {index: number, windowId?: number}) => TabGroup",
|
|
18
|
+
"windows.get": "(windowId: number, queryOptions?: object) => Window",
|
|
19
|
+
"windows.getAll": "(queryOptions?: object) => Window[]",
|
|
20
|
+
"windows.create": "(createData: object) => Window",
|
|
21
|
+
"windows.update": "(windowId: number, updateInfo: object) => Window",
|
|
22
|
+
"windows.remove": "(windowId: number) => void"
|
|
23
|
+
};
|
|
24
|
+
class ChromeApiError extends Error {
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
hint;
|
|
30
|
+
}
|
|
31
|
+
const nativeError = (code, message) => new ChromeApiError(code, message);
|
|
32
|
+
const READ = /* @__PURE__ */ new Set(["tabs.query", "tabs.get", "tabGroups.query", "tabGroups.get", "windows.get", "windows.getAll"]);
|
|
33
|
+
function resolve(method) {
|
|
34
|
+
if (!Object.hasOwn(CHROME_METHODS, method)) throw nativeError("unsupported_chrome_method", `Unsupported Chrome method ${method}. Use chrome describe to list browser-management methods.`);
|
|
35
|
+
const [namespace, name] = method.split(".");
|
|
36
|
+
const receiver = chrome[namespace];
|
|
37
|
+
return { receiver, fn: receiver?.[name], namespace, name };
|
|
38
|
+
}
|
|
39
|
+
async function describeChrome(method) {
|
|
40
|
+
if (!method) return { methods: Object.keys(CHROME_METHODS), ids: "Use numeric Chrome IDs from tabs.query/tabGroups.query/windows.getAll, not page targetIds.", lifecycle: "Native-created tabs and windows are user-managed and persist after the CLI exits." };
|
|
41
|
+
const { fn, namespace, name } = resolve(method);
|
|
42
|
+
return {
|
|
43
|
+
method,
|
|
44
|
+
signature: CHROME_METHODS[method],
|
|
45
|
+
available: typeof fn === "function",
|
|
46
|
+
access: READ.has(method) ? "read" : "write",
|
|
47
|
+
declaredPermissions: chrome.runtime.getManifest().permissions ?? [],
|
|
48
|
+
grantedPermissions: await chrome.permissions.getAll(),
|
|
49
|
+
documentation: `https://developer.chrome.com/docs/extensions/reference/api/${namespace}#method-${name}`,
|
|
50
|
+
...namespace === "tabGroups" ? { colors: ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"] } : {}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function callChrome(method, args, protectedTabs, protectedGroups) {
|
|
54
|
+
const { receiver, fn } = resolve(method);
|
|
55
|
+
if (!Array.isArray(args)) throw nativeError("invalid_args", "Chrome args must be an array of positional arguments.");
|
|
56
|
+
if (typeof fn !== "function") throw nativeError("chrome_api_unavailable", `chrome.${method} is unavailable.`);
|
|
57
|
+
try {
|
|
58
|
+
if (!READ.has(method)) {
|
|
59
|
+
let tabs = [];
|
|
60
|
+
const first = args[0];
|
|
61
|
+
if (method.startsWith("tabGroups.") || ["windows.update", "windows.remove"].includes(method)) {
|
|
62
|
+
if (!Number.isInteger(first)) throw nativeError("invalid_args", "Use an explicit numeric group or window ID.");
|
|
63
|
+
}
|
|
64
|
+
if (method.startsWith("tabGroups.")) {
|
|
65
|
+
if (protectedGroups.has(first)) throw nativeError("managed_resource_conflict", "This group is managed by OpenCLI browser sessions. Use an ordinary Chrome group.");
|
|
66
|
+
tabs = await chrome.tabs.query({ groupId: first });
|
|
67
|
+
} else if (method === "tabs.group") {
|
|
68
|
+
if (!Array.isArray(first?.tabIds) || !first.tabIds.length || first.tabIds.some((id) => !Number.isInteger(id))) throw nativeError("invalid_args", "tabs.group requires a non-empty tabIds array.");
|
|
69
|
+
if (first.groupId !== void 0) {
|
|
70
|
+
if (protectedGroups.has(first.groupId)) throw nativeError("managed_resource_conflict", "Cannot add tabs to an OpenCLI-managed group.");
|
|
71
|
+
tabs.push(...await chrome.tabs.query({ groupId: first.groupId }));
|
|
72
|
+
}
|
|
73
|
+
tabs.push(...await Promise.all(first.tabIds.map((id) => chrome.tabs.get(id))));
|
|
74
|
+
} else if (["tabs.update", "tabs.move", "tabs.remove", "tabs.ungroup", "tabs.duplicate"].includes(method)) {
|
|
75
|
+
const ids = Array.isArray(first) ? first : [first];
|
|
76
|
+
if (!ids.length || ids.some((id) => !Number.isInteger(id))) throw nativeError("invalid_args", "Use explicit numeric tab IDs from tabs.query.");
|
|
77
|
+
tabs = await Promise.all(ids.map((id) => chrome.tabs.get(id)));
|
|
78
|
+
} else if (["windows.update", "windows.remove"].includes(method)) tabs = await chrome.tabs.query({ windowId: first });
|
|
79
|
+
else if (method === "windows.create" && first?.tabId !== void 0) tabs = [await chrome.tabs.get(first.tabId)];
|
|
80
|
+
if (tabs.some((t) => protectedTabs.has(t.id) || protectedGroups.has(t.groupId))) {
|
|
81
|
+
throw nativeError("managed_resource_conflict", "The operation touches an active OpenCLI session. Finish that session before reorganizing its tabs.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return await Reflect.apply(fn, receiver, args) ?? null;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof ChromeApiError) throw error;
|
|
87
|
+
throw nativeError("chrome_api_error", `chrome.${method}: ${error.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const DAEMON_PORT = 19825;
|
|
92
|
+
const DAEMON_HOST = "127.0.0.1";
|
|
93
|
+
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
|
94
|
+
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
|
|
95
|
+
|
|
96
|
+
const KEY = "opencli_idle_interactive_v1";
|
|
97
|
+
const PAGE = "idle.html";
|
|
98
|
+
async function idlePlaceholderUrl() {
|
|
99
|
+
const data = await chrome.storage.local.get(KEY);
|
|
100
|
+
const token = data[KEY];
|
|
101
|
+
return typeof token === "string" && /^[a-f0-9-]{36}$/.test(token) ? `${chrome.runtime.getURL(PAGE)}#${token}` : null;
|
|
102
|
+
}
|
|
103
|
+
async function parkIdleInteractiveTab(tabId, windowId) {
|
|
104
|
+
const tabs = await chrome.tabs.query({ windowId });
|
|
105
|
+
if (tabs.length !== 1 || tabs[0].id !== tabId) return null;
|
|
106
|
+
const token = crypto.randomUUID();
|
|
107
|
+
await chrome.storage.local.set({ [KEY]: token });
|
|
108
|
+
return `${chrome.runtime.getURL(PAGE)}#${token}`;
|
|
109
|
+
}
|
|
110
|
+
async function consumeIdlePlaceholder(tab) {
|
|
111
|
+
const url = await idlePlaceholderUrl();
|
|
112
|
+
if (url && tab.url === url) {
|
|
113
|
+
if (tab.pendingUrl && tab.pendingUrl !== url) throw new Error("Idle OpenCLI tab is being navigated; retry the command.");
|
|
114
|
+
await chrome.storage.local.remove(KEY);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function findIdleInteractiveGroup(reservedTabs) {
|
|
118
|
+
const url = await idlePlaceholderUrl();
|
|
119
|
+
if (!url) return null;
|
|
120
|
+
const tabs = (await chrome.tabs.query({})).filter((tab2) => tab2.url === url && !tab2.pendingUrl);
|
|
121
|
+
if (tabs.length !== 1) return null;
|
|
122
|
+
const tab = tabs[0];
|
|
123
|
+
if (tab.id === void 0 || reservedTabs.has(tab.id) || tab.incognito || tab.groupId === void 0 || tab.groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) return null;
|
|
124
|
+
const window = await chrome.windows.get(tab.windowId);
|
|
125
|
+
if (window.incognito || window.type !== "normal") return null;
|
|
126
|
+
const windowTabs = await chrome.tabs.query({ windowId: tab.windowId });
|
|
127
|
+
if (windowTabs.length !== 1 || windowTabs[0].id !== tab.id || windowTabs[0].url !== url || windowTabs[0].pendingUrl) return null;
|
|
128
|
+
const group = await chrome.tabGroups.get(tab.groupId);
|
|
129
|
+
return group.windowId === tab.windowId ? group : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const attached = /* @__PURE__ */ new Set();
|
|
133
|
+
const tabFrameContexts = /* @__PURE__ */ new Map();
|
|
134
|
+
const frameTargets = /* @__PURE__ */ new Map();
|
|
135
|
+
const frameTargetKeys = /* @__PURE__ */ new Map();
|
|
136
|
+
let frameTargetCleanupRegistered = false;
|
|
137
|
+
const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024;
|
|
138
|
+
const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024;
|
|
139
|
+
const networkCaptures = /* @__PURE__ */ new Map();
|
|
140
|
+
const CDP_COMMAND_TIMEOUT_MS = 6e4;
|
|
141
|
+
const CDP_PROBE_TIMEOUT_MS = 2e3;
|
|
142
|
+
async function sendDebuggerCommand(target, method, params, timeoutMs = CDP_COMMAND_TIMEOUT_MS) {
|
|
143
|
+
let timer;
|
|
144
|
+
const commandPromise = params === void 0 ? chrome.debugger.sendCommand(target, method) : chrome.debugger.sendCommand(target, method, params);
|
|
145
|
+
commandPromise.catch(() => {
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
return await Promise.race([
|
|
149
|
+
commandPromise,
|
|
150
|
+
new Promise((_, reject) => {
|
|
151
|
+
timer = setTimeout(() => reject(new Error(
|
|
152
|
+
`CDP command ${method} timed out after ${Math.round(timeoutMs / 1e3)}s — the page may be blocked by a native dialog (alert/confirm/print)`
|
|
153
|
+
)), timeoutMs);
|
|
154
|
+
})
|
|
155
|
+
]);
|
|
156
|
+
} finally {
|
|
157
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function isDebuggableUrl$1(url) {
|
|
161
|
+
if (!url) return true;
|
|
162
|
+
return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:");
|
|
163
|
+
}
|
|
164
|
+
async function ensureAttached(tabId, aggressiveRetry = false) {
|
|
165
|
+
try {
|
|
166
|
+
const tab = await chrome.tabs.get(tabId);
|
|
167
|
+
if (!isDebuggableUrl$1(tab.url)) {
|
|
168
|
+
attached.delete(tabId);
|
|
169
|
+
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
|
|
173
|
+
attached.delete(tabId);
|
|
174
|
+
throw new Error(`Tab ${tabId} no longer exists`);
|
|
175
|
+
}
|
|
176
|
+
if (attached.has(tabId)) {
|
|
177
|
+
try {
|
|
178
|
+
await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
|
|
179
|
+
expression: "1",
|
|
180
|
+
returnByValue: true
|
|
181
|
+
}, CDP_PROBE_TIMEOUT_MS);
|
|
182
|
+
return;
|
|
183
|
+
} catch {
|
|
184
|
+
attached.delete(tabId);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
|
|
188
|
+
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
|
|
189
|
+
let lastError = "";
|
|
190
|
+
const preservedNetworkCapture = networkCaptures.get(tabId);
|
|
191
|
+
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
|
|
192
|
+
try {
|
|
193
|
+
try {
|
|
194
|
+
await chrome.debugger.detach({ tabId });
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
await chrome.debugger.attach({ tabId }, "1.3");
|
|
198
|
+
lastError = "";
|
|
199
|
+
break;
|
|
200
|
+
} catch (e) {
|
|
201
|
+
lastError = e instanceof Error ? e.message : String(e);
|
|
202
|
+
if (attempt < MAX_ATTACH_RETRIES) {
|
|
203
|
+
console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`);
|
|
204
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
205
|
+
try {
|
|
206
|
+
const tab = await chrome.tabs.get(tabId);
|
|
207
|
+
if (!isDebuggableUrl$1(tab.url)) {
|
|
208
|
+
lastError = `Tab URL changed to ${tab.url} during retry`;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
lastError = `Tab ${tabId} no longer exists`;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (lastError) {
|
|
218
|
+
let finalUrl = "unknown";
|
|
219
|
+
let finalWindowId = "unknown";
|
|
220
|
+
try {
|
|
221
|
+
const tab = await chrome.tabs.get(tabId);
|
|
222
|
+
finalUrl = tab.url ?? "undefined";
|
|
223
|
+
finalWindowId = String(tab.windowId);
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`);
|
|
227
|
+
const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : "";
|
|
228
|
+
throw new Error(`attach failed: ${lastError}${hint}`);
|
|
229
|
+
}
|
|
230
|
+
attached.add(tabId);
|
|
231
|
+
try {
|
|
232
|
+
await sendDebuggerCommand({ tabId }, "Runtime.enable");
|
|
233
|
+
} catch {
|
|
234
|
+
}
|
|
235
|
+
if (preservedNetworkCapture) {
|
|
236
|
+
try {
|
|
237
|
+
await sendDebuggerCommand({ tabId }, "Network.enable");
|
|
238
|
+
networkCaptures.set(tabId, preservedNetworkCapture);
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function evaluate(tabId, expression, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) {
|
|
244
|
+
try {
|
|
245
|
+
await ensureAttached(tabId, aggressiveRetry);
|
|
246
|
+
const result = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
|
|
247
|
+
expression,
|
|
248
|
+
returnByValue: true,
|
|
249
|
+
awaitPromise: true
|
|
250
|
+
}, timeoutMs);
|
|
251
|
+
if (result.exceptionDetails) {
|
|
252
|
+
const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error";
|
|
253
|
+
throw new Error(errMsg);
|
|
254
|
+
}
|
|
255
|
+
return result.result?.value;
|
|
256
|
+
} catch (e) {
|
|
257
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
258
|
+
if (msg.includes("Detached") || msg.includes("Debugger is not attached") || msg.includes("Target closed")) {
|
|
259
|
+
attached.delete(tabId);
|
|
260
|
+
}
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const evaluateAsync = evaluate;
|
|
265
|
+
async function screenshot(tabId, options = {}) {
|
|
266
|
+
await ensureAttached(tabId);
|
|
267
|
+
const format = options.format ?? "png";
|
|
268
|
+
const fullPage = options.fullPage === true;
|
|
269
|
+
const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : void 0;
|
|
270
|
+
const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : void 0;
|
|
271
|
+
const needsOverride = fullPage || overrideWidth !== void 0 || overrideHeight !== void 0;
|
|
272
|
+
if (needsOverride) {
|
|
273
|
+
if (overrideWidth !== void 0 && fullPage) {
|
|
274
|
+
await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
|
|
275
|
+
mobile: false,
|
|
276
|
+
width: overrideWidth,
|
|
277
|
+
height: 0,
|
|
278
|
+
deviceScaleFactor: 1
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
let finalWidth = overrideWidth ?? 0;
|
|
282
|
+
let finalHeight = overrideHeight ?? 0;
|
|
283
|
+
if (fullPage) {
|
|
284
|
+
const metrics = await sendDebuggerCommand({ tabId }, "Page.getLayoutMetrics");
|
|
285
|
+
const size = metrics.cssContentSize || metrics.contentSize;
|
|
286
|
+
if (size) {
|
|
287
|
+
if (finalWidth === 0) finalWidth = Math.ceil(size.width);
|
|
288
|
+
finalHeight = Math.ceil(size.height);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
|
|
292
|
+
mobile: false,
|
|
293
|
+
width: finalWidth,
|
|
294
|
+
height: finalHeight,
|
|
295
|
+
deviceScaleFactor: 1
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const params = { format };
|
|
300
|
+
if (format === "jpeg" && options.quality !== void 0) {
|
|
301
|
+
params.quality = Math.max(0, Math.min(100, options.quality));
|
|
302
|
+
}
|
|
303
|
+
const result = await sendDebuggerCommand({ tabId }, "Page.captureScreenshot", params);
|
|
304
|
+
return result.data;
|
|
305
|
+
} finally {
|
|
306
|
+
if (needsOverride) {
|
|
307
|
+
await sendDebuggerCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function setFileInputFiles(tabId, files, selector) {
|
|
313
|
+
await ensureAttached(tabId);
|
|
314
|
+
await sendDebuggerCommand({ tabId }, "DOM.enable");
|
|
315
|
+
await sendDebuggerCommand({ tabId }, "Page.enable");
|
|
316
|
+
const query = selector || 'input[type="file"]';
|
|
317
|
+
const found = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
|
|
318
|
+
expression: `!!document.querySelector(${JSON.stringify(query)})`,
|
|
319
|
+
returnByValue: true
|
|
320
|
+
});
|
|
321
|
+
if (!found.result?.value) {
|
|
322
|
+
throw new Error(`No element found matching selector: ${query}`);
|
|
323
|
+
}
|
|
324
|
+
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: true });
|
|
325
|
+
try {
|
|
326
|
+
const backendNodeId = await new Promise((resolve, reject) => {
|
|
327
|
+
const timer = setTimeout(() => {
|
|
328
|
+
cleanup();
|
|
329
|
+
reject(new Error("Page.fileChooserOpened not received within 5s — the input may not have opened a file chooser"));
|
|
330
|
+
}, 5e3);
|
|
331
|
+
const listener = (source, method, params) => {
|
|
332
|
+
if (source.tabId !== tabId || method !== "Page.fileChooserOpened") return;
|
|
333
|
+
cleanup();
|
|
334
|
+
const backend = params?.backendNodeId;
|
|
335
|
+
if (typeof backend === "number") resolve(backend);
|
|
336
|
+
else reject(new Error("Page.fileChooserOpened carried no backendNodeId"));
|
|
337
|
+
};
|
|
338
|
+
const cleanup = () => {
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
chrome.debugger.onEvent.removeListener(listener);
|
|
341
|
+
};
|
|
342
|
+
chrome.debugger.onEvent.addListener(listener);
|
|
343
|
+
void sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
|
|
344
|
+
expression: `document.querySelector(${JSON.stringify(query)}).click()`,
|
|
345
|
+
userGesture: true
|
|
346
|
+
}).catch((err) => {
|
|
347
|
+
cleanup();
|
|
348
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", {
|
|
352
|
+
files,
|
|
353
|
+
backendNodeId
|
|
354
|
+
});
|
|
355
|
+
} finally {
|
|
356
|
+
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function matchesDownloadPattern(item, pattern) {
|
|
361
|
+
if (!pattern) return true;
|
|
362
|
+
const haystack = [
|
|
363
|
+
item.filename,
|
|
364
|
+
item.url,
|
|
365
|
+
item.finalUrl,
|
|
366
|
+
item.mime
|
|
367
|
+
].filter(Boolean).join("\n").toLowerCase();
|
|
368
|
+
return haystack.includes(pattern.toLowerCase());
|
|
369
|
+
}
|
|
370
|
+
function downloadResult(item, startedAt) {
|
|
371
|
+
return {
|
|
372
|
+
downloaded: item.state === "complete",
|
|
373
|
+
id: item.id,
|
|
374
|
+
filename: item.filename,
|
|
375
|
+
url: item.url,
|
|
376
|
+
finalUrl: item.finalUrl,
|
|
377
|
+
mime: item.mime,
|
|
378
|
+
totalBytes: item.totalBytes,
|
|
379
|
+
state: item.state,
|
|
380
|
+
danger: item.danger,
|
|
381
|
+
error: item.error,
|
|
382
|
+
elapsedMs: Date.now() - startedAt
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
async function waitForDownload(pattern = "", timeoutMs = 3e4) {
|
|
386
|
+
const startedAt = Date.now();
|
|
387
|
+
const timeout = Math.max(1, timeoutMs);
|
|
388
|
+
return await new Promise((resolve) => {
|
|
389
|
+
let done = false;
|
|
390
|
+
const inProgressIds = /* @__PURE__ */ new Set();
|
|
391
|
+
const finish = (result) => {
|
|
392
|
+
if (done) return;
|
|
393
|
+
done = true;
|
|
394
|
+
clearTimeout(timer);
|
|
395
|
+
chrome.downloads.onCreated.removeListener(onCreated);
|
|
396
|
+
chrome.downloads.onChanged.removeListener(onChanged);
|
|
397
|
+
resolve(result);
|
|
398
|
+
};
|
|
399
|
+
const inspectById = async (id) => {
|
|
400
|
+
const items = await chrome.downloads.search({ id });
|
|
401
|
+
const item = items[0];
|
|
402
|
+
if (!item || !matchesDownloadPattern(item, pattern)) return;
|
|
403
|
+
inProgressIds.add(id);
|
|
404
|
+
if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt));
|
|
405
|
+
};
|
|
406
|
+
const onCreated = (item) => {
|
|
407
|
+
if (!matchesDownloadPattern(item, pattern)) return;
|
|
408
|
+
inProgressIds.add(item.id);
|
|
409
|
+
if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt));
|
|
410
|
+
};
|
|
411
|
+
const onChanged = (delta) => {
|
|
412
|
+
if (!delta.id) return;
|
|
413
|
+
if (!inProgressIds.has(delta.id) && !delta.filename && !delta.url) return;
|
|
414
|
+
if (delta.filename?.current || delta.url?.current) {
|
|
415
|
+
void inspectById(delta.id);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (delta.state?.current === "complete" || delta.state?.current === "interrupted") {
|
|
419
|
+
void inspectById(delta.id);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const timer = setTimeout(() => {
|
|
423
|
+
finish({
|
|
424
|
+
downloaded: false,
|
|
425
|
+
state: "interrupted",
|
|
426
|
+
error: `No download matched "${pattern || "*"}" within ${timeout}ms`,
|
|
427
|
+
elapsedMs: Date.now() - startedAt
|
|
428
|
+
});
|
|
429
|
+
}, timeout);
|
|
430
|
+
chrome.downloads.onCreated.addListener(onCreated);
|
|
431
|
+
chrome.downloads.onChanged.addListener(onChanged);
|
|
432
|
+
void chrome.downloads.search({
|
|
433
|
+
limit: 50,
|
|
434
|
+
orderBy: ["-startTime"],
|
|
435
|
+
startedAfter: new Date(startedAt - Math.max(timeout, 1e3)).toISOString()
|
|
436
|
+
}).then((recent) => {
|
|
437
|
+
if (done) return;
|
|
438
|
+
const completed = recent.find((item) => item.state === "complete" && matchesDownloadPattern(item, pattern));
|
|
439
|
+
if (completed) {
|
|
440
|
+
finish(downloadResult(completed, startedAt));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
for (const item of recent) {
|
|
444
|
+
if (item.state === "in_progress" && matchesDownloadPattern(item, pattern)) inProgressIds.add(item.id);
|
|
445
|
+
}
|
|
446
|
+
}).catch((err) => {
|
|
447
|
+
finish({
|
|
448
|
+
downloaded: false,
|
|
449
|
+
state: "interrupted",
|
|
450
|
+
error: err instanceof Error ? err.message : String(err),
|
|
451
|
+
elapsedMs: Date.now() - startedAt
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
function frameTargetKey(tabId, frameId) {
|
|
457
|
+
return `${tabId}:${frameId}`;
|
|
458
|
+
}
|
|
459
|
+
function registerFrameTargetCleanup() {
|
|
460
|
+
if (frameTargetCleanupRegistered) return;
|
|
461
|
+
frameTargetCleanupRegistered = true;
|
|
462
|
+
chrome.debugger.onEvent.addListener((_source, method, params) => {
|
|
463
|
+
if (method === "Target.detachedFromTarget") {
|
|
464
|
+
const targetId = String(params?.targetId || "");
|
|
465
|
+
clearFrameTarget(targetId);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
function clearFrameTarget(targetId) {
|
|
470
|
+
if (!targetId) return;
|
|
471
|
+
const key = frameTargetKeys.get(targetId);
|
|
472
|
+
if (key) frameTargets.delete(key);
|
|
473
|
+
frameTargetKeys.delete(targetId);
|
|
474
|
+
}
|
|
475
|
+
async function ensureFrameTarget(tabId, frameId, aggressiveRetry = false, targetUrl) {
|
|
476
|
+
registerFrameTargetCleanup();
|
|
477
|
+
await ensureAttached(tabId, aggressiveRetry);
|
|
478
|
+
const key = frameTargetKey(tabId, frameId);
|
|
479
|
+
const existing = frameTargets.get(key);
|
|
480
|
+
if (existing) return existing;
|
|
481
|
+
await sendDebuggerCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => {
|
|
482
|
+
});
|
|
483
|
+
await sendDebuggerCommand({ tabId }, "Target.setAutoAttach", {
|
|
484
|
+
autoAttach: true,
|
|
485
|
+
waitForDebuggerOnStart: false,
|
|
486
|
+
flatten: true,
|
|
487
|
+
filter: [{ type: "iframe", exclude: false }]
|
|
488
|
+
}).catch(() => {
|
|
489
|
+
});
|
|
490
|
+
const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl);
|
|
491
|
+
try {
|
|
492
|
+
await chrome.debugger.attach({ targetId }, "1.3");
|
|
493
|
+
} catch (err) {
|
|
494
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
495
|
+
if (!message.includes("Another debugger is already attached")) throw err;
|
|
496
|
+
}
|
|
497
|
+
frameTargets.set(key, targetId);
|
|
498
|
+
frameTargetKeys.set(targetId, key);
|
|
499
|
+
return targetId;
|
|
500
|
+
}
|
|
501
|
+
async function resolveFrameTargetId(tabId, frameId, targetUrl) {
|
|
502
|
+
const result = await sendDebuggerCommand({ tabId }, "Target.getTargets").catch(() => null);
|
|
503
|
+
const targets = result?.targetInfos ?? [];
|
|
504
|
+
const frameTarget = targets.find((candidate) => {
|
|
505
|
+
const candidateId = candidate.targetId || candidate.id;
|
|
506
|
+
return candidate.type === "iframe" && (candidateId === frameId || !!targetUrl && candidate.url === targetUrl);
|
|
507
|
+
});
|
|
508
|
+
const targetId = frameTarget?.targetId || frameTarget?.id;
|
|
509
|
+
if (targetId) return targetId;
|
|
510
|
+
const candidates = targets.filter((target) => target.type === "iframe").map((target) => `${target.targetId || target.id || "?"} ${target.url || ""}`).join("; ");
|
|
511
|
+
throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ""}. Candidates: ${candidates || "none"}`);
|
|
512
|
+
}
|
|
513
|
+
async function sendCommandInFrameTarget(tabId, frameId, method, params = {}, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS, targetUrl) {
|
|
514
|
+
const targetId = await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl);
|
|
515
|
+
const target = { targetId };
|
|
516
|
+
return sendDebuggerCommand(target, method, params, timeoutMs);
|
|
517
|
+
}
|
|
518
|
+
async function insertText(tabId, text) {
|
|
519
|
+
await ensureAttached(tabId);
|
|
520
|
+
await sendDebuggerCommand({ tabId }, "Input.insertText", { text });
|
|
521
|
+
}
|
|
522
|
+
function registerFrameTracking() {
|
|
523
|
+
registerFrameTargetCleanup();
|
|
524
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
525
|
+
const tabId = source.tabId;
|
|
526
|
+
if (!tabId) return;
|
|
527
|
+
if (method === "Runtime.executionContextCreated") {
|
|
528
|
+
const context = params.context;
|
|
529
|
+
if (!context?.auxData?.frameId || context.auxData.isDefault !== true) return;
|
|
530
|
+
const frameId = context.auxData.frameId;
|
|
531
|
+
if (!tabFrameContexts.has(tabId)) {
|
|
532
|
+
tabFrameContexts.set(tabId, /* @__PURE__ */ new Map());
|
|
533
|
+
}
|
|
534
|
+
tabFrameContexts.get(tabId).set(frameId, context.id);
|
|
535
|
+
}
|
|
536
|
+
if (method === "Runtime.executionContextDestroyed") {
|
|
537
|
+
const ctxId = params.executionContextId;
|
|
538
|
+
const contexts = tabFrameContexts.get(tabId);
|
|
539
|
+
if (contexts) {
|
|
540
|
+
for (const [fid, cid] of contexts) {
|
|
541
|
+
if (cid === ctxId) {
|
|
542
|
+
contexts.delete(fid);
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (method === "Runtime.executionContextsCleared") {
|
|
549
|
+
tabFrameContexts.delete(tabId);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
553
|
+
tabFrameContexts.delete(tabId);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async function getFrameTree(tabId) {
|
|
557
|
+
await ensureAttached(tabId);
|
|
558
|
+
return sendDebuggerCommand({ tabId }, "Page.getFrameTree");
|
|
559
|
+
}
|
|
560
|
+
async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) {
|
|
561
|
+
await ensureAttached(tabId, aggressiveRetry);
|
|
562
|
+
await sendDebuggerCommand({ tabId }, "Runtime.enable").catch(() => {
|
|
563
|
+
});
|
|
564
|
+
const contexts = tabFrameContexts.get(tabId);
|
|
565
|
+
const contextId = contexts?.get(frameId);
|
|
566
|
+
if (contextId !== void 0) {
|
|
567
|
+
try {
|
|
568
|
+
const result2 = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
|
|
569
|
+
expression,
|
|
570
|
+
contextId,
|
|
571
|
+
returnByValue: true,
|
|
572
|
+
awaitPromise: true
|
|
573
|
+
}, timeoutMs);
|
|
574
|
+
if (result2.exceptionDetails) {
|
|
575
|
+
const errMsg = result2.exceptionDetails.exception?.description || result2.exceptionDetails.text || "Eval error";
|
|
576
|
+
throw new Error(errMsg);
|
|
577
|
+
}
|
|
578
|
+
return result2.result?.value;
|
|
579
|
+
} catch (err) {
|
|
580
|
+
const msg = String(err?.message || err);
|
|
581
|
+
if (!/Cannot find context|context with specified id|Execution context was destroyed/i.test(msg)) {
|
|
582
|
+
throw err;
|
|
583
|
+
}
|
|
584
|
+
contexts?.delete(frameId);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry, timeoutMs).catch(() => void 0);
|
|
588
|
+
const result = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", {
|
|
589
|
+
expression,
|
|
590
|
+
returnByValue: true,
|
|
591
|
+
awaitPromise: true
|
|
592
|
+
}, aggressiveRetry, timeoutMs);
|
|
593
|
+
if (result.exceptionDetails) {
|
|
594
|
+
const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error";
|
|
595
|
+
throw new Error(errMsg);
|
|
596
|
+
}
|
|
597
|
+
return result.result?.value;
|
|
598
|
+
}
|
|
599
|
+
function normalizeCapturePatterns(pattern) {
|
|
600
|
+
return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean);
|
|
601
|
+
}
|
|
602
|
+
function shouldCaptureUrl(url, patterns) {
|
|
603
|
+
if (!url) return false;
|
|
604
|
+
if (!patterns.length) return true;
|
|
605
|
+
return patterns.some((pattern) => url.includes(pattern));
|
|
606
|
+
}
|
|
607
|
+
function normalizeHeaders(headers) {
|
|
608
|
+
if (!headers || typeof headers !== "object") return {};
|
|
609
|
+
const out = {};
|
|
610
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
611
|
+
out[String(key)] = String(value);
|
|
612
|
+
}
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) {
|
|
616
|
+
const state = networkCaptures.get(tabId);
|
|
617
|
+
if (!state) return null;
|
|
618
|
+
const existingIndex = state.requestToIndex.get(requestId);
|
|
619
|
+
if (existingIndex !== void 0) {
|
|
620
|
+
return state.entries[existingIndex] || null;
|
|
621
|
+
}
|
|
622
|
+
const url = fallback?.url || "";
|
|
623
|
+
if (!shouldCaptureUrl(url, state.patterns)) return null;
|
|
624
|
+
const entry = {
|
|
625
|
+
kind: "cdp",
|
|
626
|
+
requestId,
|
|
627
|
+
url,
|
|
628
|
+
method: fallback?.method || "GET",
|
|
629
|
+
requestHeaders: fallback?.requestHeaders || {},
|
|
630
|
+
timestamp: Date.now()
|
|
631
|
+
};
|
|
632
|
+
state.entries.push(entry);
|
|
633
|
+
state.requestToIndex.set(requestId, state.entries.length - 1);
|
|
634
|
+
return entry;
|
|
635
|
+
}
|
|
636
|
+
async function startNetworkCapture(tabId, pattern) {
|
|
637
|
+
await ensureAttached(tabId);
|
|
638
|
+
await sendDebuggerCommand({ tabId }, "Network.enable");
|
|
639
|
+
networkCaptures.set(tabId, {
|
|
640
|
+
patterns: normalizeCapturePatterns(pattern),
|
|
641
|
+
entries: [],
|
|
642
|
+
requestToIndex: /* @__PURE__ */ new Map()
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
async function readNetworkCapture(tabId, retainIncomplete = false) {
|
|
646
|
+
const state = networkCaptures.get(tabId);
|
|
647
|
+
if (!state) return [];
|
|
648
|
+
const entries = state.entries.map((entry) => ({ ...entry }));
|
|
649
|
+
if (!retainIncomplete) {
|
|
650
|
+
state.entries = [];
|
|
651
|
+
state.requestToIndex.clear();
|
|
652
|
+
return entries;
|
|
653
|
+
}
|
|
654
|
+
const pendingEntries = [];
|
|
655
|
+
const pendingMap = /* @__PURE__ */ new Map();
|
|
656
|
+
for (const [requestId, index] of state.requestToIndex) {
|
|
657
|
+
const entry = state.entries[index];
|
|
658
|
+
if (entry && !entry.captureComplete) {
|
|
659
|
+
pendingMap.set(requestId, pendingEntries.length);
|
|
660
|
+
pendingEntries.push(entry);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
state.entries = pendingEntries;
|
|
664
|
+
state.requestToIndex = pendingMap;
|
|
665
|
+
return entries;
|
|
666
|
+
}
|
|
667
|
+
function hasActiveNetworkCapture(tabId) {
|
|
668
|
+
return networkCaptures.has(tabId);
|
|
669
|
+
}
|
|
670
|
+
function clearFrameTargetsForTab(tabId) {
|
|
671
|
+
for (const [key, targetId] of [...frameTargets.entries()]) {
|
|
672
|
+
if (!key.startsWith(`${tabId}:`)) continue;
|
|
673
|
+
frameTargets.delete(key);
|
|
674
|
+
frameTargetKeys.delete(targetId);
|
|
675
|
+
chrome.debugger.detach({ targetId }).catch(() => {
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async function detach(tabId) {
|
|
680
|
+
clearFrameTargetsForTab(tabId);
|
|
681
|
+
if (!attached.has(tabId)) return;
|
|
682
|
+
attached.delete(tabId);
|
|
683
|
+
networkCaptures.delete(tabId);
|
|
684
|
+
tabFrameContexts.delete(tabId);
|
|
685
|
+
try {
|
|
686
|
+
await chrome.debugger.detach({ tabId });
|
|
687
|
+
} catch {
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function registerListeners() {
|
|
691
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
692
|
+
attached.delete(tabId);
|
|
693
|
+
networkCaptures.delete(tabId);
|
|
694
|
+
tabFrameContexts.delete(tabId);
|
|
695
|
+
clearFrameTargetsForTab(tabId);
|
|
696
|
+
});
|
|
697
|
+
chrome.debugger.onDetach.addListener((source) => {
|
|
698
|
+
if (source.tabId) {
|
|
699
|
+
attached.delete(source.tabId);
|
|
700
|
+
networkCaptures.delete(source.tabId);
|
|
701
|
+
tabFrameContexts.delete(source.tabId);
|
|
702
|
+
clearFrameTargetsForTab(source.tabId);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (source.targetId) clearFrameTarget(source.targetId);
|
|
706
|
+
});
|
|
707
|
+
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
|
708
|
+
if (info.url && !isDebuggableUrl$1(info.url)) {
|
|
709
|
+
await detach(tabId);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
chrome.debugger.onEvent.addListener(async (source, method, params) => {
|
|
713
|
+
const tabId = source.tabId;
|
|
714
|
+
if (!tabId) return;
|
|
715
|
+
const state = networkCaptures.get(tabId);
|
|
716
|
+
if (!state) return;
|
|
717
|
+
const eventParams = params;
|
|
718
|
+
if (method === "Network.requestWillBeSent") {
|
|
719
|
+
const requestId = String(eventParams?.requestId || "");
|
|
720
|
+
const request = eventParams?.request;
|
|
721
|
+
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
|
|
722
|
+
url: request?.url,
|
|
723
|
+
method: request?.method,
|
|
724
|
+
requestHeaders: normalizeHeaders(request?.headers)
|
|
725
|
+
});
|
|
726
|
+
if (!entry) return;
|
|
727
|
+
if (!eventParams?.redirectResponse) {
|
|
728
|
+
entry.requestBodyKind = request?.hasPostData ? "string" : "empty";
|
|
729
|
+
{
|
|
730
|
+
const raw = String(request?.postData || "");
|
|
731
|
+
const fullSize = raw.length;
|
|
732
|
+
const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT;
|
|
733
|
+
entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw;
|
|
734
|
+
entry.requestBodyFullSize = fullSize;
|
|
735
|
+
entry.requestBodyTruncated = truncated;
|
|
736
|
+
}
|
|
737
|
+
entry.requestBodyPending = true;
|
|
738
|
+
try {
|
|
739
|
+
const postData = await sendDebuggerCommand({ tabId }, "Network.getRequestPostData", { requestId });
|
|
740
|
+
if (postData?.postData) {
|
|
741
|
+
const raw = postData.postData;
|
|
742
|
+
const fullSize = raw.length;
|
|
743
|
+
const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT;
|
|
744
|
+
entry.requestBodyKind = "string";
|
|
745
|
+
entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw;
|
|
746
|
+
entry.requestBodyFullSize = fullSize;
|
|
747
|
+
entry.requestBodyTruncated = truncated;
|
|
748
|
+
}
|
|
749
|
+
} catch {
|
|
750
|
+
} finally {
|
|
751
|
+
entry.requestBodyPending = false;
|
|
752
|
+
if (entry.responseFinished) entry.captureComplete = true;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (method === "Network.responseReceived") {
|
|
758
|
+
const requestId = String(eventParams?.requestId || "");
|
|
759
|
+
const response = eventParams?.response;
|
|
760
|
+
const stateEntryIndex = state.requestToIndex.get(requestId);
|
|
761
|
+
if (stateEntryIndex === void 0) return;
|
|
762
|
+
const entry = state.entries[stateEntryIndex];
|
|
763
|
+
if (!entry) return;
|
|
764
|
+
entry.responseStatus = response?.status;
|
|
765
|
+
entry.responseContentType = response?.mimeType || "";
|
|
766
|
+
entry.responseHeaders = normalizeHeaders(response?.headers);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (method === "Network.loadingFinished") {
|
|
770
|
+
const requestId = String(eventParams?.requestId || "");
|
|
771
|
+
const stateEntryIndex = state.requestToIndex.get(requestId);
|
|
772
|
+
if (stateEntryIndex === void 0) return;
|
|
773
|
+
const entry = state.entries[stateEntryIndex];
|
|
774
|
+
if (!entry) return;
|
|
775
|
+
try {
|
|
776
|
+
const body = await sendDebuggerCommand({ tabId }, "Network.getResponseBody", { requestId });
|
|
777
|
+
if (typeof body?.body === "string") {
|
|
778
|
+
const fullSize = body.body.length;
|
|
779
|
+
const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT;
|
|
780
|
+
const stored = truncated ? body.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : body.body;
|
|
781
|
+
entry.responsePreview = body.base64Encoded ? `base64:${stored}` : stored;
|
|
782
|
+
entry.responseBodyFullSize = fullSize;
|
|
783
|
+
entry.responseBodyTruncated = truncated;
|
|
784
|
+
}
|
|
785
|
+
} catch {
|
|
786
|
+
} finally {
|
|
787
|
+
entry.responseFinished = true;
|
|
788
|
+
if (!entry.requestBodyPending) entry.captureComplete = true;
|
|
789
|
+
}
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (method === "Network.loadingFailed") {
|
|
793
|
+
const requestId = String(eventParams?.requestId || "");
|
|
794
|
+
const stateEntryIndex = state.requestToIndex.get(requestId);
|
|
795
|
+
if (stateEntryIndex === void 0) return;
|
|
796
|
+
const entry = state.entries[stateEntryIndex];
|
|
797
|
+
if (entry) {
|
|
798
|
+
entry.responseFinished = true;
|
|
799
|
+
if (!entry.requestBodyPending) entry.captureComplete = true;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const targetToTab = /* @__PURE__ */ new Map();
|
|
806
|
+
const tabToTarget = /* @__PURE__ */ new Map();
|
|
807
|
+
async function resolveTargetId(tabId) {
|
|
808
|
+
const cached = tabToTarget.get(tabId);
|
|
809
|
+
if (cached) return cached;
|
|
810
|
+
await refreshMappings();
|
|
811
|
+
const result = tabToTarget.get(tabId);
|
|
812
|
+
if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`);
|
|
813
|
+
return result;
|
|
814
|
+
}
|
|
815
|
+
async function resolveTabId$1(targetId) {
|
|
816
|
+
const cached = targetToTab.get(targetId);
|
|
817
|
+
if (cached !== void 0) return cached;
|
|
818
|
+
await refreshMappings();
|
|
819
|
+
const result = targetToTab.get(targetId);
|
|
820
|
+
if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`);
|
|
821
|
+
return result;
|
|
822
|
+
}
|
|
823
|
+
function evictTab(tabId) {
|
|
824
|
+
const targetId = tabToTarget.get(tabId);
|
|
825
|
+
if (targetId) targetToTab.delete(targetId);
|
|
826
|
+
tabToTarget.delete(tabId);
|
|
827
|
+
}
|
|
828
|
+
async function refreshMappings() {
|
|
829
|
+
const targets = await chrome.debugger.getTargets();
|
|
830
|
+
targetToTab.clear();
|
|
831
|
+
tabToTarget.clear();
|
|
832
|
+
for (const t of targets) {
|
|
833
|
+
if (t.type === "page" && t.tabId !== void 0) {
|
|
834
|
+
targetToTab.set(t.id, t.tabId);
|
|
835
|
+
tabToTarget.set(t.tabId, t.id);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const JOURNAL_KEY = "opencli_command_journal_v1";
|
|
841
|
+
const JOURNAL_MAX_ENTRIES = 64;
|
|
842
|
+
const JOURNAL_RESULT_MAX_BYTES = 64 * 1024;
|
|
843
|
+
let cache = null;
|
|
844
|
+
let writeQueue = Promise.resolve();
|
|
845
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
846
|
+
async function load() {
|
|
847
|
+
if (cache) return cache;
|
|
848
|
+
try {
|
|
849
|
+
const stored = await chrome.storage.session.get(JOURNAL_KEY);
|
|
850
|
+
cache = stored?.[JOURNAL_KEY] ?? {};
|
|
851
|
+
} catch {
|
|
852
|
+
cache = {};
|
|
853
|
+
}
|
|
854
|
+
return cache;
|
|
855
|
+
}
|
|
856
|
+
function persist() {
|
|
857
|
+
const snapshot = cache;
|
|
858
|
+
if (!snapshot) return;
|
|
859
|
+
writeQueue = writeQueue.then(async () => {
|
|
860
|
+
try {
|
|
861
|
+
await chrome.storage.session.set({ [JOURNAL_KEY]: snapshot });
|
|
862
|
+
} catch {
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
function trim(journal) {
|
|
867
|
+
const ids = Object.keys(journal);
|
|
868
|
+
if (ids.length <= JOURNAL_MAX_ENTRIES) return;
|
|
869
|
+
ids.sort((a, b) => journal[a].ts - journal[b].ts);
|
|
870
|
+
for (const id of ids.slice(0, ids.length - JOURNAL_MAX_ENTRIES)) delete journal[id];
|
|
871
|
+
}
|
|
872
|
+
function resultByteLength(result) {
|
|
873
|
+
try {
|
|
874
|
+
return JSON.stringify(result).length;
|
|
875
|
+
} catch {
|
|
876
|
+
return Number.POSITIVE_INFINITY;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const UNKNOWN_OUTCOME_HINT = "Inspect the browser/session state before retrying. Do not blindly re-run write commands such as navigate, click, type, or eval.";
|
|
880
|
+
async function executeWithJournal(cmd, execute) {
|
|
881
|
+
const id = cmd.id;
|
|
882
|
+
if (!id) return execute(cmd);
|
|
883
|
+
const running = inFlight.get(id);
|
|
884
|
+
if (running) return running;
|
|
885
|
+
const run = (async () => {
|
|
886
|
+
const journal = await load();
|
|
887
|
+
const entry = journal[id];
|
|
888
|
+
if (entry?.status === "done") {
|
|
889
|
+
if (entry.result) return entry.result;
|
|
890
|
+
return {
|
|
891
|
+
id,
|
|
892
|
+
ok: false,
|
|
893
|
+
errorCode: "result_evicted",
|
|
894
|
+
error: "Command already executed, but its result was too large to record for replay.",
|
|
895
|
+
errorHint: UNKNOWN_OUTCOME_HINT
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
if (entry?.status === "started") {
|
|
899
|
+
return {
|
|
900
|
+
id,
|
|
901
|
+
ok: false,
|
|
902
|
+
errorCode: "command_lost",
|
|
903
|
+
error: "Command was interrupted mid-execution (extension or browser restarted); it may or may not have applied.",
|
|
904
|
+
errorHint: UNKNOWN_OUTCOME_HINT
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
journal[id] = { status: "started", ts: Date.now() };
|
|
908
|
+
trim(journal);
|
|
909
|
+
persist();
|
|
910
|
+
let result;
|
|
911
|
+
try {
|
|
912
|
+
result = await execute(cmd);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
result = { id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
915
|
+
}
|
|
916
|
+
journal[id] = resultByteLength(result) <= JOURNAL_RESULT_MAX_BYTES ? { status: "done", ts: Date.now(), result } : { status: "done", ts: Date.now() };
|
|
917
|
+
persist();
|
|
918
|
+
return result;
|
|
919
|
+
})();
|
|
920
|
+
inFlight.set(id, run);
|
|
921
|
+
try {
|
|
922
|
+
return await run;
|
|
923
|
+
} finally {
|
|
924
|
+
inFlight.delete(id);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
let ws = null;
|
|
929
|
+
let reconnectTimer = null;
|
|
930
|
+
let reconnectAttempts = 0;
|
|
931
|
+
const CONTEXT_ID_KEY = "opencli_context_id_v1";
|
|
932
|
+
let currentContextId = "default";
|
|
933
|
+
let contextIdPromise = null;
|
|
934
|
+
let connectInFlight = null;
|
|
935
|
+
let workerReady = Promise.resolve();
|
|
936
|
+
let workerRecovered = true;
|
|
937
|
+
async function getCurrentContextId() {
|
|
938
|
+
if (contextIdPromise) return contextIdPromise;
|
|
939
|
+
contextIdPromise = (async () => {
|
|
940
|
+
try {
|
|
941
|
+
const local = chrome.storage?.local;
|
|
942
|
+
if (!local) return currentContextId;
|
|
943
|
+
const raw = await local.get(CONTEXT_ID_KEY);
|
|
944
|
+
const existing = raw[CONTEXT_ID_KEY];
|
|
945
|
+
if (typeof existing === "string" && existing.trim()) {
|
|
946
|
+
currentContextId = existing.trim();
|
|
947
|
+
return currentContextId;
|
|
948
|
+
}
|
|
949
|
+
const generated = "openmd"; // Openmd patch: fixed Browser Bridge profile id
|
|
950
|
+
await local.set({ [CONTEXT_ID_KEY]: generated });
|
|
951
|
+
currentContextId = generated;
|
|
952
|
+
return currentContextId;
|
|
953
|
+
} catch {
|
|
954
|
+
return currentContextId;
|
|
955
|
+
}
|
|
956
|
+
})();
|
|
957
|
+
return contextIdPromise;
|
|
958
|
+
}
|
|
959
|
+
function generateContextId() {
|
|
960
|
+
const alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
|
|
961
|
+
const maxUnbiasedByte = Math.floor(256 / alphabet.length) * alphabet.length;
|
|
962
|
+
let id = "";
|
|
963
|
+
while (id.length < 8) {
|
|
964
|
+
const bytes = new Uint8Array(8);
|
|
965
|
+
try {
|
|
966
|
+
crypto.getRandomValues(bytes);
|
|
967
|
+
} catch {
|
|
968
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
969
|
+
}
|
|
970
|
+
for (const byte of bytes) {
|
|
971
|
+
if (byte >= maxUnbiasedByte) continue;
|
|
972
|
+
id += alphabet[byte % alphabet.length];
|
|
973
|
+
if (id.length === 8) break;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return id;
|
|
977
|
+
}
|
|
978
|
+
const _origLog = console.log.bind(console);
|
|
979
|
+
const _origWarn = console.warn.bind(console);
|
|
980
|
+
const _origError = console.error.bind(console);
|
|
981
|
+
function forwardLog(level, args) {
|
|
982
|
+
try {
|
|
983
|
+
const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
|
|
984
|
+
safeSend(ws, { type: "log", level, msg, ts: Date.now() });
|
|
985
|
+
} catch {
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
function safeSend(socket, payload) {
|
|
989
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
|
|
990
|
+
try {
|
|
991
|
+
socket.send(JSON.stringify(payload));
|
|
992
|
+
return true;
|
|
993
|
+
} catch {
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
console.log = (...args) => {
|
|
998
|
+
_origLog(...args);
|
|
999
|
+
forwardLog("info", args);
|
|
1000
|
+
};
|
|
1001
|
+
console.warn = (...args) => {
|
|
1002
|
+
_origWarn(...args);
|
|
1003
|
+
forwardLog("warn", args);
|
|
1004
|
+
};
|
|
1005
|
+
console.error = (...args) => {
|
|
1006
|
+
_origError(...args);
|
|
1007
|
+
forwardLog("error", args);
|
|
1008
|
+
};
|
|
1009
|
+
function isDaemonSocketActive(socket = ws) {
|
|
1010
|
+
return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING;
|
|
1011
|
+
}
|
|
1012
|
+
function connect() {
|
|
1013
|
+
if (isDaemonSocketActive()) return Promise.resolve();
|
|
1014
|
+
if (connectInFlight) return connectInFlight;
|
|
1015
|
+
const attempt = workerRecovered ? connectAttempt() : workerReady.then(() => connectAttempt());
|
|
1016
|
+
connectInFlight = attempt.finally(() => {
|
|
1017
|
+
connectInFlight = null;
|
|
1018
|
+
});
|
|
1019
|
+
return connectInFlight;
|
|
1020
|
+
}
|
|
1021
|
+
async function connectAttempt() {
|
|
1022
|
+
if (isDaemonSocketActive()) return;
|
|
1023
|
+
try {
|
|
1024
|
+
const res = await fetch(DAEMON_PING_URL, {
|
|
1025
|
+
signal: AbortSignal.timeout(1e3),
|
|
1026
|
+
credentials: "omit"
|
|
1027
|
+
});
|
|
1028
|
+
if (!res.ok) {
|
|
1029
|
+
console.warn(`[opencli] daemon ping failed: HTTP ${res.status}`);
|
|
1030
|
+
scheduleReconnect();
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
reconnectAttempts = 0;
|
|
1034
|
+
} catch {
|
|
1035
|
+
scheduleReconnect();
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (isDaemonSocketActive()) return;
|
|
1039
|
+
let thisWs;
|
|
1040
|
+
try {
|
|
1041
|
+
const contextId = await getCurrentContextId();
|
|
1042
|
+
if (isDaemonSocketActive()) return;
|
|
1043
|
+
thisWs = new WebSocket(DAEMON_WS_URL);
|
|
1044
|
+
ws = thisWs;
|
|
1045
|
+
currentContextId = contextId;
|
|
1046
|
+
} catch {
|
|
1047
|
+
scheduleReconnect();
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
thisWs.onopen = () => {
|
|
1051
|
+
if (ws !== thisWs) return;
|
|
1052
|
+
console.log("[opencli] Connected to daemon");
|
|
1053
|
+
reconnectAttempts = 0;
|
|
1054
|
+
if (reconnectTimer) {
|
|
1055
|
+
clearTimeout(reconnectTimer);
|
|
1056
|
+
reconnectTimer = null;
|
|
1057
|
+
}
|
|
1058
|
+
safeSend(thisWs, {
|
|
1059
|
+
type: "hello",
|
|
1060
|
+
contextId: currentContextId,
|
|
1061
|
+
version: OPENMD_BRIDGE_VERSION,
|
|
1062
|
+
compatRange: ">=1.7.0"
|
|
1063
|
+
});
|
|
1064
|
+
startWsKeepalive(thisWs);
|
|
1065
|
+
};
|
|
1066
|
+
thisWs.onmessage = async (event) => {
|
|
1067
|
+
if (ws !== thisWs) return;
|
|
1068
|
+
try {
|
|
1069
|
+
const command = JSON.parse(event.data);
|
|
1070
|
+
const result = await executeWithJournal(command, handleCommand);
|
|
1071
|
+
const target = ws && ws.readyState === WebSocket.OPEN ? ws : thisWs;
|
|
1072
|
+
safeSend(target, result);
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
console.error("[opencli] Message handling error:", err);
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
thisWs.onclose = () => {
|
|
1078
|
+
stopWsKeepalive(thisWs);
|
|
1079
|
+
if (ws !== thisWs) return;
|
|
1080
|
+
console.log("[opencli] Disconnected from daemon");
|
|
1081
|
+
ws = null;
|
|
1082
|
+
scheduleReconnect();
|
|
1083
|
+
};
|
|
1084
|
+
thisWs.onerror = () => {
|
|
1085
|
+
thisWs.close();
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
const WS_KEEPALIVE_INTERVAL_MS = 2e4;
|
|
1089
|
+
let wsKeepaliveTimer = null;
|
|
1090
|
+
let wsKeepaliveSocket = null;
|
|
1091
|
+
function startWsKeepalive(socket) {
|
|
1092
|
+
if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer);
|
|
1093
|
+
wsKeepaliveSocket = socket;
|
|
1094
|
+
wsKeepaliveTimer = setInterval(() => {
|
|
1095
|
+
if (socket !== ws || socket.readyState !== WebSocket.OPEN) {
|
|
1096
|
+
stopWsKeepalive(socket);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
safeSend(socket, { type: "ping", ts: Date.now() });
|
|
1100
|
+
}, WS_KEEPALIVE_INTERVAL_MS);
|
|
1101
|
+
}
|
|
1102
|
+
function stopWsKeepalive(socket) {
|
|
1103
|
+
if (wsKeepaliveSocket !== socket) return;
|
|
1104
|
+
if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer);
|
|
1105
|
+
wsKeepaliveTimer = null;
|
|
1106
|
+
wsKeepaliveSocket = null;
|
|
1107
|
+
}
|
|
1108
|
+
const RECONNECT_BASE_DELAY_MS = 1e3;
|
|
1109
|
+
const RECONNECT_MAX_DELAY_MS = 15e3;
|
|
1110
|
+
function nextReconnectDelayMs() {
|
|
1111
|
+
const exp = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.min(reconnectAttempts, 6));
|
|
1112
|
+
return exp + Math.floor(Math.random() * 500);
|
|
1113
|
+
}
|
|
1114
|
+
function scheduleReconnect() {
|
|
1115
|
+
if (reconnectTimer) return;
|
|
1116
|
+
const delay = nextReconnectDelayMs();
|
|
1117
|
+
reconnectAttempts++;
|
|
1118
|
+
reconnectTimer = setTimeout(() => {
|
|
1119
|
+
reconnectTimer = null;
|
|
1120
|
+
void connect();
|
|
1121
|
+
}, delay);
|
|
1122
|
+
}
|
|
1123
|
+
const automationSessions = /* @__PURE__ */ new Map();
|
|
1124
|
+
const IDLE_TIMEOUT_DEFAULT = 3e4;
|
|
1125
|
+
const IDLE_TIMEOUT_INTERACTIVE = 6e5;
|
|
1126
|
+
const IDLE_TIMEOUT_NONE = -1;
|
|
1127
|
+
const REGISTRY_KEY = "opencli_target_lease_registry_v2";
|
|
1128
|
+
const LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:";
|
|
1129
|
+
const CONTAINER_TAB_GROUP_TITLE = {
|
|
1130
|
+
interactive: "Openmd",
|
|
1131
|
+
// Retained for registry/type compatibility. Adapter automation no longer
|
|
1132
|
+
// creates or discovers a visible tab group.
|
|
1133
|
+
automation: "Openmd"
|
|
1134
|
+
};
|
|
1135
|
+
const OWNED_TAB_GROUP_COLOR = "orange";
|
|
1136
|
+
let leaseMutationQueue = Promise.resolve();
|
|
1137
|
+
const ownedContainers = {
|
|
1138
|
+
interactive: { windowId: null, groupId: null, promise: null, groupPromise: null },
|
|
1139
|
+
automation: { windowId: null, groupId: null, promise: null, groupPromise: null }
|
|
1140
|
+
};
|
|
1141
|
+
const interactiveGroupLedger = /* @__PURE__ */ new Set();
|
|
1142
|
+
class CommandFailure extends Error {
|
|
1143
|
+
constructor(code, message, hint) {
|
|
1144
|
+
super(message);
|
|
1145
|
+
this.code = code;
|
|
1146
|
+
this.hint = hint;
|
|
1147
|
+
this.name = "CommandFailure";
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
const sessionOverrides = /* @__PURE__ */ new Map();
|
|
1151
|
+
function setSessionOverride(key, patch) {
|
|
1152
|
+
sessionOverrides.set(key, { ...sessionOverrides.get(key), ...patch });
|
|
1153
|
+
}
|
|
1154
|
+
const activeCommandCounts = /* @__PURE__ */ new Map();
|
|
1155
|
+
const closingLeases = /* @__PURE__ */ new Set();
|
|
1156
|
+
const closingTabs = /* @__PURE__ */ new Set();
|
|
1157
|
+
const LEASE_KEY_SEPARATOR = "\0";
|
|
1158
|
+
function getLeaseKey(session, surface) {
|
|
1159
|
+
return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`;
|
|
1160
|
+
}
|
|
1161
|
+
function getSessionName(session) {
|
|
1162
|
+
const raw = session?.trim();
|
|
1163
|
+
if (!raw) throw new CommandFailure(
|
|
1164
|
+
"session_required",
|
|
1165
|
+
"Browser session is required.",
|
|
1166
|
+
"Pass a browser session name, e.g. opencli browser <session> <command>."
|
|
1167
|
+
);
|
|
1168
|
+
return raw;
|
|
1169
|
+
}
|
|
1170
|
+
function getCommandSurface(cmd) {
|
|
1171
|
+
return cmd.surface === "adapter" ? "adapter" : "browser";
|
|
1172
|
+
}
|
|
1173
|
+
function getSurfaceFromKey(key) {
|
|
1174
|
+
return key.split(LEASE_KEY_SEPARATOR, 1)[0] === "adapter" ? "adapter" : "browser";
|
|
1175
|
+
}
|
|
1176
|
+
function getSessionFromKey(key) {
|
|
1177
|
+
const idx = key.indexOf(LEASE_KEY_SEPARATOR);
|
|
1178
|
+
if (idx === -1) return key;
|
|
1179
|
+
try {
|
|
1180
|
+
return decodeURIComponent(key.slice(idx + 1));
|
|
1181
|
+
} catch {
|
|
1182
|
+
return key.slice(idx + 1);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function getIdleTimeout(key) {
|
|
1186
|
+
const session = automationSessions.get(key);
|
|
1187
|
+
if (session?.kind === "bound") return IDLE_TIMEOUT_NONE;
|
|
1188
|
+
const overrides = sessionOverrides.get(key);
|
|
1189
|
+
const adapterPersistent = getSurfaceFromKey(key) === "adapter" && (session?.lifecycle === "persistent" || overrides?.lifecycle === "persistent");
|
|
1190
|
+
if (adapterPersistent) return IDLE_TIMEOUT_NONE;
|
|
1191
|
+
if (overrides?.idleTimeoutMs !== void 0) return overrides.idleTimeoutMs;
|
|
1192
|
+
return getSurfaceFromKey(key) === "browser" ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT;
|
|
1193
|
+
}
|
|
1194
|
+
function getLeaseLifecycle(key, kind) {
|
|
1195
|
+
if (kind === "bound") return "pinned";
|
|
1196
|
+
const override = sessionOverrides.get(key)?.lifecycle;
|
|
1197
|
+
if (override) return override;
|
|
1198
|
+
return getSurfaceFromKey(key) === "browser" ? "persistent" : "ephemeral";
|
|
1199
|
+
}
|
|
1200
|
+
function getOwnedWindowRole(key) {
|
|
1201
|
+
return getSurfaceFromKey(key) === "browser" ? "interactive" : "automation";
|
|
1202
|
+
}
|
|
1203
|
+
function getWindowRole(key, ownership) {
|
|
1204
|
+
return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(key);
|
|
1205
|
+
}
|
|
1206
|
+
function getWindowMode(key) {
|
|
1207
|
+
return sessionOverrides.get(key)?.windowMode ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background");
|
|
1208
|
+
}
|
|
1209
|
+
function makeAlarmName(leaseKey) {
|
|
1210
|
+
return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`;
|
|
1211
|
+
}
|
|
1212
|
+
function leaseKeyFromAlarmName(name) {
|
|
1213
|
+
if (!name.startsWith(LEASE_IDLE_ALARM_PREFIX)) return null;
|
|
1214
|
+
try {
|
|
1215
|
+
return decodeURIComponent(name.slice(LEASE_IDLE_ALARM_PREFIX.length));
|
|
1216
|
+
} catch {
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function withLeaseMutation(fn) {
|
|
1221
|
+
const run = leaseMutationQueue.then(fn, fn);
|
|
1222
|
+
leaseMutationQueue = run.then(() => void 0, () => void 0);
|
|
1223
|
+
return run;
|
|
1224
|
+
}
|
|
1225
|
+
function makeSession(key, session) {
|
|
1226
|
+
const ownership = session.owned ? "owned" : "borrowed";
|
|
1227
|
+
return {
|
|
1228
|
+
...session,
|
|
1229
|
+
contextId: currentContextId,
|
|
1230
|
+
ownership,
|
|
1231
|
+
lifecycle: getLeaseLifecycle(key, session.kind),
|
|
1232
|
+
windowRole: getWindowRole(key, ownership)
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
function emptyRegistry() {
|
|
1236
|
+
return {
|
|
1237
|
+
version: 2,
|
|
1238
|
+
contextId: currentContextId,
|
|
1239
|
+
ownedContainers: {
|
|
1240
|
+
interactive: {
|
|
1241
|
+
windowId: ownedContainers.interactive.windowId,
|
|
1242
|
+
groupIds: [...interactiveGroupLedger]
|
|
1243
|
+
},
|
|
1244
|
+
automation: { windowId: ownedContainers.automation.windowId }
|
|
1245
|
+
},
|
|
1246
|
+
leases: {}
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
async function readRegistry() {
|
|
1250
|
+
try {
|
|
1251
|
+
const session = chrome.storage?.session;
|
|
1252
|
+
if (!session) return emptyRegistry();
|
|
1253
|
+
const raw = await session.get(REGISTRY_KEY);
|
|
1254
|
+
const stored = raw[REGISTRY_KEY];
|
|
1255
|
+
if (!stored || stored.version !== 2 || typeof stored.leases !== "object") return emptyRegistry();
|
|
1256
|
+
const storedContainers = stored.ownedContainers && typeof stored.ownedContainers === "object" ? stored.ownedContainers : emptyRegistry().ownedContainers;
|
|
1257
|
+
return {
|
|
1258
|
+
version: 2,
|
|
1259
|
+
contextId: currentContextId,
|
|
1260
|
+
ownedContainers: {
|
|
1261
|
+
interactive: {
|
|
1262
|
+
windowId: typeof storedContainers.interactive?.windowId === "number" ? storedContainers.interactive.windowId : null,
|
|
1263
|
+
groupIds: Array.isArray(storedContainers.interactive?.groupIds) ? storedContainers.interactive.groupIds.filter((id) => typeof id === "number") : []
|
|
1264
|
+
},
|
|
1265
|
+
automation: {
|
|
1266
|
+
windowId: typeof storedContainers.automation?.windowId === "number" ? storedContainers.automation.windowId : null
|
|
1267
|
+
}
|
|
1268
|
+
},
|
|
1269
|
+
leases: stored.leases
|
|
1270
|
+
};
|
|
1271
|
+
} catch {
|
|
1272
|
+
return emptyRegistry();
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
async function writeRegistry(registry) {
|
|
1276
|
+
try {
|
|
1277
|
+
await chrome.storage?.session?.set({ [REGISTRY_KEY]: registry });
|
|
1278
|
+
} catch {
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
async function persistRuntimeState() {
|
|
1282
|
+
const leases = {};
|
|
1283
|
+
for (const [leaseKey, session] of automationSessions.entries()) {
|
|
1284
|
+
leases[leaseKey] = {
|
|
1285
|
+
session: session.session,
|
|
1286
|
+
surface: session.surface,
|
|
1287
|
+
kind: session.kind,
|
|
1288
|
+
windowId: session.windowId,
|
|
1289
|
+
owned: session.owned,
|
|
1290
|
+
preferredTabId: session.preferredTabId,
|
|
1291
|
+
contextId: session.contextId,
|
|
1292
|
+
ownership: session.ownership,
|
|
1293
|
+
lifecycle: session.lifecycle,
|
|
1294
|
+
windowRole: session.windowRole,
|
|
1295
|
+
idleDeadlineAt: session.idleDeadlineAt,
|
|
1296
|
+
updatedAt: Date.now()
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
await writeRegistry({
|
|
1300
|
+
version: 2,
|
|
1301
|
+
contextId: currentContextId,
|
|
1302
|
+
ownedContainers: {
|
|
1303
|
+
interactive: {
|
|
1304
|
+
windowId: ownedContainers.interactive.windowId,
|
|
1305
|
+
groupIds: [...interactiveGroupLedger]
|
|
1306
|
+
},
|
|
1307
|
+
automation: { windowId: ownedContainers.automation.windowId }
|
|
1308
|
+
},
|
|
1309
|
+
leases
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
function scheduleIdleAlarm(leaseKey, timeout) {
|
|
1313
|
+
const alarmName = makeAlarmName(leaseKey);
|
|
1314
|
+
try {
|
|
1315
|
+
if (timeout > 0) {
|
|
1316
|
+
chrome.alarms?.create?.(alarmName, { when: Date.now() + timeout });
|
|
1317
|
+
} else {
|
|
1318
|
+
chrome.alarms?.clear?.(alarmName);
|
|
1319
|
+
}
|
|
1320
|
+
} catch {
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async function safeDetach(tabId) {
|
|
1324
|
+
try {
|
|
1325
|
+
const detach$1 = detach;
|
|
1326
|
+
if (typeof detach$1 === "function") await detach$1(tabId);
|
|
1327
|
+
} catch {
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
async function removeLeaseSession(leaseKey) {
|
|
1331
|
+
const existing = automationSessions.get(leaseKey);
|
|
1332
|
+
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
|
|
1333
|
+
automationSessions.delete(leaseKey);
|
|
1334
|
+
sessionOverrides.delete(leaseKey);
|
|
1335
|
+
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
|
|
1336
|
+
await persistRuntimeState();
|
|
1337
|
+
}
|
|
1338
|
+
function resetWindowIdleTimer(leaseKey, remainingMs) {
|
|
1339
|
+
const session = automationSessions.get(leaseKey);
|
|
1340
|
+
if (!session) return;
|
|
1341
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
1342
|
+
const timeout = getIdleTimeout(leaseKey);
|
|
1343
|
+
if (timeout <= 0) {
|
|
1344
|
+
scheduleIdleAlarm(leaseKey, timeout);
|
|
1345
|
+
session.idleTimer = null;
|
|
1346
|
+
session.idleDeadlineAt = 0;
|
|
1347
|
+
void persistRuntimeState();
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
const interval = remainingMs === void 0 ? timeout : Math.max(0, Math.min(remainingMs, timeout));
|
|
1351
|
+
scheduleIdleAlarm(leaseKey, interval);
|
|
1352
|
+
session.idleDeadlineAt = Date.now() + interval;
|
|
1353
|
+
void persistRuntimeState();
|
|
1354
|
+
session.idleTimer = setTimeout(async () => {
|
|
1355
|
+
if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) {
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
await releaseLease(leaseKey, "idle timeout");
|
|
1359
|
+
}, interval);
|
|
1360
|
+
}
|
|
1361
|
+
async function focusOwnedWindowIfRequested(windowId, mode) {
|
|
1362
|
+
if (mode !== "foreground") return;
|
|
1363
|
+
const updateWindow = chrome.windows.update;
|
|
1364
|
+
if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => {
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
async function toOwnedContainerGroupCandidate(group) {
|
|
1368
|
+
let chromeWindow;
|
|
1369
|
+
try {
|
|
1370
|
+
chromeWindow = await chrome.windows.get(group.windowId);
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
if (err instanceof Error && classifyExtensionError(err.message) === "tab_gone") return null;
|
|
1373
|
+
throw err;
|
|
1374
|
+
}
|
|
1375
|
+
const reusableTabId = await findReusableOwnedContainerTab(group.windowId, group.id);
|
|
1376
|
+
return {
|
|
1377
|
+
id: group.id,
|
|
1378
|
+
windowId: group.windowId,
|
|
1379
|
+
title: group.title,
|
|
1380
|
+
focused: !!chromeWindow.focused,
|
|
1381
|
+
hasReusableTab: reusableTabId !== void 0
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
function selectOwnedContainerGroupCandidate(candidates) {
|
|
1385
|
+
if (candidates.length === 0) return null;
|
|
1386
|
+
return [...candidates].sort((a, b) => {
|
|
1387
|
+
if (a.focused !== b.focused) return a.focused ? -1 : 1;
|
|
1388
|
+
if (a.hasReusableTab !== b.hasReusableTab) return a.hasReusableTab ? -1 : 1;
|
|
1389
|
+
if (a.windowId !== b.windowId) return a.windowId - b.windowId;
|
|
1390
|
+
return a.id - b.id;
|
|
1391
|
+
})[0];
|
|
1392
|
+
}
|
|
1393
|
+
function isMissingTabGroupError(err) {
|
|
1394
|
+
return err instanceof Error && /No group with id|group no longer exists/i.test(err.message);
|
|
1395
|
+
}
|
|
1396
|
+
async function collectOwnedGroupCandidates(role) {
|
|
1397
|
+
if (role === "automation") return [];
|
|
1398
|
+
const container = ownedContainers[role];
|
|
1399
|
+
const groupsById = /* @__PURE__ */ new Map();
|
|
1400
|
+
if (container.groupId !== null) {
|
|
1401
|
+
try {
|
|
1402
|
+
const group = await chrome.tabGroups.get(container.groupId);
|
|
1403
|
+
groupsById.set(group.id, group);
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
if (!isMissingTabGroupError(err)) throw err;
|
|
1406
|
+
container.groupId = null;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
let ledgerPruned = false;
|
|
1410
|
+
for (const groupId of [...interactiveGroupLedger]) {
|
|
1411
|
+
if (groupsById.has(groupId)) continue;
|
|
1412
|
+
try {
|
|
1413
|
+
const group = await chrome.tabGroups.get(groupId);
|
|
1414
|
+
groupsById.set(group.id, group);
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
if (!isMissingTabGroupError(err)) throw err;
|
|
1417
|
+
interactiveGroupLedger.delete(groupId);
|
|
1418
|
+
ledgerPruned = true;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (ledgerPruned) await persistRuntimeState();
|
|
1422
|
+
for (const [leaseKey, session] of automationSessions.entries()) {
|
|
1423
|
+
if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue;
|
|
1424
|
+
let tab;
|
|
1425
|
+
try {
|
|
1426
|
+
tab = await chrome.tabs.get(session.preferredTabId);
|
|
1427
|
+
} catch (err) {
|
|
1428
|
+
if (err instanceof Error && classifyExtensionError(err.message) === "tab_gone") continue;
|
|
1429
|
+
throw err;
|
|
1430
|
+
}
|
|
1431
|
+
const groupId = tab.groupId;
|
|
1432
|
+
if (typeof groupId !== "number" || groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) continue;
|
|
1433
|
+
try {
|
|
1434
|
+
const group = await chrome.tabGroups.get(groupId);
|
|
1435
|
+
groupsById.set(group.id, group);
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
if (!isMissingTabGroupError(err)) throw err;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
const ownedPreferredTabIds = /* @__PURE__ */ new Set();
|
|
1441
|
+
for (const [leaseKey, session] of automationSessions.entries()) {
|
|
1442
|
+
if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue;
|
|
1443
|
+
ownedPreferredTabIds.add(session.preferredTabId);
|
|
1444
|
+
}
|
|
1445
|
+
if (ownedPreferredTabIds.size > 0) {
|
|
1446
|
+
const allGroups = await chrome.tabGroups.query({});
|
|
1447
|
+
for (const group of allGroups) {
|
|
1448
|
+
if (group.title) continue;
|
|
1449
|
+
if (groupsById.has(group.id)) continue;
|
|
1450
|
+
const tabsInGroup = await chrome.tabs.query({ groupId: group.id });
|
|
1451
|
+
if (tabsInGroup.some((tab) => tab.id !== void 0 && ownedPreferredTabIds.has(tab.id))) {
|
|
1452
|
+
groupsById.set(group.id, group);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
if (groupsById.size === 0) {
|
|
1457
|
+
const reservedTabs = new Set([...automationSessions.values()].flatMap((session) => session.preferredTabId === null ? [] : [session.preferredTabId]));
|
|
1458
|
+
const idleGroup = await findIdleInteractiveGroup(reservedTabs);
|
|
1459
|
+
if (idleGroup) groupsById.set(idleGroup.id, idleGroup);
|
|
1460
|
+
}
|
|
1461
|
+
const candidates = await Promise.all([...groupsById.values()].map(toOwnedContainerGroupCandidate));
|
|
1462
|
+
return candidates.filter((candidate) => candidate !== null);
|
|
1463
|
+
}
|
|
1464
|
+
function updateOwnedSessionWindowForTabs(role, tabIds, windowId) {
|
|
1465
|
+
const moved = new Set(tabIds);
|
|
1466
|
+
for (const [leaseKey, session] of automationSessions.entries()) {
|
|
1467
|
+
if (!session.owned || getOwnedWindowRole(leaseKey) !== role) continue;
|
|
1468
|
+
if (session.preferredTabId !== null && moved.has(session.preferredTabId)) {
|
|
1469
|
+
session.windowId = windowId;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
async function ensureTabsInWindow(tabIds, windowId) {
|
|
1474
|
+
const movedIds = [];
|
|
1475
|
+
for (const tabId of tabIds) {
|
|
1476
|
+
try {
|
|
1477
|
+
const tab = await chrome.tabs.get(tabId);
|
|
1478
|
+
if (tab.windowId !== windowId) {
|
|
1479
|
+
await chrome.tabs.move(tabId, { windowId, index: -1 });
|
|
1480
|
+
movedIds.push(tabId);
|
|
1481
|
+
}
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
if (!(err instanceof Error) || classifyExtensionError(err.message) !== "tab_gone") throw err;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return movedIds;
|
|
1487
|
+
}
|
|
1488
|
+
async function ensureCanonicalGroupTitle(role, group) {
|
|
1489
|
+
const canonicalTitle = CONTAINER_TAB_GROUP_TITLE[role];
|
|
1490
|
+
if (group.title === canonicalTitle) return group;
|
|
1491
|
+
const updated = await chrome.tabGroups.update(group.id, {
|
|
1492
|
+
title: canonicalTitle,
|
|
1493
|
+
color: OWNED_TAB_GROUP_COLOR
|
|
1494
|
+
});
|
|
1495
|
+
return { id: updated.id, windowId: updated.windowId, title: updated.title };
|
|
1496
|
+
}
|
|
1497
|
+
async function convergeOwnedGroupDuplicates(role, canonical, candidates) {
|
|
1498
|
+
for (const duplicate of candidates) {
|
|
1499
|
+
if (duplicate.id === canonical.id) continue;
|
|
1500
|
+
const tabs = await chrome.tabs.query({ groupId: duplicate.id });
|
|
1501
|
+
const tabIds = tabs.map((tab) => tab.id).filter((id) => id !== void 0);
|
|
1502
|
+
if (tabIds.length === 0) continue;
|
|
1503
|
+
await ensureTabsInWindow(tabIds, canonical.windowId);
|
|
1504
|
+
await chrome.tabs.group({ groupId: canonical.id, tabIds });
|
|
1505
|
+
updateOwnedSessionWindowForTabs(role, tabIds, canonical.windowId);
|
|
1506
|
+
}
|
|
1507
|
+
return canonical;
|
|
1508
|
+
}
|
|
1509
|
+
async function attachTabsToOwnedGroup(role, group, ids) {
|
|
1510
|
+
if (ids.length === 0) return group;
|
|
1511
|
+
await ensureTabsInWindow(ids, group.windowId);
|
|
1512
|
+
const tabs = await Promise.all(ids.map(async (id) => {
|
|
1513
|
+
try {
|
|
1514
|
+
return await chrome.tabs.get(id);
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
if (err instanceof Error && classifyExtensionError(err.message) === "tab_gone") return null;
|
|
1517
|
+
throw err;
|
|
1518
|
+
}
|
|
1519
|
+
}));
|
|
1520
|
+
const missing = tabs.filter((tab) => tab !== null && tab.id !== void 0 && tab.groupId !== group.id).map((tab) => tab.id);
|
|
1521
|
+
if (missing.length > 0) await chrome.tabs.group({ groupId: group.id, tabIds: missing });
|
|
1522
|
+
updateOwnedSessionWindowForTabs(role, ids, group.windowId);
|
|
1523
|
+
return group;
|
|
1524
|
+
}
|
|
1525
|
+
async function createOwnedGroup(role, windowId, ids) {
|
|
1526
|
+
if (ids.length === 0) throw new Error(`Cannot create ${role} tab group without tabs`);
|
|
1527
|
+
await ensureTabsInWindow(ids, windowId);
|
|
1528
|
+
const groupId = await chrome.tabs.group({ tabIds: ids, createProperties: { windowId } });
|
|
1529
|
+
ownedContainers[role].groupId = groupId;
|
|
1530
|
+
ownedContainers[role].windowId = windowId;
|
|
1531
|
+
if (role === "interactive") interactiveGroupLedger.add(groupId);
|
|
1532
|
+
await persistRuntimeState();
|
|
1533
|
+
const group = await chrome.tabGroups.update(groupId, {
|
|
1534
|
+
color: OWNED_TAB_GROUP_COLOR,
|
|
1535
|
+
title: CONTAINER_TAB_GROUP_TITLE[role],
|
|
1536
|
+
collapsed: false
|
|
1537
|
+
});
|
|
1538
|
+
updateOwnedSessionWindowForTabs(role, ids, group.windowId);
|
|
1539
|
+
return { id: group.id, windowId: group.windowId, title: group.title };
|
|
1540
|
+
}
|
|
1541
|
+
async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) {
|
|
1542
|
+
if (role === "automation") return null;
|
|
1543
|
+
const ids = [...new Set(tabIds.filter((id) => id !== void 0))];
|
|
1544
|
+
const container = ownedContainers[role];
|
|
1545
|
+
const previousGroupPromise = container.groupPromise ?? Promise.resolve(null);
|
|
1546
|
+
const nextGroupPromise = previousGroupPromise.catch(() => null).then(() => ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids));
|
|
1547
|
+
const trackedGroupPromise = nextGroupPromise.finally(() => {
|
|
1548
|
+
if (container.groupPromise === trackedGroupPromise) container.groupPromise = null;
|
|
1549
|
+
});
|
|
1550
|
+
container.groupPromise = trackedGroupPromise;
|
|
1551
|
+
return trackedGroupPromise;
|
|
1552
|
+
}
|
|
1553
|
+
async function ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids) {
|
|
1554
|
+
try {
|
|
1555
|
+
const candidates = await collectOwnedGroupCandidates(role);
|
|
1556
|
+
const selected = selectOwnedContainerGroupCandidate(candidates);
|
|
1557
|
+
let canonical = selected ? { id: selected.id, windowId: selected.windowId, title: selected.title } : null;
|
|
1558
|
+
if (canonical) {
|
|
1559
|
+
canonical = await convergeOwnedGroupDuplicates(role, canonical, candidates);
|
|
1560
|
+
canonical = await ensureCanonicalGroupTitle(role, canonical);
|
|
1561
|
+
canonical = await attachTabsToOwnedGroup(role, canonical, ids);
|
|
1562
|
+
} else if (fallbackWindowId !== null && ids.length > 0) {
|
|
1563
|
+
canonical = await createOwnedGroup(role, fallbackWindowId, ids);
|
|
1564
|
+
}
|
|
1565
|
+
if (canonical) {
|
|
1566
|
+
ownedContainers[role].windowId = canonical.windowId;
|
|
1567
|
+
ownedContainers[role].groupId = canonical.id;
|
|
1568
|
+
if (!interactiveGroupLedger.has(canonical.id)) {
|
|
1569
|
+
interactiveGroupLedger.add(canonical.id);
|
|
1570
|
+
await persistRuntimeState();
|
|
1571
|
+
}
|
|
1572
|
+
} else {
|
|
1573
|
+
ownedContainers[role].groupId = null;
|
|
1574
|
+
if (fallbackWindowId === null) ownedContainers[role].windowId = null;
|
|
1575
|
+
}
|
|
1576
|
+
return canonical;
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
console.warn(`[opencli] Failed to ensure ${role} tab group: ${err instanceof Error ? err.message : String(err)}`);
|
|
1579
|
+
throw err;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
async function ensureOwnedContainerWindow(role, initialUrl, mode = "background") {
|
|
1583
|
+
const container = ownedContainers[role];
|
|
1584
|
+
if (container.promise) return container.promise;
|
|
1585
|
+
container.promise = ensureOwnedContainerWindowUnlocked(role, initialUrl, mode).finally(() => {
|
|
1586
|
+
container.promise = null;
|
|
1587
|
+
});
|
|
1588
|
+
return container.promise;
|
|
1589
|
+
}
|
|
1590
|
+
async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "background") {
|
|
1591
|
+
const container = ownedContainers[role];
|
|
1592
|
+
if (container.windowId !== null) {
|
|
1593
|
+
const windowId = container.windowId;
|
|
1594
|
+
try {
|
|
1595
|
+
await chrome.windows.get(windowId);
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
if (!(err instanceof Error) || classifyExtensionError(err.message) !== "tab_gone") throw err;
|
|
1598
|
+
console.warn(`[opencli] Owned ${role} window ${windowId} is unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
1599
|
+
container.windowId = null;
|
|
1600
|
+
container.groupId = null;
|
|
1601
|
+
}
|
|
1602
|
+
if (container.windowId === windowId) {
|
|
1603
|
+
const group2 = await ensureOwnedContainerGroup(role, windowId, []);
|
|
1604
|
+
if (group2) {
|
|
1605
|
+
await focusOwnedWindowIfRequested(group2.windowId, mode);
|
|
1606
|
+
const initialTabId2 = await findReusableOwnedContainerTab(group2.windowId, group2.id);
|
|
1607
|
+
return {
|
|
1608
|
+
windowId: group2.windowId,
|
|
1609
|
+
initialTabId: initialTabId2
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
if (role === "interactive") {
|
|
1613
|
+
container.windowId = null;
|
|
1614
|
+
container.groupId = null;
|
|
1615
|
+
} else {
|
|
1616
|
+
await focusOwnedWindowIfRequested(windowId, mode);
|
|
1617
|
+
const initialTabId2 = await findReusableOwnedContainerTab(windowId, null);
|
|
1618
|
+
return {
|
|
1619
|
+
windowId,
|
|
1620
|
+
initialTabId: initialTabId2
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
const existingGroup = await ensureOwnedContainerGroup(role, null, []);
|
|
1626
|
+
if (existingGroup) {
|
|
1627
|
+
await focusOwnedWindowIfRequested(existingGroup.windowId, mode);
|
|
1628
|
+
const initialTabId2 = await findReusableOwnedContainerTab(existingGroup.windowId, existingGroup.id);
|
|
1629
|
+
await persistRuntimeState();
|
|
1630
|
+
return {
|
|
1631
|
+
windowId: existingGroup.windowId,
|
|
1632
|
+
initialTabId: initialTabId2
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE;
|
|
1636
|
+
const win = await chrome.windows.create({
|
|
1637
|
+
url: startUrl,
|
|
1638
|
+
focused: mode === "foreground",
|
|
1639
|
+
width: 1280,
|
|
1640
|
+
height: 900,
|
|
1641
|
+
type: "normal"
|
|
1642
|
+
});
|
|
1643
|
+
// Openmd patch: background reads never stay on screen. windows.create ignores
|
|
1644
|
+
// state:"minimized" on Windows (Edge/Chrome 157), so minimize right after.
|
|
1645
|
+
if (mode !== "foreground") await chrome.windows.update(win.id, { state: "minimized" }).catch(() => {});
|
|
1646
|
+
container.windowId = win.id;
|
|
1647
|
+
await persistRuntimeState();
|
|
1648
|
+
console.log(`[opencli] Created owned ${role} window ${container.windowId} (start=${startUrl})`);
|
|
1649
|
+
const tabs = await chrome.tabs.query({ windowId: win.id });
|
|
1650
|
+
const initialTabId = tabs[0]?.id;
|
|
1651
|
+
if (initialTabId) {
|
|
1652
|
+
await new Promise((resolve) => {
|
|
1653
|
+
const timeout = setTimeout(resolve, 500);
|
|
1654
|
+
const listener = (tabId, info) => {
|
|
1655
|
+
if (tabId === initialTabId && info.status === "complete") {
|
|
1656
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
1657
|
+
clearTimeout(timeout);
|
|
1658
|
+
resolve();
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1661
|
+
if (tabs[0].status === "complete") {
|
|
1662
|
+
clearTimeout(timeout);
|
|
1663
|
+
resolve();
|
|
1664
|
+
} else {
|
|
1665
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
const group = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId]);
|
|
1670
|
+
await persistRuntimeState();
|
|
1671
|
+
return { windowId: group?.windowId ?? container.windowId, initialTabId };
|
|
1672
|
+
}
|
|
1673
|
+
async function findReusableOwnedContainerTab(windowId, ownedGroupId) {
|
|
1674
|
+
const idleUrl = await idlePlaceholderUrl();
|
|
1675
|
+
const tabs = await chrome.tabs.query({ windowId });
|
|
1676
|
+
const reusable = tabs.find(
|
|
1677
|
+
(tab) => tab.id !== void 0 && initialTabIsAvailable(tab.id) && (isDebuggableUrl(tab.url) || idleUrl !== null && tab.url === idleUrl && !tab.pendingUrl) && (ownedGroupId === void 0 || ownedGroupId !== null && tab.groupId === ownedGroupId || !isSafeNavigationUrl(tab.url ?? ""))
|
|
1678
|
+
);
|
|
1679
|
+
return reusable?.id;
|
|
1680
|
+
}
|
|
1681
|
+
function initialTabIsAvailable(tabId) {
|
|
1682
|
+
if (tabId === void 0) return false;
|
|
1683
|
+
for (const session of automationSessions.values()) {
|
|
1684
|
+
if (session.owned && session.preferredTabId === tabId) return false;
|
|
1685
|
+
}
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
async function createOwnedTabLease(leaseKey, initialUrl) {
|
|
1689
|
+
return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl));
|
|
1690
|
+
}
|
|
1691
|
+
async function createOwnedTabLeaseUnlocked(leaseKey, initialUrl) {
|
|
1692
|
+
const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE;
|
|
1693
|
+
const role = getOwnedWindowRole(leaseKey);
|
|
1694
|
+
const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, BLANK_PAGE, getWindowMode(leaseKey));
|
|
1695
|
+
let tab;
|
|
1696
|
+
if (initialTabIsAvailable(initialTabId)) {
|
|
1697
|
+
tab = await chrome.tabs.get(initialTabId);
|
|
1698
|
+
await consumeIdlePlaceholder(tab);
|
|
1699
|
+
if (!isTargetUrl(tab.url, targetUrl)) {
|
|
1700
|
+
tab = await chrome.tabs.update(initialTabId, { url: targetUrl });
|
|
1701
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1702
|
+
tab = await chrome.tabs.get(initialTabId);
|
|
1703
|
+
}
|
|
1704
|
+
} else {
|
|
1705
|
+
tab = await chrome.tabs.create({ windowId, url: targetUrl, active: true });
|
|
1706
|
+
}
|
|
1707
|
+
const tabId = tab.id;
|
|
1708
|
+
if (!tabId) throw new Error("Failed to create tab lease in automation container");
|
|
1709
|
+
const group = await ensureOwnedContainerGroup(role, windowId, [tabId]);
|
|
1710
|
+
const sessionWindowId = group?.windowId ?? tab.windowId;
|
|
1711
|
+
if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId);
|
|
1712
|
+
setLeaseSession(leaseKey, {
|
|
1713
|
+
session: getSessionFromKey(leaseKey),
|
|
1714
|
+
surface: getSurfaceFromKey(leaseKey),
|
|
1715
|
+
kind: "owned",
|
|
1716
|
+
windowId: sessionWindowId,
|
|
1717
|
+
owned: true,
|
|
1718
|
+
preferredTabId: tabId
|
|
1719
|
+
});
|
|
1720
|
+
resetWindowIdleTimer(leaseKey);
|
|
1721
|
+
return { tabId, tab };
|
|
1722
|
+
}
|
|
1723
|
+
async function getAutomationWindow(leaseKey, initialUrl) {
|
|
1724
|
+
const existing = automationSessions.get(leaseKey);
|
|
1725
|
+
if (existing) {
|
|
1726
|
+
if (!existing.owned) {
|
|
1727
|
+
throw new CommandFailure(
|
|
1728
|
+
"bound_window_operation_blocked",
|
|
1729
|
+
`Session "${existing.session}" is bound to a user tab and does not own an OpenCLI tab lease.`,
|
|
1730
|
+
"Use page commands on the bound tab, or unbind the session first."
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
try {
|
|
1734
|
+
const tabId = existing.preferredTabId;
|
|
1735
|
+
if (tabId !== null) {
|
|
1736
|
+
const tab = await chrome.tabs.get(tabId);
|
|
1737
|
+
if (isDebuggableUrl(tab.url)) return tab.windowId;
|
|
1738
|
+
}
|
|
1739
|
+
await chrome.windows.get(existing.windowId);
|
|
1740
|
+
return existing.windowId;
|
|
1741
|
+
} catch {
|
|
1742
|
+
await removeLeaseSession(leaseKey);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
const role = getOwnedWindowRole(leaseKey);
|
|
1746
|
+
return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(leaseKey))).windowId;
|
|
1747
|
+
}
|
|
1748
|
+
chrome.windows.onRemoved.addListener(async (windowId) => {
|
|
1749
|
+
await workerReady;
|
|
1750
|
+
for (const container of Object.values(ownedContainers)) {
|
|
1751
|
+
if (container.windowId === windowId) {
|
|
1752
|
+
container.windowId = null;
|
|
1753
|
+
container.groupId = null;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
for (const [leaseKey, session] of automationSessions.entries()) {
|
|
1757
|
+
if (session.windowId === windowId) {
|
|
1758
|
+
console.log(`[opencli] ${session.surface} container closed (session=${session.session})`);
|
|
1759
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
1760
|
+
automationSessions.delete(leaseKey);
|
|
1761
|
+
sessionOverrides.delete(leaseKey);
|
|
1762
|
+
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
await persistRuntimeState();
|
|
1766
|
+
});
|
|
1767
|
+
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
|
1768
|
+
await workerReady;
|
|
1769
|
+
evictTab(tabId);
|
|
1770
|
+
for (const [leaseKey, session] of automationSessions.entries()) {
|
|
1771
|
+
if (session.preferredTabId === tabId) {
|
|
1772
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
1773
|
+
automationSessions.delete(leaseKey);
|
|
1774
|
+
sessionOverrides.delete(leaseKey);
|
|
1775
|
+
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
|
|
1776
|
+
console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
await persistRuntimeState();
|
|
1780
|
+
});
|
|
1781
|
+
let initialized = false;
|
|
1782
|
+
function initialize() {
|
|
1783
|
+
if (initialized) return;
|
|
1784
|
+
initialized = true;
|
|
1785
|
+
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
|
|
1786
|
+
registerListeners();
|
|
1787
|
+
try {
|
|
1788
|
+
const registerFrameTracking$1 = registerFrameTracking;
|
|
1789
|
+
registerFrameTracking$1?.();
|
|
1790
|
+
} catch {
|
|
1791
|
+
}
|
|
1792
|
+
try {
|
|
1793
|
+
void chrome.storage?.local?.remove?.(REGISTRY_KEY)?.catch?.(() => {
|
|
1794
|
+
});
|
|
1795
|
+
} catch {
|
|
1796
|
+
}
|
|
1797
|
+
workerRecovered = false;
|
|
1798
|
+
workerReady = (async () => {
|
|
1799
|
+
await getCurrentContextId();
|
|
1800
|
+
await reconcileTargetLeaseRegistry();
|
|
1801
|
+
})().catch((err) => {
|
|
1802
|
+
console.warn(`[opencli] Startup recovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1803
|
+
}).finally(() => {
|
|
1804
|
+
workerRecovered = true;
|
|
1805
|
+
});
|
|
1806
|
+
void workerReady.then(() => connect());
|
|
1807
|
+
console.log("[opencli] OpenCLI extension initialized");
|
|
1808
|
+
}
|
|
1809
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
1810
|
+
initialize();
|
|
1811
|
+
});
|
|
1812
|
+
chrome.runtime.onStartup.addListener(() => {
|
|
1813
|
+
initialize();
|
|
1814
|
+
});
|
|
1815
|
+
initialize();
|
|
1816
|
+
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
|
1817
|
+
await workerReady;
|
|
1818
|
+
if (alarm.name === "keepalive") void connect();
|
|
1819
|
+
const leaseKey = leaseKeyFromAlarmName(alarm.name);
|
|
1820
|
+
if (!leaseKey) return;
|
|
1821
|
+
if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) {
|
|
1822
|
+
resetWindowIdleTimer(leaseKey);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
await releaseLease(leaseKey, "idle alarm");
|
|
1826
|
+
});
|
|
1827
|
+
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
1828
|
+
if (msg?.type === "getStatus") {
|
|
1829
|
+
void (async () => {
|
|
1830
|
+
const contextId = await getCurrentContextId();
|
|
1831
|
+
const connected = ws?.readyState === WebSocket.OPEN;
|
|
1832
|
+
const extensionVersion = OPENMD_BRIDGE_VERSION;
|
|
1833
|
+
const daemonVersion = connected ? await fetchDaemonVersion() : null;
|
|
1834
|
+
sendResponse({
|
|
1835
|
+
connected,
|
|
1836
|
+
reconnecting: reconnectTimer !== null,
|
|
1837
|
+
contextId,
|
|
1838
|
+
extensionVersion,
|
|
1839
|
+
daemonVersion
|
|
1840
|
+
});
|
|
1841
|
+
})();
|
|
1842
|
+
return true;
|
|
1843
|
+
}
|
|
1844
|
+
return false;
|
|
1845
|
+
});
|
|
1846
|
+
async function fetchDaemonVersion() {
|
|
1847
|
+
try {
|
|
1848
|
+
const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/status`, {
|
|
1849
|
+
method: "GET",
|
|
1850
|
+
headers: { "X-OpenCLI": "1" },
|
|
1851
|
+
signal: AbortSignal.timeout(1500)
|
|
1852
|
+
});
|
|
1853
|
+
if (!res.ok) return null;
|
|
1854
|
+
const body = await res.json();
|
|
1855
|
+
return typeof body.daemonVersion === "string" ? body.daemonVersion : null;
|
|
1856
|
+
} catch {
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
async function handleCommand(cmd) {
|
|
1861
|
+
if (cmd.action === "chrome-describe" || cmd.action === "chrome-call") {
|
|
1862
|
+
try {
|
|
1863
|
+
if (cmd.action === "chrome-describe") return { id: cmd.id, ok: true, data: await describeChrome(cmd.chromeMethod) };
|
|
1864
|
+
return await withLeaseMutation(async () => ({ id: cmd.id, ok: true, data: await callChrome(
|
|
1865
|
+
cmd.chromeMethod ?? "",
|
|
1866
|
+
cmd.chromeArgs ?? [],
|
|
1867
|
+
new Set([...automationSessions.values()].map((s) => s.preferredTabId).filter((id) => id !== null)),
|
|
1868
|
+
/* @__PURE__ */ new Set([...interactiveGroupLedger, ...Object.values(ownedContainers).map((c) => c.groupId).filter((id) => id !== null)])
|
|
1869
|
+
) }));
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
return errorResult(cmd.id, error);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
const session = getSessionName(cmd.session);
|
|
1875
|
+
const surface = getCommandSurface(cmd);
|
|
1876
|
+
const leaseKey = getLeaseKey(session, surface);
|
|
1877
|
+
if (closingLeases.has(leaseKey)) {
|
|
1878
|
+
return errorResult(cmd.id, new CommandFailure("session_busy", "This session is closing; retry after cleanup finishes."));
|
|
1879
|
+
}
|
|
1880
|
+
if (cmd.windowMode === "foreground" || cmd.windowMode === "background") {
|
|
1881
|
+
setSessionOverride(leaseKey, { windowMode: cmd.windowMode });
|
|
1882
|
+
}
|
|
1883
|
+
if (surface === "adapter" && (cmd.siteSession === "persistent" || cmd.siteSession === "ephemeral")) {
|
|
1884
|
+
setSessionOverride(leaseKey, { lifecycle: cmd.siteSession });
|
|
1885
|
+
}
|
|
1886
|
+
if (cmd.idleTimeout != null && cmd.idleTimeout > 0) {
|
|
1887
|
+
setSessionOverride(leaseKey, { idleTimeoutMs: cmd.idleTimeout * 1e3 });
|
|
1888
|
+
}
|
|
1889
|
+
resetWindowIdleTimer(leaseKey);
|
|
1890
|
+
activeCommandCounts.set(leaseKey, (activeCommandCounts.get(leaseKey) ?? 0) + 1);
|
|
1891
|
+
try {
|
|
1892
|
+
switch (cmd.action) {
|
|
1893
|
+
case "exec":
|
|
1894
|
+
return await handleExec(cmd, leaseKey);
|
|
1895
|
+
case "navigate":
|
|
1896
|
+
return await handleNavigate(cmd, leaseKey);
|
|
1897
|
+
case "tabs":
|
|
1898
|
+
return await handleTabs(cmd, leaseKey);
|
|
1899
|
+
case "cookies":
|
|
1900
|
+
return await handleCookies(cmd);
|
|
1901
|
+
case "screenshot":
|
|
1902
|
+
return await handleScreenshot(cmd, leaseKey);
|
|
1903
|
+
case "close-owned-tab":
|
|
1904
|
+
case "close-window":
|
|
1905
|
+
return await handleCloseWindow(cmd, leaseKey);
|
|
1906
|
+
case "cdp":
|
|
1907
|
+
return await handleCdp(cmd, leaseKey);
|
|
1908
|
+
case "set-file-input":
|
|
1909
|
+
return await handleSetFileInput(cmd, leaseKey);
|
|
1910
|
+
case "insert-text":
|
|
1911
|
+
return await handleInsertText(cmd, leaseKey);
|
|
1912
|
+
case "bind":
|
|
1913
|
+
return await handleBind(cmd, leaseKey);
|
|
1914
|
+
case "network-capture-start":
|
|
1915
|
+
return await handleNetworkCaptureStart(cmd, leaseKey);
|
|
1916
|
+
case "network-capture-read":
|
|
1917
|
+
return await handleNetworkCaptureRead(cmd, leaseKey);
|
|
1918
|
+
case "wait-download":
|
|
1919
|
+
return await handleWaitDownload(cmd);
|
|
1920
|
+
case "frames":
|
|
1921
|
+
return await handleFrames(cmd, leaseKey);
|
|
1922
|
+
default:
|
|
1923
|
+
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
|
1924
|
+
}
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
return errorResult(cmd.id, err);
|
|
1927
|
+
} finally {
|
|
1928
|
+
const remaining = (activeCommandCounts.get(leaseKey) ?? 1) - 1;
|
|
1929
|
+
if (remaining <= 0) activeCommandCounts.delete(leaseKey);
|
|
1930
|
+
else activeCommandCounts.set(leaseKey, remaining);
|
|
1931
|
+
resetWindowIdleTimer(leaseKey);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
const BLANK_PAGE = "about:blank";
|
|
1935
|
+
function isDebuggableUrl(url) {
|
|
1936
|
+
if (!url) return true;
|
|
1937
|
+
return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:");
|
|
1938
|
+
}
|
|
1939
|
+
function isSafeNavigationUrl(url) {
|
|
1940
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
1941
|
+
}
|
|
1942
|
+
function normalizeUrlForComparison(url) {
|
|
1943
|
+
if (!url) return "";
|
|
1944
|
+
try {
|
|
1945
|
+
const parsed = new URL(url);
|
|
1946
|
+
if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") {
|
|
1947
|
+
parsed.port = "";
|
|
1948
|
+
}
|
|
1949
|
+
const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
|
|
1950
|
+
return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
|
|
1951
|
+
} catch {
|
|
1952
|
+
return url;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
function isTargetUrl(currentUrl, targetUrl) {
|
|
1956
|
+
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
|
1957
|
+
}
|
|
1958
|
+
function getUrlOrigin(url) {
|
|
1959
|
+
if (!url) return null;
|
|
1960
|
+
try {
|
|
1961
|
+
return new URL(url).origin;
|
|
1962
|
+
} catch {
|
|
1963
|
+
return null;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
function enumerateCrossOriginFrames(tree) {
|
|
1967
|
+
const frames = [];
|
|
1968
|
+
function collect(node, accessibleOrigin) {
|
|
1969
|
+
for (const child of node.childFrames || []) {
|
|
1970
|
+
const frame = child.frame;
|
|
1971
|
+
const frameUrl = frame.url || frame.unreachableUrl || "";
|
|
1972
|
+
const frameOrigin = getUrlOrigin(frameUrl);
|
|
1973
|
+
if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) {
|
|
1974
|
+
collect(child, frameOrigin);
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
frames.push({
|
|
1978
|
+
index: frames.length,
|
|
1979
|
+
frameId: frame.id,
|
|
1980
|
+
url: frameUrl,
|
|
1981
|
+
name: frame.name || ""
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
const rootFrame = tree?.frameTree?.frame;
|
|
1986
|
+
const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || "";
|
|
1987
|
+
collect(tree.frameTree, getUrlOrigin(rootUrl));
|
|
1988
|
+
return frames;
|
|
1989
|
+
}
|
|
1990
|
+
function setLeaseSession(leaseKey, session) {
|
|
1991
|
+
if (session.preferredTabId !== null && closingTabs.has(session.preferredTabId)) {
|
|
1992
|
+
throw new CommandFailure("session_busy", "This tab is closing; wait for cleanup to finish.");
|
|
1993
|
+
}
|
|
1994
|
+
const existing = automationSessions.get(leaseKey);
|
|
1995
|
+
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
|
|
1996
|
+
const timeout = getIdleTimeout(leaseKey);
|
|
1997
|
+
automationSessions.set(leaseKey, {
|
|
1998
|
+
...makeSession(leaseKey, session),
|
|
1999
|
+
idleTimer: null,
|
|
2000
|
+
idleDeadlineAt: timeout <= 0 ? 0 : Date.now() + timeout
|
|
2001
|
+
});
|
|
2002
|
+
void persistRuntimeState();
|
|
2003
|
+
}
|
|
2004
|
+
async function resolveCommandTabId(cmd) {
|
|
2005
|
+
if (cmd.page) return resolveTabId$1(cmd.page);
|
|
2006
|
+
return void 0;
|
|
2007
|
+
}
|
|
2008
|
+
async function resolveTab(tabId, leaseKey, initialUrl) {
|
|
2009
|
+
const existingSession = automationSessions.get(leaseKey);
|
|
2010
|
+
if (tabId !== void 0) {
|
|
2011
|
+
try {
|
|
2012
|
+
const tab = await chrome.tabs.get(tabId);
|
|
2013
|
+
const session = existingSession;
|
|
2014
|
+
const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false;
|
|
2015
|
+
if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
|
|
2016
|
+
if (session && !session.owned) {
|
|
2017
|
+
throw new CommandFailure(
|
|
2018
|
+
matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch",
|
|
2019
|
+
matchesSession ? `Bound tab for session "${session.session}" is not debuggable (${tab.url ?? "unknown URL"}).` : `Target tab is not the tab bound to session "${session.session}".`,
|
|
2020
|
+
'Run "opencli browser bind" again on a debuggable http(s) tab.'
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) {
|
|
2024
|
+
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`);
|
|
2025
|
+
try {
|
|
2026
|
+
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
|
|
2027
|
+
const moved = await chrome.tabs.get(tabId);
|
|
2028
|
+
if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) {
|
|
2029
|
+
return { tabId, tab: moved };
|
|
2030
|
+
}
|
|
2031
|
+
} catch (moveErr) {
|
|
2032
|
+
console.warn(`[opencli] Failed to move tab back: ${moveErr}`);
|
|
2033
|
+
}
|
|
2034
|
+
} else if (!isDebuggableUrl(tab.url)) {
|
|
2035
|
+
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
|
2036
|
+
}
|
|
2037
|
+
} catch (err) {
|
|
2038
|
+
if (err instanceof CommandFailure) throw err;
|
|
2039
|
+
if (existingSession && !existingSession.owned) {
|
|
2040
|
+
automationSessions.delete(leaseKey);
|
|
2041
|
+
throw new CommandFailure(
|
|
2042
|
+
"bound_tab_gone",
|
|
2043
|
+
`Bound tab for session "${existingSession.session}" no longer exists.`,
|
|
2044
|
+
'Run "opencli browser bind" again, then retry the command.'
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
const existingPreferredTabId = existingSession?.preferredTabId ?? null;
|
|
2051
|
+
if (existingSession && existingPreferredTabId !== null) {
|
|
2052
|
+
const session = existingSession;
|
|
2053
|
+
try {
|
|
2054
|
+
const preferredTab = await chrome.tabs.get(existingPreferredTabId);
|
|
2055
|
+
if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab };
|
|
2056
|
+
if (!session.owned) {
|
|
2057
|
+
throw new CommandFailure(
|
|
2058
|
+
"bound_tab_not_debuggable",
|
|
2059
|
+
`Bound tab for session "${session.session}" is not debuggable (${preferredTab.url ?? "unknown URL"}).`,
|
|
2060
|
+
'Switch the tab to an http(s) page or run "opencli browser bind" on another tab.'
|
|
2061
|
+
);
|
|
2062
|
+
}
|
|
2063
|
+
} catch (err) {
|
|
2064
|
+
if (err instanceof CommandFailure) throw err;
|
|
2065
|
+
await removeLeaseSession(leaseKey);
|
|
2066
|
+
if (!session.owned) {
|
|
2067
|
+
throw new CommandFailure(
|
|
2068
|
+
"bound_tab_gone",
|
|
2069
|
+
`Bound tab for session "${session.session}" no longer exists.`,
|
|
2070
|
+
'Run "opencli browser bind" again, then retry the command.'
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
return createOwnedTabLease(leaseKey, initialUrl);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
if (!existingSession || existingSession.owned && existingSession.preferredTabId === null) {
|
|
2077
|
+
return createOwnedTabLease(leaseKey, initialUrl);
|
|
2078
|
+
}
|
|
2079
|
+
const windowId = await getAutomationWindow(leaseKey, initialUrl);
|
|
2080
|
+
const role = getOwnedWindowRole(leaseKey);
|
|
2081
|
+
const group = existingSession?.owned ? await ensureOwnedContainerGroup(role, windowId, []) : null;
|
|
2082
|
+
const scopedWindowId = group?.windowId ?? windowId;
|
|
2083
|
+
const reusableTabId = await findReusableOwnedContainerTab(scopedWindowId, existingSession?.owned ? group?.id ?? null : void 0);
|
|
2084
|
+
if (reusableTabId !== void 0) return { tabId: reusableTabId, tab: await chrome.tabs.get(reusableTabId) };
|
|
2085
|
+
const tabs = await chrome.tabs.query({ windowId: scopedWindowId });
|
|
2086
|
+
const reuseTab = existingSession?.owned ? void 0 : tabs.find((t) => t.id);
|
|
2087
|
+
if (reuseTab?.id) {
|
|
2088
|
+
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
|
|
2089
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
2090
|
+
try {
|
|
2091
|
+
const updated = await chrome.tabs.get(reuseTab.id);
|
|
2092
|
+
if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated };
|
|
2093
|
+
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
|
|
2094
|
+
} catch {
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: true });
|
|
2098
|
+
if (!newTab.id) throw new Error("Failed to create tab in automation container");
|
|
2099
|
+
await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]);
|
|
2100
|
+
return { tabId: newTab.id, tab: await chrome.tabs.get(newTab.id) };
|
|
2101
|
+
}
|
|
2102
|
+
async function pageScopedResult(id, tabId, data) {
|
|
2103
|
+
const page = await resolveTargetId(tabId);
|
|
2104
|
+
return { id, ok: true, data, page };
|
|
2105
|
+
}
|
|
2106
|
+
async function resolveTabId(tabId, leaseKey, initialUrl) {
|
|
2107
|
+
const resolved = await resolveTab(tabId, leaseKey, initialUrl);
|
|
2108
|
+
return resolved.tabId;
|
|
2109
|
+
}
|
|
2110
|
+
async function listAutomationTabs(leaseKey) {
|
|
2111
|
+
const session = automationSessions.get(leaseKey);
|
|
2112
|
+
if (!session) return [];
|
|
2113
|
+
if (session.preferredTabId !== null) {
|
|
2114
|
+
try {
|
|
2115
|
+
return [await chrome.tabs.get(session.preferredTabId)];
|
|
2116
|
+
} catch {
|
|
2117
|
+
automationSessions.delete(leaseKey);
|
|
2118
|
+
return [];
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
try {
|
|
2122
|
+
return await chrome.tabs.query({ windowId: session.windowId });
|
|
2123
|
+
} catch {
|
|
2124
|
+
automationSessions.delete(leaseKey);
|
|
2125
|
+
return [];
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
async function listAutomationWebTabs(leaseKey) {
|
|
2129
|
+
const tabs = await listAutomationTabs(leaseKey);
|
|
2130
|
+
return tabs.filter((tab) => isDebuggableUrl(tab.url));
|
|
2131
|
+
}
|
|
2132
|
+
function commandCdpTimeoutMs(cmd) {
|
|
2133
|
+
if (typeof cmd.deadlineAt === "number" && cmd.deadlineAt > 0) {
|
|
2134
|
+
return Math.max(1e4, cmd.deadlineAt - Date.now() - 5e3);
|
|
2135
|
+
}
|
|
2136
|
+
if (typeof cmd.timeout === "number" && cmd.timeout > 0) {
|
|
2137
|
+
return Math.max(1e4, cmd.timeout * 1e3 - 5e3);
|
|
2138
|
+
}
|
|
2139
|
+
return void 0;
|
|
2140
|
+
}
|
|
2141
|
+
function classifyExtensionError(message) {
|
|
2142
|
+
if (/Inspected target navigated|Target closed/.test(message)) return "target_navigated";
|
|
2143
|
+
if (/Detached while handling command/.test(message)) return "detached_mid_command";
|
|
2144
|
+
if (/CDP command .* timed out/.test(message)) return "cdp_timeout";
|
|
2145
|
+
if (/attach failed|Debugger is not attached/.test(message)) return "attach_failed";
|
|
2146
|
+
if (/No tab with id|no longer exists|No window with id/.test(message)) return "tab_gone";
|
|
2147
|
+
return void 0;
|
|
2148
|
+
}
|
|
2149
|
+
function errorResult(id, err) {
|
|
2150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2151
|
+
if (err instanceof CommandFailure || err instanceof ChromeApiError) {
|
|
2152
|
+
return { id, ok: false, error: message, errorCode: err.code, ...err.hint ? { errorHint: err.hint } : {} };
|
|
2153
|
+
}
|
|
2154
|
+
const errorCode = classifyExtensionError(message);
|
|
2155
|
+
return { id, ok: false, error: message, ...errorCode ? { errorCode } : {} };
|
|
2156
|
+
}
|
|
2157
|
+
async function handleExec(cmd, leaseKey) {
|
|
2158
|
+
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
|
|
2159
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2160
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2161
|
+
try {
|
|
2162
|
+
const aggressive = getSurfaceFromKey(leaseKey) === "browser";
|
|
2163
|
+
if (cmd.frameIndex != null) {
|
|
2164
|
+
const tree = await getFrameTree(tabId);
|
|
2165
|
+
const frames = enumerateCrossOriginFrames(tree);
|
|
2166
|
+
if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) {
|
|
2167
|
+
return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` };
|
|
2168
|
+
}
|
|
2169
|
+
const data2 = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive, commandCdpTimeoutMs(cmd));
|
|
2170
|
+
return pageScopedResult(cmd.id, tabId, data2);
|
|
2171
|
+
}
|
|
2172
|
+
const data = await evaluateAsync(tabId, cmd.code, aggressive, commandCdpTimeoutMs(cmd));
|
|
2173
|
+
return pageScopedResult(cmd.id, tabId, data);
|
|
2174
|
+
} catch (err) {
|
|
2175
|
+
return errorResult(cmd.id, err);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function handleFrames(cmd, leaseKey) {
|
|
2179
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2180
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2181
|
+
try {
|
|
2182
|
+
const tree = await getFrameTree(tabId);
|
|
2183
|
+
return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) };
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
return errorResult(cmd.id, err);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
function preferOwnedTab(leaseKey, tabId) {
|
|
2189
|
+
const session = automationSessions.get(leaseKey);
|
|
2190
|
+
if (!session?.owned) return;
|
|
2191
|
+
setLeaseSession(leaseKey, {
|
|
2192
|
+
session: session.session,
|
|
2193
|
+
surface: session.surface,
|
|
2194
|
+
kind: session.kind,
|
|
2195
|
+
windowId: session.windowId,
|
|
2196
|
+
owned: true,
|
|
2197
|
+
preferredTabId: tabId
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
async function handleNavigate(cmd, leaseKey) {
|
|
2201
|
+
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
|
|
2202
|
+
if (!isSafeNavigationUrl(cmd.url)) {
|
|
2203
|
+
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
|
|
2204
|
+
}
|
|
2205
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2206
|
+
const resolved = await resolveTab(cmdTabId, leaseKey);
|
|
2207
|
+
const tabId = resolved.tabId;
|
|
2208
|
+
const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId);
|
|
2209
|
+
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
|
|
2210
|
+
const targetUrl = cmd.url;
|
|
2211
|
+
if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) {
|
|
2212
|
+
return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false });
|
|
2213
|
+
}
|
|
2214
|
+
if (!hasActiveNetworkCapture(tabId)) {
|
|
2215
|
+
await detach(tabId);
|
|
2216
|
+
}
|
|
2217
|
+
await chrome.tabs.update(tabId, { url: targetUrl });
|
|
2218
|
+
let timedOut = false;
|
|
2219
|
+
await new Promise((resolve) => {
|
|
2220
|
+
let settled = false;
|
|
2221
|
+
let checkTimer = null;
|
|
2222
|
+
let timeoutTimer = null;
|
|
2223
|
+
const finish = () => {
|
|
2224
|
+
if (settled) return;
|
|
2225
|
+
settled = true;
|
|
2226
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
2227
|
+
if (checkTimer) clearTimeout(checkTimer);
|
|
2228
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
2229
|
+
resolve();
|
|
2230
|
+
};
|
|
2231
|
+
const isNavigationDone = (url) => {
|
|
2232
|
+
return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
|
|
2233
|
+
};
|
|
2234
|
+
const listener = (id, info, tab2) => {
|
|
2235
|
+
if (id !== tabId) return;
|
|
2236
|
+
if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) {
|
|
2237
|
+
finish();
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
2241
|
+
checkTimer = setTimeout(async () => {
|
|
2242
|
+
try {
|
|
2243
|
+
const currentTab = await chrome.tabs.get(tabId);
|
|
2244
|
+
if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) {
|
|
2245
|
+
finish();
|
|
2246
|
+
}
|
|
2247
|
+
} catch {
|
|
2248
|
+
}
|
|
2249
|
+
}, 100);
|
|
2250
|
+
timeoutTimer = setTimeout(() => {
|
|
2251
|
+
timedOut = true;
|
|
2252
|
+
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
|
|
2253
|
+
finish();
|
|
2254
|
+
}, 15e3);
|
|
2255
|
+
});
|
|
2256
|
+
let tab = await chrome.tabs.get(tabId);
|
|
2257
|
+
const postNavigationSession = automationSessions.get(leaseKey);
|
|
2258
|
+
if (postNavigationSession && tab.windowId !== postNavigationSession.windowId) {
|
|
2259
|
+
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${postNavigationSession.windowId}`);
|
|
2260
|
+
try {
|
|
2261
|
+
await chrome.tabs.move(tabId, { windowId: postNavigationSession.windowId, index: -1 });
|
|
2262
|
+
tab = await chrome.tabs.get(tabId);
|
|
2263
|
+
} catch (moveErr) {
|
|
2264
|
+
console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut });
|
|
2268
|
+
}
|
|
2269
|
+
async function handleTabs(cmd, leaseKey) {
|
|
2270
|
+
const session = automationSessions.get(leaseKey);
|
|
2271
|
+
if (session && !session.owned && cmd.op !== "list") {
|
|
2272
|
+
return {
|
|
2273
|
+
id: cmd.id,
|
|
2274
|
+
ok: false,
|
|
2275
|
+
errorCode: "bound_tab_mutation_blocked",
|
|
2276
|
+
error: `Session "${session.session}" is bound to a user tab; tab new/select/close requires an owned OpenCLI session.`,
|
|
2277
|
+
errorHint: "Unbind the session first, or use a different session for owned OpenCLI tabs."
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
switch (cmd.op) {
|
|
2281
|
+
case "list": {
|
|
2282
|
+
const tabs = await listAutomationWebTabs(leaseKey);
|
|
2283
|
+
const data = await Promise.all(tabs.map(async (t, i) => {
|
|
2284
|
+
let page;
|
|
2285
|
+
try {
|
|
2286
|
+
page = t.id ? await resolveTargetId(t.id) : void 0;
|
|
2287
|
+
} catch {
|
|
2288
|
+
}
|
|
2289
|
+
return { index: i, page, url: t.url, title: t.title, active: t.active };
|
|
2290
|
+
}));
|
|
2291
|
+
return { id: cmd.id, ok: true, data };
|
|
2292
|
+
}
|
|
2293
|
+
case "new": {
|
|
2294
|
+
if (cmd.url && !isSafeNavigationUrl(cmd.url)) {
|
|
2295
|
+
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
|
|
2296
|
+
}
|
|
2297
|
+
if (!automationSessions.has(leaseKey)) {
|
|
2298
|
+
const created = await createOwnedTabLease(leaseKey, cmd.url);
|
|
2299
|
+
return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url });
|
|
2300
|
+
}
|
|
2301
|
+
const windowId = await getAutomationWindow(leaseKey);
|
|
2302
|
+
let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
|
|
2303
|
+
const tabId = tab.id;
|
|
2304
|
+
if (!tabId) return { id: cmd.id, ok: false, error: "Failed to create tab" };
|
|
2305
|
+
const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]);
|
|
2306
|
+
const sessionWindowId = group?.windowId ?? tab.windowId;
|
|
2307
|
+
if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId);
|
|
2308
|
+
setLeaseSession(leaseKey, {
|
|
2309
|
+
session: getSessionFromKey(leaseKey),
|
|
2310
|
+
surface: getSurfaceFromKey(leaseKey),
|
|
2311
|
+
kind: "owned",
|
|
2312
|
+
windowId: sessionWindowId,
|
|
2313
|
+
owned: true,
|
|
2314
|
+
preferredTabId: tabId
|
|
2315
|
+
});
|
|
2316
|
+
resetWindowIdleTimer(leaseKey);
|
|
2317
|
+
return pageScopedResult(cmd.id, tabId, { url: tab.url });
|
|
2318
|
+
}
|
|
2319
|
+
case "close": {
|
|
2320
|
+
if (cmd.index !== void 0) {
|
|
2321
|
+
const tabs = await listAutomationWebTabs(leaseKey);
|
|
2322
|
+
const target = tabs[cmd.index];
|
|
2323
|
+
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
2324
|
+
const closedPage2 = await resolveTargetId(target.id).catch(() => void 0);
|
|
2325
|
+
const currentSession2 = automationSessions.get(leaseKey);
|
|
2326
|
+
if (currentSession2?.preferredTabId === target.id) {
|
|
2327
|
+
await releaseLease(leaseKey, "tab close");
|
|
2328
|
+
} else {
|
|
2329
|
+
await safeDetach(target.id);
|
|
2330
|
+
await chrome.tabs.remove(target.id);
|
|
2331
|
+
}
|
|
2332
|
+
return { id: cmd.id, ok: true, data: { closed: closedPage2 } };
|
|
2333
|
+
}
|
|
2334
|
+
if (cmd.page !== void 0 && session?.owned) {
|
|
2335
|
+
let tabId2;
|
|
2336
|
+
try {
|
|
2337
|
+
tabId2 = await resolveCommandTabId(cmd);
|
|
2338
|
+
} catch {
|
|
2339
|
+
return { id: cmd.id, ok: false, error: `Page no longer exists` };
|
|
2340
|
+
}
|
|
2341
|
+
if (tabId2 === void 0) {
|
|
2342
|
+
return { id: cmd.id, ok: false, error: `Page no longer exists` };
|
|
2343
|
+
}
|
|
2344
|
+
let tab;
|
|
2345
|
+
try {
|
|
2346
|
+
tab = await chrome.tabs.get(tabId2);
|
|
2347
|
+
} catch {
|
|
2348
|
+
return { id: cmd.id, ok: false, error: `Page no longer exists` };
|
|
2349
|
+
}
|
|
2350
|
+
if (tab.windowId !== session.windowId) {
|
|
2351
|
+
return { id: cmd.id, ok: false, error: `Page is not in the automation container` };
|
|
2352
|
+
}
|
|
2353
|
+
const closedPage2 = await resolveTargetId(tabId2).catch(() => void 0);
|
|
2354
|
+
if (session.preferredTabId === tabId2) {
|
|
2355
|
+
await releaseLease(leaseKey, "tab close");
|
|
2356
|
+
} else {
|
|
2357
|
+
await safeDetach(tabId2);
|
|
2358
|
+
await chrome.tabs.remove(tabId2);
|
|
2359
|
+
}
|
|
2360
|
+
return { id: cmd.id, ok: true, data: { closed: closedPage2 } };
|
|
2361
|
+
}
|
|
2362
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2363
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2364
|
+
const closedPage = await resolveTargetId(tabId).catch(() => void 0);
|
|
2365
|
+
const currentSession = automationSessions.get(leaseKey);
|
|
2366
|
+
if (currentSession?.preferredTabId === tabId) {
|
|
2367
|
+
await releaseLease(leaseKey, "tab close");
|
|
2368
|
+
} else {
|
|
2369
|
+
await safeDetach(tabId);
|
|
2370
|
+
await chrome.tabs.remove(tabId);
|
|
2371
|
+
}
|
|
2372
|
+
return { id: cmd.id, ok: true, data: { closed: closedPage } };
|
|
2373
|
+
}
|
|
2374
|
+
case "select": {
|
|
2375
|
+
if (cmd.index === void 0 && cmd.page === void 0)
|
|
2376
|
+
return { id: cmd.id, ok: false, error: "Missing index or page" };
|
|
2377
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2378
|
+
if (cmdTabId !== void 0) {
|
|
2379
|
+
const session2 = automationSessions.get(leaseKey);
|
|
2380
|
+
let tab;
|
|
2381
|
+
try {
|
|
2382
|
+
tab = await chrome.tabs.get(cmdTabId);
|
|
2383
|
+
} catch {
|
|
2384
|
+
return { id: cmd.id, ok: false, error: `Page no longer exists` };
|
|
2385
|
+
}
|
|
2386
|
+
if (!session2 || tab.windowId !== session2.windowId) {
|
|
2387
|
+
return { id: cmd.id, ok: false, error: `Page is not in the automation container` };
|
|
2388
|
+
}
|
|
2389
|
+
await chrome.tabs.update(cmdTabId, { active: true });
|
|
2390
|
+
preferOwnedTab(leaseKey, cmdTabId);
|
|
2391
|
+
return pageScopedResult(cmd.id, cmdTabId, { selected: true });
|
|
2392
|
+
}
|
|
2393
|
+
const tabs = await listAutomationWebTabs(leaseKey);
|
|
2394
|
+
const target = tabs[cmd.index];
|
|
2395
|
+
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
2396
|
+
await chrome.tabs.update(target.id, { active: true });
|
|
2397
|
+
preferOwnedTab(leaseKey, target.id);
|
|
2398
|
+
return pageScopedResult(cmd.id, target.id, { selected: true });
|
|
2399
|
+
}
|
|
2400
|
+
default:
|
|
2401
|
+
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
async function handleCookies(cmd) {
|
|
2405
|
+
if (!cmd.domain && !cmd.url) {
|
|
2406
|
+
return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" };
|
|
2407
|
+
}
|
|
2408
|
+
const details = {};
|
|
2409
|
+
if (cmd.domain) details.domain = cmd.domain;
|
|
2410
|
+
if (cmd.url) details.url = cmd.url;
|
|
2411
|
+
const cookies = await chrome.cookies.getAll(details);
|
|
2412
|
+
const data = cookies.map((c) => ({
|
|
2413
|
+
name: c.name,
|
|
2414
|
+
value: c.value,
|
|
2415
|
+
domain: c.domain,
|
|
2416
|
+
path: c.path,
|
|
2417
|
+
secure: c.secure,
|
|
2418
|
+
httpOnly: c.httpOnly,
|
|
2419
|
+
expirationDate: c.expirationDate
|
|
2420
|
+
}));
|
|
2421
|
+
return { id: cmd.id, ok: true, data };
|
|
2422
|
+
}
|
|
2423
|
+
async function handleScreenshot(cmd, leaseKey) {
|
|
2424
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2425
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2426
|
+
try {
|
|
2427
|
+
const data = await screenshot(tabId, {
|
|
2428
|
+
format: cmd.format,
|
|
2429
|
+
quality: cmd.quality,
|
|
2430
|
+
fullPage: cmd.fullPage,
|
|
2431
|
+
width: cmd.width,
|
|
2432
|
+
height: cmd.height
|
|
2433
|
+
});
|
|
2434
|
+
return pageScopedResult(cmd.id, tabId, data);
|
|
2435
|
+
} catch (err) {
|
|
2436
|
+
return errorResult(cmd.id, err);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
const CDP_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
2440
|
+
// Agent DOM context
|
|
2441
|
+
"Accessibility.enable",
|
|
2442
|
+
"Accessibility.getFullAXTree",
|
|
2443
|
+
"DOM.enable",
|
|
2444
|
+
"DOM.getDocument",
|
|
2445
|
+
"DOM.getBoxModel",
|
|
2446
|
+
"DOM.getContentQuads",
|
|
2447
|
+
"DOM.focus",
|
|
2448
|
+
"DOM.querySelector",
|
|
2449
|
+
"DOM.querySelectorAll",
|
|
2450
|
+
"DOM.scrollIntoViewIfNeeded",
|
|
2451
|
+
"DOMSnapshot.captureSnapshot",
|
|
2452
|
+
// Native input events
|
|
2453
|
+
"Input.dispatchMouseEvent",
|
|
2454
|
+
"Input.dispatchKeyEvent",
|
|
2455
|
+
"Input.insertText",
|
|
2456
|
+
// Page metrics & screenshots
|
|
2457
|
+
"Page.getLayoutMetrics",
|
|
2458
|
+
"Page.captureScreenshot",
|
|
2459
|
+
"Page.getFrameTree",
|
|
2460
|
+
"Page.handleJavaScriptDialog",
|
|
2461
|
+
// Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
|
|
2462
|
+
"Runtime.enable",
|
|
2463
|
+
// Emulation (used by screenshot full-page)
|
|
2464
|
+
"Emulation.setDeviceMetricsOverride",
|
|
2465
|
+
"Emulation.clearDeviceMetricsOverride"
|
|
2466
|
+
]);
|
|
2467
|
+
async function handleCdp(cmd, leaseKey) {
|
|
2468
|
+
if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" };
|
|
2469
|
+
if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) {
|
|
2470
|
+
return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` };
|
|
2471
|
+
}
|
|
2472
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2473
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2474
|
+
try {
|
|
2475
|
+
const aggressive = getSurfaceFromKey(leaseKey) === "browser";
|
|
2476
|
+
await ensureAttached(tabId, aggressive);
|
|
2477
|
+
const params = cmd.cdpParams ?? {};
|
|
2478
|
+
const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0;
|
|
2479
|
+
const routeTargetUrl = typeof params.targetUrl === "string" ? params.targetUrl : void 0;
|
|
2480
|
+
const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, commandCdpTimeoutMs(cmd) ?? 3e4, routeTargetUrl) : await sendDebuggerCommand(
|
|
2481
|
+
{ tabId },
|
|
2482
|
+
cmd.cdpMethod,
|
|
2483
|
+
stripOpenCliFrameRoutingParams(params, false),
|
|
2484
|
+
commandCdpTimeoutMs(cmd)
|
|
2485
|
+
);
|
|
2486
|
+
return pageScopedResult(cmd.id, tabId, data);
|
|
2487
|
+
} catch (err) {
|
|
2488
|
+
return errorResult(cmd.id, err);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
function stripOpenCliFrameRoutingParams(params, stripFrameId) {
|
|
2492
|
+
const { sessionId, frameId, targetUrl, ...rest } = params;
|
|
2493
|
+
if (!stripFrameId && frameId !== void 0) return { ...rest, frameId };
|
|
2494
|
+
return rest;
|
|
2495
|
+
}
|
|
2496
|
+
async function handleCloseWindow(cmd, leaseKey) {
|
|
2497
|
+
if (cmd.action === "close-owned-tab") {
|
|
2498
|
+
return withLeaseMutation(async () => {
|
|
2499
|
+
if ((activeCommandCounts.get(leaseKey) ?? 0) > 1) {
|
|
2500
|
+
throw new CommandFailure("session_busy", "Cannot close a tab while another command is using this session.");
|
|
2501
|
+
}
|
|
2502
|
+
closingLeases.add(leaseKey);
|
|
2503
|
+
let closingTabId;
|
|
2504
|
+
try {
|
|
2505
|
+
const session = automationSessions.get(leaseKey);
|
|
2506
|
+
if (!session?.owned) {
|
|
2507
|
+
await releaseLease(leaseKey, "explicit close");
|
|
2508
|
+
return { id: cmd.id, ok: true, data: { closed: true, session: getSessionFromKey(leaseKey), tabCleanup: "no-owned-tab" } };
|
|
2509
|
+
}
|
|
2510
|
+
const tabId = session.preferredTabId;
|
|
2511
|
+
if (tabId === null) {
|
|
2512
|
+
throw new CommandFailure("owned_tab_identity_missing", "Cannot close an owned lease without a tab identity.");
|
|
2513
|
+
}
|
|
2514
|
+
if ([...automationSessions.entries()].some(([key, lease]) => key !== leaseKey && lease.preferredTabId === tabId)) {
|
|
2515
|
+
throw new CommandFailure("session_busy", "Another session also uses this tab; release that session before closing it.");
|
|
2516
|
+
}
|
|
2517
|
+
closingTabId = tabId;
|
|
2518
|
+
closingTabs.add(tabId);
|
|
2519
|
+
const tab = await chrome.tabs.get(tabId);
|
|
2520
|
+
if (tab.groupId !== void 0 && tab.groupId !== chrome.tabGroups.TAB_GROUP_ID_NONE) {
|
|
2521
|
+
await chrome.tabs.ungroup(tabId);
|
|
2522
|
+
}
|
|
2523
|
+
await safeDetach(tabId);
|
|
2524
|
+
await chrome.tabs.remove(tabId);
|
|
2525
|
+
evictTab(tabId);
|
|
2526
|
+
await removeLeaseSession(leaseKey);
|
|
2527
|
+
return { id: cmd.id, ok: true, data: { closed: true, session: session.session, tabCleanup: "closed-owned-tab" } };
|
|
2528
|
+
} finally {
|
|
2529
|
+
closingLeases.delete(leaseKey);
|
|
2530
|
+
if (closingTabId !== void 0) closingTabs.delete(closingTabId);
|
|
2531
|
+
}
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
const sessionName = automationSessions.get(leaseKey)?.session ?? getSessionFromKey(leaseKey);
|
|
2535
|
+
await releaseLease(leaseKey, "explicit close");
|
|
2536
|
+
return { id: cmd.id, ok: true, data: { closed: true, session: sessionName } };
|
|
2537
|
+
}
|
|
2538
|
+
async function handleSetFileInput(cmd, leaseKey) {
|
|
2539
|
+
if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) {
|
|
2540
|
+
return { id: cmd.id, ok: false, error: "Missing or empty files array" };
|
|
2541
|
+
}
|
|
2542
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2543
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2544
|
+
try {
|
|
2545
|
+
await setFileInputFiles(tabId, cmd.files, cmd.selector);
|
|
2546
|
+
return pageScopedResult(cmd.id, tabId, { count: cmd.files.length });
|
|
2547
|
+
} catch (err) {
|
|
2548
|
+
return errorResult(cmd.id, err);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
async function handleInsertText(cmd, leaseKey) {
|
|
2552
|
+
if (typeof cmd.text !== "string") {
|
|
2553
|
+
return { id: cmd.id, ok: false, error: "Missing text payload" };
|
|
2554
|
+
}
|
|
2555
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2556
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2557
|
+
try {
|
|
2558
|
+
await insertText(tabId, cmd.text);
|
|
2559
|
+
return pageScopedResult(cmd.id, tabId, { inserted: true });
|
|
2560
|
+
} catch (err) {
|
|
2561
|
+
return errorResult(cmd.id, err);
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
async function handleNetworkCaptureStart(cmd, leaseKey) {
|
|
2565
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2566
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2567
|
+
try {
|
|
2568
|
+
await startNetworkCapture(tabId, cmd.pattern);
|
|
2569
|
+
return pageScopedResult(cmd.id, tabId, { started: true });
|
|
2570
|
+
} catch (err) {
|
|
2571
|
+
return errorResult(cmd.id, err);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
async function handleNetworkCaptureRead(cmd, leaseKey) {
|
|
2575
|
+
const cmdTabId = await resolveCommandTabId(cmd);
|
|
2576
|
+
const tabId = await resolveTabId(cmdTabId, leaseKey);
|
|
2577
|
+
try {
|
|
2578
|
+
const data = await readNetworkCapture(tabId, cmd.retainIncomplete === true);
|
|
2579
|
+
return pageScopedResult(cmd.id, tabId, data);
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
return errorResult(cmd.id, err);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
async function handleWaitDownload(cmd) {
|
|
2585
|
+
try {
|
|
2586
|
+
const data = await waitForDownload(cmd.pattern ?? "", cmd.timeoutMs ?? 3e4);
|
|
2587
|
+
return { id: cmd.id, ok: true, data };
|
|
2588
|
+
} catch (err) {
|
|
2589
|
+
return errorResult(cmd.id, err);
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
async function releaseLease(leaseKey, reason = "released") {
|
|
2593
|
+
const session = automationSessions.get(leaseKey);
|
|
2594
|
+
if (!session) {
|
|
2595
|
+
sessionOverrides.delete(leaseKey);
|
|
2596
|
+
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
|
|
2597
|
+
await persistRuntimeState();
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
2601
|
+
scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE);
|
|
2602
|
+
if (session.owned) {
|
|
2603
|
+
const tabId = session.preferredTabId;
|
|
2604
|
+
if (tabId !== null) {
|
|
2605
|
+
const hasOtherOwnedLease = [...automationSessions.entries()].some(
|
|
2606
|
+
([otherLease, otherSession]) => otherLease !== leaseKey && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null
|
|
2607
|
+
);
|
|
2608
|
+
await safeDetach(tabId);
|
|
2609
|
+
evictTab(tabId);
|
|
2610
|
+
if (hasOtherOwnedLease) {
|
|
2611
|
+
await chrome.tabs.remove(tabId).catch(() => {
|
|
2612
|
+
});
|
|
2613
|
+
console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`);
|
|
2614
|
+
} else {
|
|
2615
|
+
try {
|
|
2616
|
+
const role = getOwnedWindowRole(leaseKey);
|
|
2617
|
+
const idleUrl = role === "interactive" ? await parkIdleInteractiveTab(tabId, session.windowId) : null;
|
|
2618
|
+
const tab = await chrome.tabs.update(tabId, { url: idleUrl ?? BLANK_PAGE, active: true });
|
|
2619
|
+
const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]);
|
|
2620
|
+
if (group) session.windowId = group.windowId;
|
|
2621
|
+
console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`);
|
|
2622
|
+
} catch {
|
|
2623
|
+
await chrome.tabs.remove(tabId).catch(() => {
|
|
2624
|
+
});
|
|
2625
|
+
console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`);
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
} else {
|
|
2629
|
+
console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (session=${session.session}, surface=${session.surface}, ${reason})`);
|
|
2630
|
+
}
|
|
2631
|
+
} else if (session.preferredTabId !== null) {
|
|
2632
|
+
await safeDetach(session.preferredTabId);
|
|
2633
|
+
console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`);
|
|
2634
|
+
}
|
|
2635
|
+
automationSessions.delete(leaseKey);
|
|
2636
|
+
sessionOverrides.delete(leaseKey);
|
|
2637
|
+
await persistRuntimeState();
|
|
2638
|
+
}
|
|
2639
|
+
async function reconcileTargetLeaseRegistry() {
|
|
2640
|
+
const registry = await readRegistry();
|
|
2641
|
+
interactiveGroupLedger.clear();
|
|
2642
|
+
for (const id of registry.ownedContainers.interactive.groupIds) interactiveGroupLedger.add(id);
|
|
2643
|
+
for (const role of Object.keys(ownedContainers)) {
|
|
2644
|
+
ownedContainers[role].windowId = registry.ownedContainers[role]?.windowId ?? null;
|
|
2645
|
+
const windowId = ownedContainers[role].windowId;
|
|
2646
|
+
if (windowId !== null) {
|
|
2647
|
+
try {
|
|
2648
|
+
await chrome.windows.get(windowId);
|
|
2649
|
+
} catch {
|
|
2650
|
+
ownedContainers[role].windowId = null;
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
automationSessions.clear();
|
|
2655
|
+
for (const [leaseKey, stored] of Object.entries(registry.leases)) {
|
|
2656
|
+
const tabId = stored.preferredTabId;
|
|
2657
|
+
if (tabId === null) continue;
|
|
2658
|
+
try {
|
|
2659
|
+
const tab = await chrome.tabs.get(tabId);
|
|
2660
|
+
if (!isDebuggableUrl(tab.url)) continue;
|
|
2661
|
+
if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") {
|
|
2662
|
+
setSessionOverride(leaseKey, { lifecycle: stored.lifecycle });
|
|
2663
|
+
}
|
|
2664
|
+
const session = makeSession(leaseKey, {
|
|
2665
|
+
session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey),
|
|
2666
|
+
surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey),
|
|
2667
|
+
kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned",
|
|
2668
|
+
windowId: tab.windowId,
|
|
2669
|
+
owned: stored.owned,
|
|
2670
|
+
preferredTabId: tabId
|
|
2671
|
+
});
|
|
2672
|
+
const timeout = getIdleTimeout(leaseKey);
|
|
2673
|
+
automationSessions.set(leaseKey, {
|
|
2674
|
+
...session,
|
|
2675
|
+
idleTimer: null,
|
|
2676
|
+
idleDeadlineAt: stored.idleDeadlineAt
|
|
2677
|
+
});
|
|
2678
|
+
if (session.owned) {
|
|
2679
|
+
const role = getOwnedWindowRole(leaseKey);
|
|
2680
|
+
if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId;
|
|
2681
|
+
const group = await ensureOwnedContainerGroup(role, tab.windowId, [tabId]);
|
|
2682
|
+
if (group) {
|
|
2683
|
+
const current = automationSessions.get(leaseKey);
|
|
2684
|
+
if (current) current.windowId = group.windowId;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout;
|
|
2688
|
+
if (timeout > 0) {
|
|
2689
|
+
if (remaining <= 0) {
|
|
2690
|
+
await releaseLease(leaseKey, "reconciled idle expiry");
|
|
2691
|
+
} else {
|
|
2692
|
+
resetWindowIdleTimer(leaseKey, remaining);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
} catch {
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
try {
|
|
2699
|
+
await ensureOwnedContainerGroup("interactive", null, []);
|
|
2700
|
+
} catch (err) {
|
|
2701
|
+
console.warn(`[opencli] Startup interactive group convergence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2702
|
+
}
|
|
2703
|
+
await persistRuntimeState();
|
|
2704
|
+
}
|
|
2705
|
+
async function handleBind(cmd, leaseKey) {
|
|
2706
|
+
const existing = automationSessions.get(leaseKey);
|
|
2707
|
+
if (existing?.owned) {
|
|
2708
|
+
await releaseLease(leaseKey, "rebind");
|
|
2709
|
+
}
|
|
2710
|
+
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
2711
|
+
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
|
|
2712
|
+
const boundTab = activeTabs.find((tab) => isDebuggableUrl(tab.url)) ?? fallbackTabs.find((tab) => isDebuggableUrl(tab.url));
|
|
2713
|
+
if (!boundTab?.id) {
|
|
2714
|
+
return {
|
|
2715
|
+
id: cmd.id,
|
|
2716
|
+
ok: false,
|
|
2717
|
+
errorCode: "bound_tab_not_found",
|
|
2718
|
+
error: "No debuggable tab found in the current window",
|
|
2719
|
+
errorHint: "Focus the target Chrome tab/window, then retry bind."
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
const current = automationSessions.get(leaseKey);
|
|
2723
|
+
if (current && !current.owned && current.preferredTabId !== null && current.preferredTabId !== boundTab.id) {
|
|
2724
|
+
await detach(current.preferredTabId).catch(() => {
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
setLeaseSession(leaseKey, {
|
|
2728
|
+
session: getSessionFromKey(leaseKey),
|
|
2729
|
+
surface: getSurfaceFromKey(leaseKey),
|
|
2730
|
+
kind: "bound",
|
|
2731
|
+
windowId: boundTab.windowId,
|
|
2732
|
+
owned: false,
|
|
2733
|
+
preferredTabId: boundTab.id
|
|
2734
|
+
});
|
|
2735
|
+
resetWindowIdleTimer(leaseKey);
|
|
2736
|
+
console.log(`[opencli] Session ${getSessionFromKey(leaseKey)} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
|
|
2737
|
+
return pageScopedResult(cmd.id, boundTab.id, {
|
|
2738
|
+
url: boundTab.url,
|
|
2739
|
+
title: boundTab.title,
|
|
2740
|
+
session: getSessionFromKey(leaseKey)
|
|
2741
|
+
});
|
|
2742
|
+
}
|