@jackwener/opencli 1.5.7 → 1.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/extension-manifest-regression.test.js +1 -0
- package/extension/dist/background.js +736 -778
- package/extension/manifest.json +2 -1
- package/extension/src/background.ts +3 -2
- package/extension/src/cdp.test.ts +75 -0
- package/extension/src/cdp.ts +77 -3
- package/package.json +1 -1
- package/src/extension-manifest-regression.test.ts +1 -0
|
@@ -1,861 +1,819 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//#endregion
|
|
13
|
-
//#region src/cdp.ts
|
|
14
|
-
/**
|
|
15
|
-
* CDP execution via chrome.debugger API.
|
|
16
|
-
*
|
|
17
|
-
* chrome.debugger only needs the "debugger" permission — no host_permissions.
|
|
18
|
-
* It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
|
|
19
|
-
* tabs (resolveTabId in background.ts filters them).
|
|
20
|
-
*/
|
|
21
|
-
var attached = /* @__PURE__ */ new Set();
|
|
22
|
-
/** Internal blank page used when no user URL is provided. */
|
|
23
|
-
var BLANK_PAGE$1 = "data:text/html,<html></html>";
|
|
24
|
-
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
|
1
|
+
const DAEMON_PORT = 19825;
|
|
2
|
+
const DAEMON_HOST = "localhost";
|
|
3
|
+
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
|
4
|
+
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
|
|
5
|
+
const WS_RECONNECT_BASE_DELAY = 2e3;
|
|
6
|
+
const WS_RECONNECT_MAX_DELAY = 5e3;
|
|
7
|
+
|
|
8
|
+
const attached = /* @__PURE__ */ new Set();
|
|
9
|
+
const BLANK_PAGE$1 = "data:text/html,<html></html>";
|
|
10
|
+
const FOREIGN_EXTENSION_URL_PREFIX = "chrome-extension://";
|
|
11
|
+
const ATTACH_RECOVERY_DELAY_MS = 120;
|
|
25
12
|
function isDebuggableUrl$1(url) {
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
if (!url) return true;
|
|
14
|
+
return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE$1;
|
|
15
|
+
}
|
|
16
|
+
async function removeForeignExtensionEmbeds(tabId) {
|
|
17
|
+
const tab = await chrome.tabs.get(tabId);
|
|
18
|
+
if (!tab.url || !tab.url.startsWith("http://") && !tab.url.startsWith("https://")) {
|
|
19
|
+
return { removed: 0 };
|
|
20
|
+
}
|
|
21
|
+
if (!chrome.scripting?.executeScript) return { removed: 0 };
|
|
22
|
+
try {
|
|
23
|
+
const [result] = await chrome.scripting.executeScript({
|
|
24
|
+
target: { tabId },
|
|
25
|
+
args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`],
|
|
26
|
+
func: (ownExtensionPrefix) => {
|
|
27
|
+
const extensionPrefix = "chrome-extension://";
|
|
28
|
+
const selectors = ["iframe", "frame", "embed", "object"];
|
|
29
|
+
const visitedRoots = /* @__PURE__ */ new Set();
|
|
30
|
+
const roots = [document];
|
|
31
|
+
let removed = 0;
|
|
32
|
+
while (roots.length > 0) {
|
|
33
|
+
const root = roots.pop();
|
|
34
|
+
if (!root || visitedRoots.has(root)) continue;
|
|
35
|
+
visitedRoots.add(root);
|
|
36
|
+
for (const selector of selectors) {
|
|
37
|
+
const nodes = root.querySelectorAll(selector);
|
|
38
|
+
for (const node of nodes) {
|
|
39
|
+
const src = node.getAttribute("src") || node.getAttribute("data") || "";
|
|
40
|
+
if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue;
|
|
41
|
+
node.remove();
|
|
42
|
+
removed++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
46
|
+
let current = walker.nextNode();
|
|
47
|
+
while (current) {
|
|
48
|
+
const element = current;
|
|
49
|
+
if (element.shadowRoot) roots.push(element.shadowRoot);
|
|
50
|
+
current = walker.nextNode();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { removed };
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return result?.result ?? { removed: 0 };
|
|
57
|
+
} catch {
|
|
58
|
+
return { removed: 0 };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function delay(ms) {
|
|
62
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
async function tryAttach(tabId) {
|
|
65
|
+
await chrome.debugger.attach({ tabId }, "1.3");
|
|
28
66
|
}
|
|
29
67
|
async function ensureAttached(tabId) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
try {
|
|
69
|
+
const tab = await chrome.tabs.get(tabId);
|
|
70
|
+
if (!isDebuggableUrl$1(tab.url)) {
|
|
71
|
+
attached.delete(tabId);
|
|
72
|
+
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
|
|
76
|
+
attached.delete(tabId);
|
|
77
|
+
throw new Error(`Tab ${tabId} no longer exists`);
|
|
78
|
+
}
|
|
79
|
+
if (attached.has(tabId)) {
|
|
80
|
+
try {
|
|
81
|
+
await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
|
|
82
|
+
expression: "1",
|
|
83
|
+
returnByValue: true
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
} catch {
|
|
87
|
+
attached.delete(tabId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await tryAttach(tabId);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
94
|
+
const hint = msg.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : "";
|
|
95
|
+
if (msg.includes("chrome-extension://")) {
|
|
96
|
+
const recoveryCleanup = await removeForeignExtensionEmbeds(tabId);
|
|
97
|
+
if (recoveryCleanup.removed > 0) {
|
|
98
|
+
console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`);
|
|
99
|
+
}
|
|
100
|
+
await delay(ATTACH_RECOVERY_DELAY_MS);
|
|
101
|
+
try {
|
|
102
|
+
await tryAttach(tabId);
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error(`attach failed: ${msg}${hint}`);
|
|
105
|
+
}
|
|
106
|
+
} else if (msg.includes("Another debugger is already attached")) {
|
|
107
|
+
try {
|
|
108
|
+
await chrome.debugger.detach({ tabId });
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await tryAttach(tabId);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error(`attach failed: ${msg}${hint}`);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error(`attach failed: ${msg}${hint}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
attached.add(tabId);
|
|
121
|
+
try {
|
|
122
|
+
await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await chrome.debugger.sendCommand({ tabId }, "Debugger.enable");
|
|
127
|
+
await chrome.debugger.sendCommand({ tabId }, "Debugger.setBreakpointsActive", { active: false });
|
|
128
|
+
} catch {
|
|
129
|
+
}
|
|
70
130
|
}
|
|
71
131
|
async function evaluate(tabId, expression) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Capture a screenshot via CDP Page.captureScreenshot.
|
|
87
|
-
* Returns base64-encoded image data.
|
|
88
|
-
*/
|
|
132
|
+
await ensureAttached(tabId);
|
|
133
|
+
const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
|
|
134
|
+
expression,
|
|
135
|
+
returnByValue: true,
|
|
136
|
+
awaitPromise: true
|
|
137
|
+
});
|
|
138
|
+
if (result.exceptionDetails) {
|
|
139
|
+
const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error";
|
|
140
|
+
throw new Error(errMsg);
|
|
141
|
+
}
|
|
142
|
+
return result.result?.value;
|
|
143
|
+
}
|
|
144
|
+
const evaluateAsync = evaluate;
|
|
89
145
|
async function screenshot(tabId, options = {}) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
*/
|
|
146
|
+
await ensureAttached(tabId);
|
|
147
|
+
const format = options.format ?? "png";
|
|
148
|
+
if (options.fullPage) {
|
|
149
|
+
const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics");
|
|
150
|
+
const size = metrics.cssContentSize || metrics.contentSize;
|
|
151
|
+
if (size) {
|
|
152
|
+
await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
|
|
153
|
+
mobile: false,
|
|
154
|
+
width: Math.ceil(size.width),
|
|
155
|
+
height: Math.ceil(size.height),
|
|
156
|
+
deviceScaleFactor: 1
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const params = { format };
|
|
162
|
+
if (format === "jpeg" && options.quality !== void 0) {
|
|
163
|
+
params.quality = Math.max(0, Math.min(100, options.quality));
|
|
164
|
+
}
|
|
165
|
+
const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params);
|
|
166
|
+
return result.data;
|
|
167
|
+
} finally {
|
|
168
|
+
if (options.fullPage) {
|
|
169
|
+
await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
119
174
|
async function setFileInputFiles(tabId, files, selector) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
175
|
+
await ensureAttached(tabId);
|
|
176
|
+
await chrome.debugger.sendCommand({ tabId }, "DOM.enable");
|
|
177
|
+
const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument");
|
|
178
|
+
const query = selector || 'input[type="file"]';
|
|
179
|
+
const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", {
|
|
180
|
+
nodeId: doc.root.nodeId,
|
|
181
|
+
selector: query
|
|
182
|
+
});
|
|
183
|
+
if (!result.nodeId) {
|
|
184
|
+
throw new Error(`No element found matching selector: ${query}`);
|
|
185
|
+
}
|
|
186
|
+
await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", {
|
|
187
|
+
files,
|
|
188
|
+
nodeId: result.nodeId
|
|
189
|
+
});
|
|
133
190
|
}
|
|
134
191
|
async function detach(tabId) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
192
|
+
if (!attached.has(tabId)) return;
|
|
193
|
+
attached.delete(tabId);
|
|
194
|
+
try {
|
|
195
|
+
await chrome.debugger.detach({ tabId });
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
140
198
|
}
|
|
141
199
|
function registerListeners() {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
200
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
201
|
+
attached.delete(tabId);
|
|
202
|
+
});
|
|
203
|
+
chrome.debugger.onDetach.addListener((source) => {
|
|
204
|
+
if (source.tabId) attached.delete(source.tabId);
|
|
205
|
+
});
|
|
206
|
+
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
|
207
|
+
if (info.url && !isDebuggableUrl$1(info.url)) {
|
|
208
|
+
await detach(tabId);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let ws = null;
|
|
214
|
+
let reconnectTimer = null;
|
|
215
|
+
let reconnectAttempts = 0;
|
|
216
|
+
const _origLog = console.log.bind(console);
|
|
217
|
+
const _origWarn = console.warn.bind(console);
|
|
218
|
+
const _origError = console.error.bind(console);
|
|
160
219
|
function forwardLog(level, args) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
msg,
|
|
168
|
-
ts: Date.now()
|
|
169
|
-
}));
|
|
170
|
-
} catch {}
|
|
220
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
221
|
+
try {
|
|
222
|
+
const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
|
|
223
|
+
ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() }));
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
171
226
|
}
|
|
172
227
|
console.log = (...args) => {
|
|
173
|
-
|
|
174
|
-
|
|
228
|
+
_origLog(...args);
|
|
229
|
+
forwardLog("info", args);
|
|
175
230
|
};
|
|
176
231
|
console.warn = (...args) => {
|
|
177
|
-
|
|
178
|
-
|
|
232
|
+
_origWarn(...args);
|
|
233
|
+
forwardLog("warn", args);
|
|
179
234
|
};
|
|
180
235
|
console.error = (...args) => {
|
|
181
|
-
|
|
182
|
-
|
|
236
|
+
_origError(...args);
|
|
237
|
+
forwardLog("error", args);
|
|
183
238
|
};
|
|
184
|
-
/**
|
|
185
|
-
* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
|
|
186
|
-
* connection. fetch() failures are silently catchable; new WebSocket() is not
|
|
187
|
-
* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
|
|
188
|
-
* JS handler can intercept it. By keeping the probe inside connect() every
|
|
189
|
-
* call site remains unchanged and the guard can never be accidentally skipped.
|
|
190
|
-
*/
|
|
191
239
|
async function connect() {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects.
|
|
235
|
-
* The keepalive alarm (~24s) will still call connect() periodically, but at a
|
|
236
|
-
* much lower frequency — reducing console noise when the daemon is not running.
|
|
237
|
-
*/
|
|
238
|
-
var MAX_EAGER_ATTEMPTS = 6;
|
|
240
|
+
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
|
|
243
|
+
if (!res.ok) return;
|
|
244
|
+
} catch {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
ws = new WebSocket(DAEMON_WS_URL);
|
|
249
|
+
} catch {
|
|
250
|
+
scheduleReconnect();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
ws.onopen = () => {
|
|
254
|
+
console.log("[opencli] Connected to daemon");
|
|
255
|
+
reconnectAttempts = 0;
|
|
256
|
+
if (reconnectTimer) {
|
|
257
|
+
clearTimeout(reconnectTimer);
|
|
258
|
+
reconnectTimer = null;
|
|
259
|
+
}
|
|
260
|
+
ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version }));
|
|
261
|
+
};
|
|
262
|
+
ws.onmessage = async (event) => {
|
|
263
|
+
try {
|
|
264
|
+
const command = JSON.parse(event.data);
|
|
265
|
+
const result = await handleCommand(command);
|
|
266
|
+
ws?.send(JSON.stringify(result));
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error("[opencli] Message handling error:", err);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
ws.onclose = () => {
|
|
272
|
+
console.log("[opencli] Disconnected from daemon");
|
|
273
|
+
ws = null;
|
|
274
|
+
scheduleReconnect();
|
|
275
|
+
};
|
|
276
|
+
ws.onerror = () => {
|
|
277
|
+
ws?.close();
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const MAX_EAGER_ATTEMPTS = 6;
|
|
239
281
|
function scheduleReconnect() {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
282
|
+
if (reconnectTimer) return;
|
|
283
|
+
reconnectAttempts++;
|
|
284
|
+
if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return;
|
|
285
|
+
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
|
286
|
+
reconnectTimer = setTimeout(() => {
|
|
287
|
+
reconnectTimer = null;
|
|
288
|
+
void connect();
|
|
289
|
+
}, delay);
|
|
290
|
+
}
|
|
291
|
+
const automationSessions = /* @__PURE__ */ new Map();
|
|
292
|
+
const WINDOW_IDLE_TIMEOUT = 3e4;
|
|
251
293
|
function getWorkspaceKey(workspace) {
|
|
252
|
-
|
|
294
|
+
return workspace?.trim() || "default";
|
|
253
295
|
}
|
|
254
296
|
function resetWindowIdleTimer(workspace) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
297
|
+
const session = automationSessions.get(workspace);
|
|
298
|
+
if (!session) return;
|
|
299
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
300
|
+
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
|
|
301
|
+
session.idleTimer = setTimeout(async () => {
|
|
302
|
+
const current = automationSessions.get(workspace);
|
|
303
|
+
if (!current) return;
|
|
304
|
+
if (!current.owned) {
|
|
305
|
+
console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
|
|
306
|
+
automationSessions.delete(workspace);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
await chrome.windows.remove(current.windowId);
|
|
311
|
+
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
|
312
|
+
} catch {
|
|
313
|
+
}
|
|
314
|
+
automationSessions.delete(workspace);
|
|
315
|
+
}, WINDOW_IDLE_TIMEOUT);
|
|
316
|
+
}
|
|
275
317
|
async function getAutomationWindow(workspace) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
318
|
+
const existing = automationSessions.get(workspace);
|
|
319
|
+
if (existing) {
|
|
320
|
+
try {
|
|
321
|
+
await chrome.windows.get(existing.windowId);
|
|
322
|
+
return existing.windowId;
|
|
323
|
+
} catch {
|
|
324
|
+
automationSessions.delete(workspace);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const win = await chrome.windows.create({
|
|
328
|
+
url: BLANK_PAGE,
|
|
329
|
+
focused: false,
|
|
330
|
+
width: 1280,
|
|
331
|
+
height: 900,
|
|
332
|
+
type: "normal"
|
|
333
|
+
});
|
|
334
|
+
const session = {
|
|
335
|
+
windowId: win.id,
|
|
336
|
+
idleTimer: null,
|
|
337
|
+
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
|
338
|
+
owned: true,
|
|
339
|
+
preferredTabId: null
|
|
340
|
+
};
|
|
341
|
+
automationSessions.set(workspace, session);
|
|
342
|
+
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
|
343
|
+
resetWindowIdleTimer(workspace);
|
|
344
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
345
|
+
return session.windowId;
|
|
301
346
|
}
|
|
302
347
|
chrome.windows.onRemoved.addListener((windowId) => {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
348
|
+
for (const [workspace, session] of automationSessions.entries()) {
|
|
349
|
+
if (session.windowId === windowId) {
|
|
350
|
+
console.log(`[opencli] Automation window closed (${workspace})`);
|
|
351
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
352
|
+
automationSessions.delete(workspace);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
308
355
|
});
|
|
309
|
-
|
|
356
|
+
let initialized = false;
|
|
310
357
|
function initialize() {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
358
|
+
if (initialized) return;
|
|
359
|
+
initialized = true;
|
|
360
|
+
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
|
|
361
|
+
registerListeners();
|
|
362
|
+
void connect();
|
|
363
|
+
console.log("[opencli] OpenCLI extension initialized");
|
|
317
364
|
}
|
|
318
365
|
chrome.runtime.onInstalled.addListener(() => {
|
|
319
|
-
|
|
366
|
+
initialize();
|
|
320
367
|
});
|
|
321
368
|
chrome.runtime.onStartup.addListener(() => {
|
|
322
|
-
|
|
369
|
+
initialize();
|
|
323
370
|
});
|
|
324
371
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
325
|
-
|
|
372
|
+
if (alarm.name === "keepalive") void connect();
|
|
326
373
|
});
|
|
327
374
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
375
|
+
if (msg?.type === "getStatus") {
|
|
376
|
+
sendResponse({
|
|
377
|
+
connected: ws?.readyState === WebSocket.OPEN,
|
|
378
|
+
reconnecting: reconnectTimer !== null
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
333
382
|
});
|
|
334
383
|
async function handleCommand(cmd) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
384
|
+
const workspace = getWorkspaceKey(cmd.workspace);
|
|
385
|
+
resetWindowIdleTimer(workspace);
|
|
386
|
+
try {
|
|
387
|
+
switch (cmd.action) {
|
|
388
|
+
case "exec":
|
|
389
|
+
return await handleExec(cmd, workspace);
|
|
390
|
+
case "navigate":
|
|
391
|
+
return await handleNavigate(cmd, workspace);
|
|
392
|
+
case "tabs":
|
|
393
|
+
return await handleTabs(cmd, workspace);
|
|
394
|
+
case "cookies":
|
|
395
|
+
return await handleCookies(cmd);
|
|
396
|
+
case "screenshot":
|
|
397
|
+
return await handleScreenshot(cmd, workspace);
|
|
398
|
+
case "close-window":
|
|
399
|
+
return await handleCloseWindow(cmd, workspace);
|
|
400
|
+
case "sessions":
|
|
401
|
+
return await handleSessions(cmd);
|
|
402
|
+
case "set-file-input":
|
|
403
|
+
return await handleSetFileInput(cmd, workspace);
|
|
404
|
+
case "bind-current":
|
|
405
|
+
return await handleBindCurrent(cmd, workspace);
|
|
406
|
+
default:
|
|
407
|
+
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
return {
|
|
411
|
+
id: cmd.id,
|
|
412
|
+
ok: false,
|
|
413
|
+
error: err instanceof Error ? err.message : String(err)
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const BLANK_PAGE = "data:text/html,<html></html>";
|
|
365
418
|
function isDebuggableUrl(url) {
|
|
366
|
-
|
|
367
|
-
|
|
419
|
+
if (!url) return true;
|
|
420
|
+
return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE;
|
|
368
421
|
}
|
|
369
|
-
/** Check if a URL is safe for user-facing navigation (http/https only). */
|
|
370
422
|
function isSafeNavigationUrl(url) {
|
|
371
|
-
|
|
423
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
372
424
|
}
|
|
373
|
-
/** Minimal URL normalization for same-page comparison: root slash + default port only. */
|
|
374
425
|
function normalizeUrlForComparison(url) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
426
|
+
if (!url) return "";
|
|
427
|
+
try {
|
|
428
|
+
const parsed = new URL(url);
|
|
429
|
+
if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") {
|
|
430
|
+
parsed.port = "";
|
|
431
|
+
}
|
|
432
|
+
const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
|
|
433
|
+
return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
|
|
434
|
+
} catch {
|
|
435
|
+
return url;
|
|
436
|
+
}
|
|
384
437
|
}
|
|
385
438
|
function isTargetUrl(currentUrl, targetUrl) {
|
|
386
|
-
|
|
439
|
+
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
|
387
440
|
}
|
|
388
441
|
function matchesDomain(url, domain) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
442
|
+
if (!url) return false;
|
|
443
|
+
try {
|
|
444
|
+
const parsed = new URL(url);
|
|
445
|
+
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
|
|
446
|
+
} catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
396
449
|
}
|
|
397
450
|
function matchesBindCriteria(tab, cmd) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
451
|
+
if (!tab.id || !isDebuggableUrl(tab.url)) return false;
|
|
452
|
+
if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
|
|
453
|
+
if (cmd.matchPathPrefix) {
|
|
454
|
+
try {
|
|
455
|
+
const parsed = new URL(tab.url);
|
|
456
|
+
if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
406
462
|
}
|
|
407
463
|
function isNotebooklmWorkspace(workspace) {
|
|
408
|
-
|
|
464
|
+
return workspace === "site:notebooklm";
|
|
409
465
|
}
|
|
410
466
|
function classifyNotebooklmUrl(url) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
467
|
+
if (!url) return "other";
|
|
468
|
+
try {
|
|
469
|
+
const parsed = new URL(url);
|
|
470
|
+
if (parsed.hostname !== "notebooklm.google.com") return "other";
|
|
471
|
+
return parsed.pathname.startsWith("/notebook/") ? "notebook" : "home";
|
|
472
|
+
} catch {
|
|
473
|
+
return "other";
|
|
474
|
+
}
|
|
419
475
|
}
|
|
420
476
|
function scoreWorkspaceTab(workspace, tab) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
477
|
+
if (!tab.id || !isDebuggableUrl(tab.url)) return -1;
|
|
478
|
+
if (isNotebooklmWorkspace(workspace)) {
|
|
479
|
+
const kind = classifyNotebooklmUrl(tab.url);
|
|
480
|
+
if (kind === "other") return -1;
|
|
481
|
+
if (kind === "notebook") return tab.active ? 400 : 300;
|
|
482
|
+
return tab.active ? 200 : 100;
|
|
483
|
+
}
|
|
484
|
+
return -1;
|
|
429
485
|
}
|
|
430
486
|
function setWorkspaceSession(workspace, session) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
487
|
+
const existing = automationSessions.get(workspace);
|
|
488
|
+
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
|
|
489
|
+
automationSessions.set(workspace, {
|
|
490
|
+
...session,
|
|
491
|
+
idleTimer: null,
|
|
492
|
+
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT
|
|
493
|
+
});
|
|
438
494
|
}
|
|
439
495
|
async function maybeBindWorkspaceToExistingTab(workspace) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
/**
|
|
462
|
-
* Resolve target tab in the automation window.
|
|
463
|
-
* If explicit tabId is given, use that directly.
|
|
464
|
-
* Otherwise, find or create a tab in the dedicated automation window.
|
|
465
|
-
*/
|
|
496
|
+
if (!isNotebooklmWorkspace(workspace)) return null;
|
|
497
|
+
const tabs = await chrome.tabs.query({});
|
|
498
|
+
let bestTab = null;
|
|
499
|
+
let bestScore = -1;
|
|
500
|
+
for (const tab of tabs) {
|
|
501
|
+
const score = scoreWorkspaceTab(workspace, tab);
|
|
502
|
+
if (score > bestScore) {
|
|
503
|
+
bestScore = score;
|
|
504
|
+
bestTab = tab;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (!bestTab?.id || bestScore < 0) return null;
|
|
508
|
+
setWorkspaceSession(workspace, {
|
|
509
|
+
windowId: bestTab.windowId,
|
|
510
|
+
owned: false,
|
|
511
|
+
preferredTabId: bestTab.id
|
|
512
|
+
});
|
|
513
|
+
console.log(`[opencli] Workspace ${workspace} bound to existing tab ${bestTab.id} in window ${bestTab.windowId}`);
|
|
514
|
+
resetWindowIdleTimer(workspace);
|
|
515
|
+
return bestTab.id;
|
|
516
|
+
}
|
|
466
517
|
async function resolveTabId(tabId, workspace) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
518
|
+
if (tabId !== void 0) {
|
|
519
|
+
try {
|
|
520
|
+
const tab = await chrome.tabs.get(tabId);
|
|
521
|
+
const session = automationSessions.get(workspace);
|
|
522
|
+
const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false;
|
|
523
|
+
if (isDebuggableUrl(tab.url) && matchesSession) return tabId;
|
|
524
|
+
if (session && !matchesSession) {
|
|
525
|
+
console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`);
|
|
526
|
+
} else if (!isDebuggableUrl(tab.url)) {
|
|
527
|
+
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
|
528
|
+
}
|
|
529
|
+
} catch {
|
|
530
|
+
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const adoptedTabId = await maybeBindWorkspaceToExistingTab(workspace);
|
|
534
|
+
if (adoptedTabId !== null) return adoptedTabId;
|
|
535
|
+
const existingSession = automationSessions.get(workspace);
|
|
536
|
+
if (existingSession && existingSession.preferredTabId !== null) {
|
|
537
|
+
try {
|
|
538
|
+
const preferredTabId = existingSession.preferredTabId;
|
|
539
|
+
const preferredTab = await chrome.tabs.get(preferredTabId);
|
|
540
|
+
if (isDebuggableUrl(preferredTab.url)) return preferredTab.id;
|
|
541
|
+
} catch {
|
|
542
|
+
automationSessions.delete(workspace);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const windowId = await getAutomationWindow(workspace);
|
|
546
|
+
const tabs = await chrome.tabs.query({ windowId });
|
|
547
|
+
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
|
|
548
|
+
if (debuggableTab?.id) return debuggableTab.id;
|
|
549
|
+
const reuseTab = tabs.find((t) => t.id);
|
|
550
|
+
if (reuseTab?.id) {
|
|
551
|
+
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
|
|
552
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
553
|
+
try {
|
|
554
|
+
const updated = await chrome.tabs.get(reuseTab.id);
|
|
555
|
+
if (isDebuggableUrl(updated.url)) return reuseTab.id;
|
|
556
|
+
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
|
|
557
|
+
} catch {
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true });
|
|
561
|
+
if (!newTab.id) throw new Error("Failed to create tab in automation window");
|
|
562
|
+
return newTab.id;
|
|
507
563
|
}
|
|
508
564
|
async function listAutomationTabs(workspace) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
565
|
+
const session = automationSessions.get(workspace);
|
|
566
|
+
if (!session) return [];
|
|
567
|
+
if (session.preferredTabId !== null) {
|
|
568
|
+
try {
|
|
569
|
+
return [await chrome.tabs.get(session.preferredTabId)];
|
|
570
|
+
} catch {
|
|
571
|
+
automationSessions.delete(workspace);
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
return await chrome.tabs.query({ windowId: session.windowId });
|
|
577
|
+
} catch {
|
|
578
|
+
automationSessions.delete(workspace);
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
523
581
|
}
|
|
524
582
|
async function listAutomationWebTabs(workspace) {
|
|
525
|
-
|
|
583
|
+
const tabs = await listAutomationTabs(workspace);
|
|
584
|
+
return tabs.filter((tab) => isDebuggableUrl(tab.url));
|
|
526
585
|
}
|
|
527
586
|
async function handleExec(cmd, workspace) {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return {
|
|
537
|
-
id: cmd.id,
|
|
538
|
-
ok: true,
|
|
539
|
-
data
|
|
540
|
-
};
|
|
541
|
-
} catch (err) {
|
|
542
|
-
return {
|
|
543
|
-
id: cmd.id,
|
|
544
|
-
ok: false,
|
|
545
|
-
error: err instanceof Error ? err.message : String(err)
|
|
546
|
-
};
|
|
547
|
-
}
|
|
587
|
+
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
|
|
588
|
+
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
589
|
+
try {
|
|
590
|
+
const data = await evaluateAsync(tabId, cmd.code);
|
|
591
|
+
return { id: cmd.id, ok: true, data };
|
|
592
|
+
} catch (err) {
|
|
593
|
+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
594
|
+
}
|
|
548
595
|
}
|
|
549
596
|
async function handleNavigate(cmd, workspace) {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
id: cmd.id,
|
|
612
|
-
ok: true,
|
|
613
|
-
data: {
|
|
614
|
-
title: tab.title,
|
|
615
|
-
url: tab.url,
|
|
616
|
-
tabId,
|
|
617
|
-
timedOut
|
|
618
|
-
}
|
|
619
|
-
};
|
|
597
|
+
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
|
|
598
|
+
if (!isSafeNavigationUrl(cmd.url)) {
|
|
599
|
+
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
|
|
600
|
+
}
|
|
601
|
+
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
602
|
+
const beforeTab = await chrome.tabs.get(tabId);
|
|
603
|
+
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
|
|
604
|
+
const targetUrl = cmd.url;
|
|
605
|
+
if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) {
|
|
606
|
+
return {
|
|
607
|
+
id: cmd.id,
|
|
608
|
+
ok: true,
|
|
609
|
+
data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false }
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
await detach(tabId);
|
|
613
|
+
await chrome.tabs.update(tabId, { url: targetUrl });
|
|
614
|
+
let timedOut = false;
|
|
615
|
+
await new Promise((resolve) => {
|
|
616
|
+
let settled = false;
|
|
617
|
+
let checkTimer = null;
|
|
618
|
+
let timeoutTimer = null;
|
|
619
|
+
const finish = () => {
|
|
620
|
+
if (settled) return;
|
|
621
|
+
settled = true;
|
|
622
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
623
|
+
if (checkTimer) clearTimeout(checkTimer);
|
|
624
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
625
|
+
resolve();
|
|
626
|
+
};
|
|
627
|
+
const isNavigationDone = (url) => {
|
|
628
|
+
return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
|
|
629
|
+
};
|
|
630
|
+
const listener = (id, info, tab2) => {
|
|
631
|
+
if (id !== tabId) return;
|
|
632
|
+
if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) {
|
|
633
|
+
finish();
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
637
|
+
checkTimer = setTimeout(async () => {
|
|
638
|
+
try {
|
|
639
|
+
const currentTab = await chrome.tabs.get(tabId);
|
|
640
|
+
if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) {
|
|
641
|
+
finish();
|
|
642
|
+
}
|
|
643
|
+
} catch {
|
|
644
|
+
}
|
|
645
|
+
}, 100);
|
|
646
|
+
timeoutTimer = setTimeout(() => {
|
|
647
|
+
timedOut = true;
|
|
648
|
+
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
|
|
649
|
+
finish();
|
|
650
|
+
}, 15e3);
|
|
651
|
+
});
|
|
652
|
+
const tab = await chrome.tabs.get(tabId);
|
|
653
|
+
return {
|
|
654
|
+
id: cmd.id,
|
|
655
|
+
ok: true,
|
|
656
|
+
data: { title: tab.title, url: tab.url, tabId, timedOut }
|
|
657
|
+
};
|
|
620
658
|
}
|
|
621
659
|
async function handleTabs(cmd, workspace) {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
case "select": {
|
|
684
|
-
if (cmd.index === void 0 && cmd.tabId === void 0) return {
|
|
685
|
-
id: cmd.id,
|
|
686
|
-
ok: false,
|
|
687
|
-
error: "Missing index or tabId"
|
|
688
|
-
};
|
|
689
|
-
if (cmd.tabId !== void 0) {
|
|
690
|
-
const session = automationSessions.get(workspace);
|
|
691
|
-
let tab;
|
|
692
|
-
try {
|
|
693
|
-
tab = await chrome.tabs.get(cmd.tabId);
|
|
694
|
-
} catch {
|
|
695
|
-
return {
|
|
696
|
-
id: cmd.id,
|
|
697
|
-
ok: false,
|
|
698
|
-
error: `Tab ${cmd.tabId} no longer exists`
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
if (!session || tab.windowId !== session.windowId) return {
|
|
702
|
-
id: cmd.id,
|
|
703
|
-
ok: false,
|
|
704
|
-
error: `Tab ${cmd.tabId} is not in the automation window`
|
|
705
|
-
};
|
|
706
|
-
await chrome.tabs.update(cmd.tabId, { active: true });
|
|
707
|
-
return {
|
|
708
|
-
id: cmd.id,
|
|
709
|
-
ok: true,
|
|
710
|
-
data: { selected: cmd.tabId }
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
const target = (await listAutomationWebTabs(workspace))[cmd.index];
|
|
714
|
-
if (!target?.id) return {
|
|
715
|
-
id: cmd.id,
|
|
716
|
-
ok: false,
|
|
717
|
-
error: `Tab index ${cmd.index} not found`
|
|
718
|
-
};
|
|
719
|
-
await chrome.tabs.update(target.id, { active: true });
|
|
720
|
-
return {
|
|
721
|
-
id: cmd.id,
|
|
722
|
-
ok: true,
|
|
723
|
-
data: { selected: target.id }
|
|
724
|
-
};
|
|
725
|
-
}
|
|
726
|
-
default: return {
|
|
727
|
-
id: cmd.id,
|
|
728
|
-
ok: false,
|
|
729
|
-
error: `Unknown tabs op: ${cmd.op}`
|
|
730
|
-
};
|
|
731
|
-
}
|
|
660
|
+
switch (cmd.op) {
|
|
661
|
+
case "list": {
|
|
662
|
+
const tabs = await listAutomationWebTabs(workspace);
|
|
663
|
+
const data = tabs.map((t, i) => ({
|
|
664
|
+
index: i,
|
|
665
|
+
tabId: t.id,
|
|
666
|
+
url: t.url,
|
|
667
|
+
title: t.title,
|
|
668
|
+
active: t.active
|
|
669
|
+
}));
|
|
670
|
+
return { id: cmd.id, ok: true, data };
|
|
671
|
+
}
|
|
672
|
+
case "new": {
|
|
673
|
+
if (cmd.url && !isSafeNavigationUrl(cmd.url)) {
|
|
674
|
+
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
|
|
675
|
+
}
|
|
676
|
+
const windowId = await getAutomationWindow(workspace);
|
|
677
|
+
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
|
|
678
|
+
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
|
679
|
+
}
|
|
680
|
+
case "close": {
|
|
681
|
+
if (cmd.index !== void 0) {
|
|
682
|
+
const tabs = await listAutomationWebTabs(workspace);
|
|
683
|
+
const target = tabs[cmd.index];
|
|
684
|
+
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
685
|
+
await chrome.tabs.remove(target.id);
|
|
686
|
+
await detach(target.id);
|
|
687
|
+
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
|
688
|
+
}
|
|
689
|
+
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
690
|
+
await chrome.tabs.remove(tabId);
|
|
691
|
+
await detach(tabId);
|
|
692
|
+
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
|
693
|
+
}
|
|
694
|
+
case "select": {
|
|
695
|
+
if (cmd.index === void 0 && cmd.tabId === void 0)
|
|
696
|
+
return { id: cmd.id, ok: false, error: "Missing index or tabId" };
|
|
697
|
+
if (cmd.tabId !== void 0) {
|
|
698
|
+
const session = automationSessions.get(workspace);
|
|
699
|
+
let tab;
|
|
700
|
+
try {
|
|
701
|
+
tab = await chrome.tabs.get(cmd.tabId);
|
|
702
|
+
} catch {
|
|
703
|
+
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` };
|
|
704
|
+
}
|
|
705
|
+
if (!session || tab.windowId !== session.windowId) {
|
|
706
|
+
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` };
|
|
707
|
+
}
|
|
708
|
+
await chrome.tabs.update(cmd.tabId, { active: true });
|
|
709
|
+
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
|
710
|
+
}
|
|
711
|
+
const tabs = await listAutomationWebTabs(workspace);
|
|
712
|
+
const target = tabs[cmd.index];
|
|
713
|
+
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
714
|
+
await chrome.tabs.update(target.id, { active: true });
|
|
715
|
+
return { id: cmd.id, ok: true, data: { selected: target.id } };
|
|
716
|
+
}
|
|
717
|
+
default:
|
|
718
|
+
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
|
|
719
|
+
}
|
|
732
720
|
}
|
|
733
721
|
async function handleCookies(cmd) {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
return {
|
|
752
|
-
id: cmd.id,
|
|
753
|
-
ok: true,
|
|
754
|
-
data
|
|
755
|
-
};
|
|
722
|
+
if (!cmd.domain && !cmd.url) {
|
|
723
|
+
return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" };
|
|
724
|
+
}
|
|
725
|
+
const details = {};
|
|
726
|
+
if (cmd.domain) details.domain = cmd.domain;
|
|
727
|
+
if (cmd.url) details.url = cmd.url;
|
|
728
|
+
const cookies = await chrome.cookies.getAll(details);
|
|
729
|
+
const data = cookies.map((c) => ({
|
|
730
|
+
name: c.name,
|
|
731
|
+
value: c.value,
|
|
732
|
+
domain: c.domain,
|
|
733
|
+
path: c.path,
|
|
734
|
+
secure: c.secure,
|
|
735
|
+
httpOnly: c.httpOnly,
|
|
736
|
+
expirationDate: c.expirationDate
|
|
737
|
+
}));
|
|
738
|
+
return { id: cmd.id, ok: true, data };
|
|
756
739
|
}
|
|
757
740
|
async function handleScreenshot(cmd, workspace) {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
};
|
|
770
|
-
} catch (err) {
|
|
771
|
-
return {
|
|
772
|
-
id: cmd.id,
|
|
773
|
-
ok: false,
|
|
774
|
-
error: err instanceof Error ? err.message : String(err)
|
|
775
|
-
};
|
|
776
|
-
}
|
|
741
|
+
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
742
|
+
try {
|
|
743
|
+
const data = await screenshot(tabId, {
|
|
744
|
+
format: cmd.format,
|
|
745
|
+
quality: cmd.quality,
|
|
746
|
+
fullPage: cmd.fullPage
|
|
747
|
+
});
|
|
748
|
+
return { id: cmd.id, ok: true, data };
|
|
749
|
+
} catch (err) {
|
|
750
|
+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
751
|
+
}
|
|
777
752
|
}
|
|
778
753
|
async function handleCloseWindow(cmd, workspace) {
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
};
|
|
754
|
+
const session = automationSessions.get(workspace);
|
|
755
|
+
if (session) {
|
|
756
|
+
if (session.owned) {
|
|
757
|
+
try {
|
|
758
|
+
await chrome.windows.remove(session.windowId);
|
|
759
|
+
} catch {
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
763
|
+
automationSessions.delete(workspace);
|
|
764
|
+
}
|
|
765
|
+
return { id: cmd.id, ok: true, data: { closed: true } };
|
|
792
766
|
}
|
|
793
767
|
async function handleSetFileInput(cmd, workspace) {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
ok: true,
|
|
805
|
-
data: { count: cmd.files.length }
|
|
806
|
-
};
|
|
807
|
-
} catch (err) {
|
|
808
|
-
return {
|
|
809
|
-
id: cmd.id,
|
|
810
|
-
ok: false,
|
|
811
|
-
error: err instanceof Error ? err.message : String(err)
|
|
812
|
-
};
|
|
813
|
-
}
|
|
768
|
+
if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) {
|
|
769
|
+
return { id: cmd.id, ok: false, error: "Missing or empty files array" };
|
|
770
|
+
}
|
|
771
|
+
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
772
|
+
try {
|
|
773
|
+
await setFileInputFiles(tabId, cmd.files, cmd.selector);
|
|
774
|
+
return { id: cmd.id, ok: true, data: { count: cmd.files.length } };
|
|
775
|
+
} catch (err) {
|
|
776
|
+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
777
|
+
}
|
|
814
778
|
}
|
|
815
779
|
async function handleSessions(cmd) {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
id: cmd.id,
|
|
825
|
-
ok: true,
|
|
826
|
-
data
|
|
827
|
-
};
|
|
780
|
+
const now = Date.now();
|
|
781
|
+
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
|
782
|
+
workspace,
|
|
783
|
+
windowId: session.windowId,
|
|
784
|
+
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
|
|
785
|
+
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
|
|
786
|
+
})));
|
|
787
|
+
return { id: cmd.id, ok: true, data };
|
|
828
788
|
}
|
|
829
789
|
async function handleBindCurrent(cmd, workspace) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
861
|
-
//#endregion
|
|
790
|
+
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
791
|
+
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
|
|
792
|
+
const allTabs = await chrome.tabs.query({});
|
|
793
|
+
const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
|
|
794
|
+
if (!boundTab?.id) {
|
|
795
|
+
return {
|
|
796
|
+
id: cmd.id,
|
|
797
|
+
ok: false,
|
|
798
|
+
error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found"
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
setWorkspaceSession(workspace, {
|
|
802
|
+
windowId: boundTab.windowId,
|
|
803
|
+
owned: false,
|
|
804
|
+
preferredTabId: boundTab.id
|
|
805
|
+
});
|
|
806
|
+
resetWindowIdleTimer(workspace);
|
|
807
|
+
console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
|
|
808
|
+
return {
|
|
809
|
+
id: cmd.id,
|
|
810
|
+
ok: true,
|
|
811
|
+
data: {
|
|
812
|
+
tabId: boundTab.id,
|
|
813
|
+
windowId: boundTab.windowId,
|
|
814
|
+
url: boundTab.url,
|
|
815
|
+
title: boundTab.title,
|
|
816
|
+
workspace
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
}
|