browser-ctl 0.2.7__tar.gz → 0.2.9__tar.gz

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.
Files changed (27) hide show
  1. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/PKG-INFO +1 -1
  2. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/SKILL.md +70 -3
  3. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/cli.py +11 -0
  4. browser_ctl-0.2.9/browser_ctl/extension/actions.js +502 -0
  5. browser_ctl-0.2.9/browser_ctl/extension/background.js +258 -0
  6. browser_ctl-0.2.9/browser_ctl/extension/click.js +338 -0
  7. browser_ctl-0.2.9/browser_ctl/extension/content-script.js +1001 -0
  8. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/extension/manifest.json +3 -2
  9. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl.egg-info/PKG-INFO +1 -1
  10. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl.egg-info/SOURCES.txt +3 -0
  11. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/pyproject.toml +1 -1
  12. browser_ctl-0.2.7/browser_ctl/extension/background.js +0 -1389
  13. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/LICENSE +0 -0
  14. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/README.md +0 -0
  15. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/__init__.py +0 -0
  16. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/__main__.py +0 -0
  17. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/client.py +0 -0
  18. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/extension/icon-128.png +0 -0
  19. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/extension/icon-16.png +0 -0
  20. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/extension/icon-32.png +0 -0
  21. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/extension/icon-48.png +0 -0
  22. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl/server.py +0 -0
  23. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl.egg-info/dependency_links.txt +0 -0
  24. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl.egg-info/entry_points.txt +0 -0
  25. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl.egg-info/requires.txt +0 -0
  26. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/browser_ctl.egg-info/top_level.txt +0 -0
  27. {browser_ctl-0.2.7 → browser_ctl-0.2.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browser-ctl
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: Control your browser from the command line via a Chrome extension + WebSocket bridge
5
5
  Author-email: geb <853934146@qq.com>
6
6
  License-Expression: MIT
@@ -80,8 +80,8 @@ bctl eval <code> Execute JS in page context (MAIN world)
80
80
 
81
81
  ### Tabs
82
82
  ```
83
- bctl tabs List all tabs (id, url, title, active)
84
- bctl tab <id> Switch to tab
83
+ bctl tabs List all tabs (id, url, title, active, windowId)
84
+ bctl tab <id> Switch to tab (also focuses the containing window)
85
85
  bctl new-tab [url] Open new tab
86
86
  bctl close-tab [id] Close tab (default: active)
87
87
  ```
@@ -166,6 +166,69 @@ bctl go "https://v.qq.com/x/cover/<cid>.html"
166
166
  - After navigation: `bctl wait 2-3` or `bctl wait "<selector>" 10`
167
167
  - After hover for overlay: `bctl wait 1`
168
168
  - AI generation: **poll** with `bctl wait 5 && bctl count "selector"` in a loop
169
+ - Pure sleep (`bctl wait N`) runs locally in Python — no extension round-trip, so it
170
+ never times out even on heavy pages.
171
+
172
+ ### Heavy SPA Pages (YouTube, Gmail, etc.)
173
+ Heavy SPA pages can cause the extension service worker to become unresponsive during
174
+ page load. To avoid timeouts:
175
+ - **Don't chain `bctl wait` with navigation via `&&`** — if the page is loading, the
176
+ wait command may timeout because the extension is busy. Instead, run them separately:
177
+ ```bash
178
+ bctl go "https://www.youtube.com"
179
+ bctl wait 3
180
+ bctl status
181
+ ```
182
+ - **Use `bctl go` instead of `bctl new-tab`** when you just need to navigate — it's
183
+ more reliable because it reuses the current tab instead of creating a new one.
184
+ - **If `new-tab` times out but the tab was created**, use `bctl tabs` to find it and
185
+ `bctl tab <id>` to switch to it.
186
+
187
+ ### Multi-Window Awareness
188
+ `bctl tabs` returns tabs from ALL windows with `windowId` and `focusedWindowId`.
189
+ `bctl tab <id>` automatically focuses the containing window before activating the tab,
190
+ so cross-window tab switching works reliably.
191
+
192
+ When working with multiple windows:
193
+ - Check `windowId` in `bctl tabs` output to understand which window each tab belongs to
194
+ - `bctl tab <id>` handles cross-window switching automatically
195
+ - `bctl status` and `bctl snapshot` always operate on the active tab of the **focused** window
196
+
197
+ ### SPA Form Interactions (React, Vue, Angular, etc.)
198
+ Modern SPA frameworks manage form state internally. **Never use `bctl eval` to set
199
+ form values or click buttons** — it bypasses the framework's event system:
200
+
201
+ ```bash
202
+ # BAD — bypasses React state, the dropdown/filter won't actually update:
203
+ bctl eval "document.querySelector('input').value = 'hello'; ..."
204
+
205
+ # GOOD — triggers real keyboard events that React/Vue can observe:
206
+ bctl type "input" "hello"
207
+
208
+ # BAD — JS .click() doesn't fire full pointer+mouse sequence, SPA may ignore it:
209
+ bctl eval "document.querySelector('button').click()"
210
+
211
+ # GOOD — dispatches real mousedown/mouseup/click events:
212
+ bctl click "button" -t "Submit"
213
+ ```
214
+
215
+ **`bctl type`** — sets value and fires focus/input/change events; works for most
216
+ React/Vue inputs including search filters and form fields.
217
+
218
+ **`bctl input-text`** — types character-by-character with real keydown/keypress/keyup
219
+ events; use for rich text editors, autocomplete fields, or when `type` doesn't trigger
220
+ the expected behavior. Add `--delay 50` if the app debounces input.
221
+
222
+ **Complex dropdowns/pickers** (tag selectors, date pickers, combo boxes):
223
+ 1. `bctl click` to open the dropdown
224
+ 2. `bctl wait 1` for the dropdown to render
225
+ 3. `bctl type` into the filter/search input (NOT `bctl eval` with `value =`)
226
+ 4. `bctl wait 1` for results to filter
227
+ 5. `bctl click` on the target option (use `-t` for text matching)
228
+ 6. **Verify** the selection: `bctl snapshot` or `bctl text` to confirm state changed
229
+
230
+ Always verify after complex interactions — if the state didn't change, retry with
231
+ `bctl input-text --delay 50` instead of `bctl type`.
169
232
 
170
233
  ### Data Extraction
171
234
  Prefer `bctl select` over `bctl eval` — it's more reliable, works on all sites,
@@ -178,11 +241,15 @@ and returns text/href/id/class/aria-label automatically.
178
241
  3. **Use `download` for authenticated resources** — never `curl` from sites behind login.
179
242
  4. **Use `hover` before clicking overlay buttons** — many UIs hide actions until hover.
180
243
  5. **Check `tabs` after tab-opening actions** — popups may switch the active tab.
181
- 6. **Chain commands** with `&&`: `bctl go "https://example.com" && bctl wait 2 && bctl status`
244
+ 6. **Don't chain `&&` with `bctl wait` after navigation** run them as separate commands.
245
+ 7. **Prefer `bctl go` over `bctl new-tab`** for simple navigation — fewer failure modes.
246
+ 8. **Never use `eval` to set input values or click buttons on SPA sites** — use `type`/`input-text`/`click`.
247
+ 9. **Verify after complex UI interactions** — `snapshot` or `text` to confirm state changed.
182
248
 
183
249
  ## Known Limitations
184
250
 
185
251
  - `eval` blocked by Trusted Types on some sites (Gemini, YouTube) — use `attr`/`select` instead
252
+ - `eval` with `input.value = ...` or `.click()` bypasses SPA framework state — use `type`/`click` instead
186
253
  - `screenshot` captures visible viewport only — scroll for full-page capture
187
254
  - Without `-i`, `click` always hits the FIRST match — use `count` to check first
188
255
 
@@ -739,6 +739,17 @@ def main():
739
739
 
740
740
  # Standard command: parse args, send to server
741
741
  action, params = args_to_action_params(cmd, args)
742
+
743
+ # Handle pure sleep locally — avoids extension round-trip which can
744
+ # timeout on heavy SPA pages (YouTube, etc.) where the service worker
745
+ # becomes unresponsive during page load.
746
+ if action == "wait" and "seconds" in params:
747
+ import time
748
+ seconds = params["seconds"]
749
+ time.sleep(seconds)
750
+ print(json.dumps({"success": True, "data": {"waited": seconds}}))
751
+ return
752
+
742
753
  _client().send_command(action, params)
743
754
 
744
755
 
@@ -0,0 +1,502 @@
1
+ /**
2
+ * actions.js — Chrome API action handlers for Browser-Ctl.
3
+ *
4
+ * Handles navigation, tabs, screenshot, eval, download, upload, dialog,
5
+ * wait, and the runInPage/doBatch helpers for content-script operations.
6
+ */
7
+
8
+ import { contentScriptHandler } from "./content-script.js";
9
+
10
+ // =========================================================================
11
+ // Shared helpers
12
+ // =========================================================================
13
+
14
+ /** Get the currently active tab. */
15
+ export async function activeTab() {
16
+ const [tab] = await chrome.tabs.query({
17
+ active: true,
18
+ currentWindow: true,
19
+ });
20
+ if (!tab) throw new Error("No active tab");
21
+ return tab;
22
+ }
23
+
24
+ /**
25
+ * Wait for a tab to finish loading after navigation.
26
+ * Returns { loaded: boolean, timedOut: boolean } so callers can decide.
27
+ */
28
+ export function waitForTabLoad(tabId, timeoutMs = 10000) {
29
+ return new Promise((resolve) => {
30
+ const timer = setTimeout(() => {
31
+ chrome.tabs.onUpdated.removeListener(listener);
32
+ resolve({ loaded: false, timedOut: true });
33
+ }, timeoutMs);
34
+
35
+ function listener(updatedTabId, info) {
36
+ if (updatedTabId === tabId && info.status === "complete") {
37
+ clearTimeout(timer);
38
+ chrome.tabs.onUpdated.removeListener(listener);
39
+ resolve({ loaded: true, timedOut: false });
40
+ }
41
+ }
42
+ chrome.tabs.onUpdated.addListener(listener);
43
+ });
44
+ }
45
+
46
+ // =========================================================================
47
+ // Content-script injection helpers
48
+ // =========================================================================
49
+
50
+ /**
51
+ * Run a single DOM operation in the active tab.
52
+ * Wraps the command as a 1-element batch and extracts the first result.
53
+ * Includes lightweight retry for transient service-worker issues.
54
+ */
55
+ export async function runInPage(op, params) {
56
+ const tab = await activeTab();
57
+
58
+ for (let attempt = 0; attempt < 2; attempt++) {
59
+ try {
60
+ const results = await chrome.scripting.executeScript({
61
+ target: { tabId: tab.id },
62
+ func: contentScriptHandler,
63
+ args: [[{ op, params }]],
64
+ });
65
+
66
+ const arr = results[0]?.result;
67
+ if (!arr || !arr.length) {
68
+ if (attempt === 0) continue; // Retry once
69
+ throw new Error("Content script returned no result");
70
+ }
71
+ const r = arr[0];
72
+ if (!r.success)
73
+ throw new Error(r.error || "Content script operation failed");
74
+ return r.data;
75
+ } catch (e) {
76
+ if (attempt === 0 && e.message?.includes("no result")) continue;
77
+ throw e;
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Execute multiple DOM operations in a single executeScript call.
84
+ * Called by the "batch" action.
85
+ */
86
+ export async function doBatch(params) {
87
+ const commands = params.commands || [];
88
+ if (!commands.length) return { results: [] };
89
+
90
+ const tab = await activeTab();
91
+ const ops = commands.map((c) => ({ op: c.action, params: c.params }));
92
+
93
+ const results = await chrome.scripting.executeScript({
94
+ target: { tabId: tab.id },
95
+ func: contentScriptHandler,
96
+ args: [ops],
97
+ });
98
+
99
+ const arr = results[0]?.result;
100
+ if (!arr) throw new Error("Batch content script returned no result");
101
+ return { results: arr };
102
+ }
103
+
104
+ // =========================================================================
105
+ // Navigation commands
106
+ // =========================================================================
107
+
108
+ export async function doNavigate(params) {
109
+ const url = params.url;
110
+ if (!url) throw new Error("Missing 'url' parameter");
111
+ const tab = await activeTab();
112
+ await chrome.tabs.update(tab.id, { url });
113
+ await waitForTabLoad(tab.id);
114
+ const updated = await chrome.tabs.get(tab.id);
115
+ return { url: updated.url, title: updated.title };
116
+ }
117
+
118
+ export async function doBack() {
119
+ const tab = await activeTab();
120
+ await chrome.tabs.goBack(tab.id);
121
+ await waitForTabLoad(tab.id);
122
+ const updated = await chrome.tabs.get(tab.id);
123
+ return { url: updated.url, title: updated.title };
124
+ }
125
+
126
+ export async function doForward() {
127
+ const tab = await activeTab();
128
+ await chrome.tabs.goForward(tab.id);
129
+ await waitForTabLoad(tab.id);
130
+ const updated = await chrome.tabs.get(tab.id);
131
+ return { url: updated.url, title: updated.title };
132
+ }
133
+
134
+ export async function doReload() {
135
+ const tab = await activeTab();
136
+ await chrome.tabs.reload(tab.id);
137
+ await waitForTabLoad(tab.id);
138
+ const updated = await chrome.tabs.get(tab.id);
139
+ return { url: updated.url, title: updated.title };
140
+ }
141
+
142
+ // =========================================================================
143
+ // Tab commands
144
+ // =========================================================================
145
+
146
+ export async function doTabs() {
147
+ const tabs = await chrome.tabs.query({});
148
+ const focusedWindow = await chrome.windows.getLastFocused();
149
+ return {
150
+ tabs: tabs.map((t) => ({
151
+ id: t.id,
152
+ url: t.url,
153
+ title: t.title,
154
+ active: t.active,
155
+ windowId: t.windowId,
156
+ })),
157
+ focusedWindowId: focusedWindow.id,
158
+ };
159
+ }
160
+
161
+ export async function doSwitchTab(params) {
162
+ const tabId = parseInt(params.id, 10);
163
+ if (isNaN(tabId)) throw new Error("Missing or invalid 'id' parameter");
164
+ const tab = await chrome.tabs.get(tabId);
165
+ await chrome.windows.update(tab.windowId, { focused: true });
166
+ await chrome.tabs.update(tabId, { active: true });
167
+ const updated = await chrome.tabs.get(tabId);
168
+ return {
169
+ id: updated.id,
170
+ url: updated.url,
171
+ title: updated.title,
172
+ windowId: updated.windowId,
173
+ };
174
+ }
175
+
176
+ export async function doNewTab(params) {
177
+ const tab = await chrome.tabs.create({ url: params.url || "about:blank" });
178
+ if (params.url) await waitForTabLoad(tab.id);
179
+ const updated = await chrome.tabs.get(tab.id);
180
+ return { id: updated.id, url: updated.url, title: updated.title };
181
+ }
182
+
183
+ export async function doCloseTab(params) {
184
+ const tabId = params.id ? parseInt(params.id, 10) : (await activeTab()).id;
185
+ await chrome.tabs.remove(tabId);
186
+ return { closed: tabId };
187
+ }
188
+
189
+ // =========================================================================
190
+ // Status / Screenshot
191
+ // =========================================================================
192
+
193
+ export async function doStatus() {
194
+ const tab = await activeTab();
195
+ return { url: tab.url, title: tab.title, id: tab.id };
196
+ }
197
+
198
+ export async function doScreenshot() {
199
+ const dataUrl = await chrome.tabs.captureVisibleTab(null, { format: "png" });
200
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, "");
201
+ return { format: "png", base64 };
202
+ }
203
+
204
+ // =========================================================================
205
+ // Download
206
+ // =========================================================================
207
+
208
+ export async function doDownload(params) {
209
+ const { url, selector, filename, index } = params;
210
+
211
+ let downloadUrl = url;
212
+
213
+ if (selector && !url) {
214
+ const result = await runInPage("extractUrl", { selector, index });
215
+ downloadUrl = result.url;
216
+ if (!downloadUrl)
217
+ throw new Error(`No downloadable URL found on element: ${selector}`);
218
+ }
219
+
220
+ if (!downloadUrl)
221
+ throw new Error("Missing 'url' or 'selector' parameter");
222
+
223
+ const downloadId = await new Promise((resolve, reject) => {
224
+ chrome.downloads.download(
225
+ {
226
+ url: downloadUrl,
227
+ filename: filename || undefined,
228
+ conflictAction: "uniquify",
229
+ },
230
+ (id) => {
231
+ if (chrome.runtime.lastError) {
232
+ reject(new Error(chrome.runtime.lastError.message));
233
+ } else {
234
+ resolve(id);
235
+ }
236
+ }
237
+ );
238
+ });
239
+
240
+ const info = await waitForDownload(downloadId);
241
+ return info;
242
+ }
243
+
244
+ function waitForDownload(downloadId, timeoutMs = 30000) {
245
+ return new Promise((resolve) => {
246
+ const timer = setTimeout(() => {
247
+ chrome.downloads.onChanged.removeListener(listener);
248
+ resolve({ downloadId, state: "timeout" });
249
+ }, timeoutMs);
250
+
251
+ function listener(delta) {
252
+ if (delta.id !== downloadId) return;
253
+ if (delta.state && delta.state.current === "complete") {
254
+ clearTimeout(timer);
255
+ chrome.downloads.onChanged.removeListener(listener);
256
+ chrome.downloads.search({ id: downloadId }, (results) => {
257
+ const item = results[0];
258
+ resolve({
259
+ downloadId,
260
+ state: "complete",
261
+ filename: item?.filename,
262
+ fileSize: item?.fileSize,
263
+ mime: item?.mime,
264
+ });
265
+ });
266
+ } else if (delta.state && delta.state.current === "interrupted") {
267
+ clearTimeout(timer);
268
+ chrome.downloads.onChanged.removeListener(listener);
269
+ resolve({
270
+ downloadId,
271
+ state: "failed",
272
+ error: delta.error?.current,
273
+ });
274
+ }
275
+ }
276
+ chrome.downloads.onChanged.addListener(listener);
277
+ });
278
+ }
279
+
280
+ // =========================================================================
281
+ // Press key
282
+ // =========================================================================
283
+
284
+ export async function doPress(params) {
285
+ const key = params.key;
286
+ if (!key) throw new Error("Missing 'key' parameter");
287
+ return await runInPage("press", { key });
288
+ }
289
+
290
+ // =========================================================================
291
+ // Eval (script injection with CDP fallback)
292
+ // =========================================================================
293
+
294
+ export async function doEval(params) {
295
+ const code = params.code;
296
+ if (!code) throw new Error("Missing 'code' parameter");
297
+ const tab = await activeTab();
298
+
299
+ // Strategy 1: MAIN world <script> tag injection (fast path)
300
+ const results = await chrome.scripting.executeScript({
301
+ target: { tabId: tab.id },
302
+ func: (userCode) => {
303
+ const key = "__bctl_r_" + Math.random().toString(36).slice(2);
304
+ const script = document.createElement("script");
305
+ script.textContent =
306
+ "try{window['" +
307
+ key +
308
+ "']={v:(0,eval)(" +
309
+ JSON.stringify(userCode) +
310
+ ")}}" +
311
+ "catch(e){window['" +
312
+ key +
313
+ "']={e:e.message||String(e)}}";
314
+ (document.head || document.documentElement).appendChild(script);
315
+ script.remove();
316
+ const r = window[key];
317
+ delete window[key];
318
+ if (!r) return null; // CSP blocked — fall through to debugger
319
+ if (r.e !== undefined) return { error: r.e };
320
+ return { value: r.v, ok: true };
321
+ },
322
+ args: [code],
323
+ world: "MAIN",
324
+ });
325
+
326
+ const r = results[0]?.result;
327
+ if (r && r.ok) return { result: r.value ?? null };
328
+ if (r && r.error) throw new Error(r.error);
329
+
330
+ // Strategy 2: Chrome DevTools Protocol via chrome.debugger (CSP fallback)
331
+ return await evalViaDebugger(tab.id, code);
332
+ }
333
+
334
+ async function evalViaDebugger(tabId, code) {
335
+ try {
336
+ await chrome.debugger.attach({ tabId }, "1.3");
337
+ } catch (e) {
338
+ if (e.message?.includes("Another debugger")) {
339
+ throw new Error(
340
+ "eval: cannot attach debugger (DevTools may be open). Close DevTools and retry, or use 'bctl select'/'bctl text' for DOM queries."
341
+ );
342
+ }
343
+ throw new Error("eval: cannot attach debugger — " + (e.message || e));
344
+ }
345
+
346
+ try {
347
+ const result = await chrome.debugger.sendCommand(
348
+ { tabId },
349
+ "Runtime.evaluate",
350
+ { expression: code, returnByValue: true, awaitPromise: false }
351
+ );
352
+
353
+ if (result.exceptionDetails) {
354
+ const desc =
355
+ result.exceptionDetails.exception?.description ||
356
+ result.exceptionDetails.text ||
357
+ "Evaluation failed";
358
+ throw new Error(desc);
359
+ }
360
+
361
+ return { result: result.result?.value ?? null };
362
+ } finally {
363
+ try {
364
+ await chrome.debugger.detach({ tabId });
365
+ } catch (_) {
366
+ // ignore detach errors
367
+ }
368
+ }
369
+ }
370
+
371
+ // =========================================================================
372
+ // Wait
373
+ // =========================================================================
374
+
375
+ export async function doWait(params) {
376
+ const selector = params.selector;
377
+ const timeout = params.timeout ?? 5;
378
+
379
+ if (!selector) {
380
+ const seconds = parseFloat(params.seconds ?? params.selector ?? timeout);
381
+ await new Promise((r) => setTimeout(r, seconds * 1000));
382
+ return { waited: seconds };
383
+ }
384
+
385
+ return await runInPage("wait", { selector, timeout });
386
+ }
387
+
388
+ // =========================================================================
389
+ // Upload (via Chrome DevTools Protocol)
390
+ // =========================================================================
391
+
392
+ export async function doUpload(params) {
393
+ const { selector, files } = params;
394
+ if (!selector) throw new Error("Missing 'selector' parameter");
395
+ if (!files || !files.length) throw new Error("Missing 'files' parameter");
396
+
397
+ const tab = await activeTab();
398
+
399
+ try {
400
+ await chrome.debugger.attach({ tabId: tab.id }, "1.3");
401
+ } catch (e) {
402
+ if (e.message?.includes("Another debugger")) {
403
+ throw new Error(
404
+ "upload: cannot attach debugger (DevTools may be open). Close DevTools and retry."
405
+ );
406
+ }
407
+ throw new Error("upload: cannot attach debugger — " + (e.message || e));
408
+ }
409
+
410
+ try {
411
+ const doc = await chrome.debugger.sendCommand(
412
+ { tabId: tab.id },
413
+ "DOM.getDocument",
414
+ {}
415
+ );
416
+
417
+ const nodeResult = await chrome.debugger.sendCommand(
418
+ { tabId: tab.id },
419
+ "DOM.querySelector",
420
+ { nodeId: doc.root.nodeId, selector }
421
+ );
422
+
423
+ if (!nodeResult.nodeId) {
424
+ throw new Error(`Element not found: ${selector}`);
425
+ }
426
+
427
+ await chrome.debugger.sendCommand(
428
+ { tabId: tab.id },
429
+ "DOM.setFileInputFiles",
430
+ { files, nodeId: nodeResult.nodeId }
431
+ );
432
+
433
+ return { uploaded: files.length, files, selector };
434
+ } finally {
435
+ try {
436
+ await chrome.debugger.detach({ tabId: tab.id });
437
+ } catch (_) {
438
+ // ignore detach errors
439
+ }
440
+ }
441
+ }
442
+
443
+ // =========================================================================
444
+ // Dialog handling
445
+ // =========================================================================
446
+
447
+ export async function doDialog(params) {
448
+ const accept = params.accept !== false;
449
+ const text = params.text || "";
450
+
451
+ const tab = await activeTab();
452
+
453
+ await chrome.scripting.executeScript({
454
+ target: { tabId: tab.id },
455
+ func: (shouldAccept, responseText) => {
456
+ const origAlert = window.alert;
457
+ const origConfirm = window.confirm;
458
+ const origPrompt = window.prompt;
459
+
460
+ function restore() {
461
+ window.alert = origAlert;
462
+ window.confirm = origConfirm;
463
+ window.prompt = origPrompt;
464
+ }
465
+
466
+ window.alert = function (message) {
467
+ window.__bctl_last_dialog = {
468
+ type: "alert",
469
+ message: String(message),
470
+ };
471
+ restore();
472
+ };
473
+
474
+ window.confirm = function (message) {
475
+ window.__bctl_last_dialog = {
476
+ type: "confirm",
477
+ message: String(message),
478
+ returned: shouldAccept,
479
+ };
480
+ restore();
481
+ return shouldAccept;
482
+ };
483
+
484
+ window.prompt = function (message, defaultValue) {
485
+ const value = shouldAccept
486
+ ? responseText || defaultValue || ""
487
+ : null;
488
+ window.__bctl_last_dialog = {
489
+ type: "prompt",
490
+ message: String(message),
491
+ returned: value,
492
+ };
493
+ restore();
494
+ return value;
495
+ };
496
+ },
497
+ args: [accept, text],
498
+ world: "MAIN",
499
+ });
500
+
501
+ return { handler: accept ? "accept" : "dismiss", text: text || null };
502
+ }