browser-ctl 0.2.12__tar.gz → 0.2.13__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 (26) hide show
  1. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/PKG-INFO +1 -1
  2. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/SKILL.md +18 -1
  3. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/actions.js +81 -13
  4. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/click.js +51 -3
  5. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/content-script.js +31 -11
  6. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/manifest.json +1 -1
  7. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/PKG-INFO +1 -1
  8. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/pyproject.toml +1 -1
  9. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/LICENSE +0 -0
  10. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/README.md +0 -0
  11. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/__init__.py +0 -0
  12. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/__main__.py +0 -0
  13. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/cli.py +0 -0
  14. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/client.py +0 -0
  15. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/background.js +0 -0
  16. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/icon-128.png +0 -0
  17. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/icon-16.png +0 -0
  18. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/icon-32.png +0 -0
  19. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/icon-48.png +0 -0
  20. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/server.py +0 -0
  21. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/SOURCES.txt +0 -0
  22. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/dependency_links.txt +0 -0
  23. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/entry_points.txt +0 -0
  24. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/requires.txt +0 -0
  25. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/top_level.txt +0 -0
  26. {browser_ctl-0.2.12 → browser_ctl-0.2.13}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browser-ctl
3
- Version: 0.2.12
3
+ Version: 0.2.13
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
@@ -59,9 +59,10 @@ bctl click <sel> [-i N] [-t text] Click element; -t filters by visible text
59
59
  bctl dblclick <sel> [-i N] [-t text] Double-click
60
60
  bctl hover <sel> [-i N] [-t text] Hover (triggers mouseover)
61
61
  bctl focus <sel> [-i N] [-t text] Focus element
62
- bctl type <sel> <text> Type text (replaces existing; React-compatible)
62
+ bctl type <sel> <text> Type text (replaces existing; React/Vue compatible)
63
63
  bctl input-text <sel> <text> [--clear] [--delay ms] Char-by-char (rich editors)
64
64
  bctl press <key> Press key: Enter, Escape, Tab, ArrowDown, etc.
65
+ (includes keyCode/which for Vue 2 & jQuery compat)
65
66
  bctl check <sel> [-i N] [-t text] Check checkbox/radio
66
67
  bctl uncheck <sel> [-i N] [-t text] Uncheck checkbox
67
68
  bctl scroll <dir|sel> [n] Scroll: up/down/top/bottom/<selector> [pixels]
@@ -186,6 +187,15 @@ bctl assert-url "/releases/tag/vX.Y.Z" --mode includes
186
187
  Then confirm automation side effects separately (e.g. Actions run appears/completes,
187
188
  and PyPI shows the new version).
188
189
 
190
+ ### Chat / AI UIs
191
+ For chat interfaces (ChatGPT, Gemini, internal AI tools, etc.), use `type` + `press Enter`
192
+ as the simplest send pattern — no need to find the send button:
193
+ ```bash
194
+ bctl type "#input" "your prompt here"
195
+ bctl press Enter
196
+ ```
197
+ Then poll for completion: `bctl wait 5 && bctl count ".response"` in a loop.
198
+
189
199
  ### Waiting Strategy
190
200
  - After navigation: `bctl wait 2-3` or `bctl wait "<selector>" 10`
191
201
  - After hover for overlay: `bctl wait 1`
@@ -289,6 +299,13 @@ Use this sequence on any site:
289
299
  - `screenshot` captures visible viewport only — scroll for full-page capture
290
300
  - Without `-i`, `click` always hits the FIRST match — use `count` to check first
291
301
 
302
+ ## Resilience Notes
303
+
304
+ - **`click` auto force-clicks** when the element is visible and enabled but obscured by
305
+ an overlay or fails hit-test. No need for `eval` workarounds on tricky UIs.
306
+ - **`press` includes `keyCode`/`which`** for all common keys, so `@keydown.enter` handlers
307
+ in Vue 2, jQuery `.on("keydown")`, and legacy `e.keyCode` checks all work correctly.
308
+
292
309
  ## Error Handling
293
310
 
294
311
  - `bctl ping` shows `"extension": false` → user must check Chrome and the extension
@@ -11,6 +11,22 @@ import { contentScriptHandler } from "./content-script.js";
11
11
  // Shared helpers
12
12
  // =========================================================================
13
13
 
14
+ /**
15
+ * Get the ID of the last-focused normal Chrome window.
16
+ * Returns null when Chrome has no normal windows (e.g. background-only on macOS).
17
+ */
18
+ async function getLastFocusedNormalWindowId() {
19
+ try {
20
+ const win = await chrome.windows.getLastFocused({ windowTypes: ["normal"] });
21
+ if (win && win.id !== chrome.windows.WINDOW_ID_NONE) return win.id;
22
+ } catch (_) {}
23
+ try {
24
+ const all = await chrome.windows.getAll({ windowTypes: ["normal"] });
25
+ if (all.length > 0) return all[0].id;
26
+ } catch (_) {}
27
+ return null;
28
+ }
29
+
14
30
  /** Get the currently active tab. */
15
31
  export async function activeTab(context = {}) {
16
32
  if (context.tabId !== undefined && context.tabId !== null) {
@@ -21,10 +37,9 @@ export async function activeTab(context = {}) {
21
37
  if (context.windowId !== undefined && context.windowId !== null) {
22
38
  query.windowId = context.windowId;
23
39
  } else {
24
- // In service workers, currentWindow is unreliable — use lastFocusedWindow
25
- const focusedWindow = await chrome.windows.getLastFocused({ windowTypes: ["normal"] });
26
- if (focusedWindow) {
27
- query.windowId = focusedWindow.id;
40
+ const windowId = await getLastFocusedNormalWindowId();
41
+ if (windowId !== null) {
42
+ query.windowId = windowId;
28
43
  } else {
29
44
  query.currentWindow = true;
30
45
  }
@@ -196,9 +211,26 @@ export async function doBatch(params, context = extractExecutionContext(params))
196
211
  export async function doNavigate(params, context = extractExecutionContext(params)) {
197
212
  const url = params.url;
198
213
  if (!url) throw new Error("Missing 'url' parameter");
199
- const tab = await activeTab(context);
214
+
215
+ let tab;
216
+ try {
217
+ tab = await activeTab(context);
218
+ } catch (_) {
219
+ // No active tab / no window — create a new window with the URL
220
+ const newWindow = await chrome.windows.create({ url });
221
+ const tabs = await chrome.tabs.query({ active: true, windowId: newWindow.id });
222
+ tab = tabs[0];
223
+ if (tab) {
224
+ const loadResult = await waitForTabLoad(tab.id, 5000);
225
+ if (loadResult.loaded) {
226
+ const updated = await getTabOrThrow(tab.id);
227
+ return { url: updated.url, title: updated.title };
228
+ }
229
+ }
230
+ return { url, title: "" };
231
+ }
232
+
200
233
  await chrome.tabs.update(tab.id, { url });
201
- // Short wait — avoid long blocking that causes SW termination
202
234
  const loadResult = await waitForTabLoad(tab.id, 5000);
203
235
  if (!loadResult.loaded) {
204
236
  return { url, title: "" };
@@ -246,7 +278,11 @@ export async function doReload(params = {}, context = extractExecutionContext(pa
246
278
 
247
279
  export async function doTabs() {
248
280
  const tabs = await chrome.tabs.query({});
249
- const focusedWindow = await chrome.windows.getLastFocused();
281
+ let focusedWindowId = null;
282
+ try {
283
+ const focusedWindow = await chrome.windows.getLastFocused();
284
+ focusedWindowId = focusedWindow?.id ?? null;
285
+ } catch (_) {}
250
286
  return {
251
287
  tabs: tabs.map((t) => ({
252
288
  id: t.id,
@@ -255,7 +291,7 @@ export async function doTabs() {
255
291
  active: t.active,
256
292
  windowId: t.windowId,
257
293
  })),
258
- focusedWindowId: focusedWindow.id,
294
+ focusedWindowId,
259
295
  };
260
296
  }
261
297
 
@@ -275,11 +311,38 @@ export async function doSwitchTab(params) {
275
311
  }
276
312
 
277
313
  export async function doNewTab(params) {
278
- const createOpts = { url: params.url || "about:blank" };
279
- if (params.windowId) createOpts.windowId = params.windowId;
280
- const tab = await chrome.tabs.create(createOpts);
314
+ const url = params.url || "about:blank";
315
+ const createOpts = { url };
316
+ if (params.windowId) {
317
+ createOpts.windowId = params.windowId;
318
+ } else {
319
+ const windowId = await getLastFocusedNormalWindowId();
320
+ if (windowId !== null) {
321
+ createOpts.windowId = windowId;
322
+ }
323
+ }
324
+
325
+ let tab;
326
+ try {
327
+ tab = await chrome.tabs.create(createOpts);
328
+ } catch (_) {
329
+ // No window available — create a new window with the tab
330
+ const newWindow = await chrome.windows.create({ url });
331
+ const tabs = await chrome.tabs.query({ active: true, windowId: newWindow.id });
332
+ tab = tabs[0];
333
+ if (!tab) throw new Error("Failed to create new window");
334
+ if (params.url) {
335
+ const loadResult = await waitForTabLoad(tab.id, 5000);
336
+ if (!loadResult.loaded) {
337
+ return { id: tab.id, url: params.url, title: tab.title || "" };
338
+ }
339
+ const updated = await getTabOrThrow(tab.id);
340
+ return { id: updated.id, url: updated.url, title: updated.title };
341
+ }
342
+ return { id: tab.id, url: tab.url, title: tab.title || "" };
343
+ }
344
+
281
345
  if (params.url) {
282
- // Short wait only — avoid long blocking that causes SW termination
283
346
  const loadResult = await waitForTabLoad(tab.id, 5000);
284
347
  if (!loadResult.loaded) {
285
348
  return { id: tab.id, url: params.url, title: tab.title || "" };
@@ -300,7 +363,12 @@ export async function doCloseTab(params, context = extractExecutionContext(param
300
363
  // =========================================================================
301
364
 
302
365
  export async function doStatus(params = {}, context = extractExecutionContext(params)) {
303
- const tab = await activeTab(context);
366
+ let tab;
367
+ try {
368
+ tab = await activeTab(context);
369
+ } catch (_) {
370
+ return { url: null, title: null, id: null, noWindow: true };
371
+ }
304
372
  return { url: tab.url, title: tab.title, id: tab.id };
305
373
  }
306
374
 
@@ -26,8 +26,12 @@ async function doClick(params, context = {}) {
26
26
 
27
27
  const result = results[0]?.result;
28
28
  if (!result || !result.success) {
29
- // If actionability failed, try CDP fallback for fully trusted events
30
- if (result?.cx !== undefined && result?.cy !== undefined && result?.error?.includes("obscured")) {
29
+ // If actionability failed due to hit-test (obscured / no element at coords),
30
+ // try CDP fallback for fully trusted events
31
+ const isHitTestFailure = result?.error?.includes("pointer events") ||
32
+ result?.error?.includes("obscured") ||
33
+ result?.error?.includes("no element at coordinates");
34
+ if (isHitTestFailure && result?.cx !== undefined && result?.cy !== undefined) {
31
35
  try {
32
36
  return await clickViaCDP(tab.id, result.cx, result.cy, params);
33
37
  } catch (_) {
@@ -365,7 +369,51 @@ async function clickHandler(selector, index, textFilter) {
365
369
  return { success: true, total, capturedUrl };
366
370
  }
367
371
 
368
- // All retries exhausted
372
+ // All retries exhausted — if failure is purely hit-test related,
373
+ // force-click anyway (the element is visible, stable, enabled, just obscured)
374
+ const isHitTestOnly = lastReason.includes("pointer events") ||
375
+ lastReason.includes("obscured") ||
376
+ lastReason.includes("no element at coordinates");
377
+
378
+ if (isHitTestOnly) {
379
+ const origOpen = window.open;
380
+ let capturedUrl = null;
381
+ window.open = function (url) {
382
+ if (url && typeof url === "string" && url.startsWith("http")) {
383
+ capturedUrl = url;
384
+ }
385
+ return null;
386
+ };
387
+
388
+ try {
389
+ const rect = el.getBoundingClientRect();
390
+ const cx = rect.left + rect.width / 2;
391
+ const cy = rect.top + rect.height / 2;
392
+ const mOpts = {
393
+ bubbles: true,
394
+ cancelable: true,
395
+ clientX: cx,
396
+ clientY: cy,
397
+ button: 0,
398
+ view: window,
399
+ };
400
+ el.dispatchEvent(
401
+ new PointerEvent("pointerdown", { ...mOpts, pointerId: 1 })
402
+ );
403
+ el.dispatchEvent(new MouseEvent("mousedown", mOpts));
404
+ el.dispatchEvent(
405
+ new PointerEvent("pointerup", { ...mOpts, pointerId: 1 })
406
+ );
407
+ el.dispatchEvent(new MouseEvent("mouseup", mOpts));
408
+ el.click();
409
+ await new Promise((r) => setTimeout(r, 50));
410
+ } finally {
411
+ window.open = origOpen;
412
+ }
413
+
414
+ return { success: true, total, capturedUrl, forced: true };
415
+ }
416
+
369
417
  return {
370
418
  success: false,
371
419
  error: `click: actionability check failed for "${selector}": ${lastReason}`,
@@ -408,7 +408,16 @@ async function contentScriptHandler(commands) {
408
408
  while (target.shadowRoot && target.shadowRoot.activeElement) {
409
409
  target = target.shadowRoot.activeElement;
410
410
  }
411
- const opts = { key, bubbles: true, cancelable: true };
411
+ // Map key names to legacy keyCode values for framework compatibility
412
+ // (Vue 2 @keydown.enter checks e.keyCode === 13, not e.key)
413
+ const KEY_CODES = {
414
+ Enter: 13, Tab: 9, Escape: 27, Backspace: 8, Delete: 46,
415
+ ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39,
416
+ Home: 36, End: 35, PageUp: 33, PageDown: 34,
417
+ " ": 32, Space: 32,
418
+ };
419
+ const keyCode = KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
420
+ const opts = { key, keyCode, which: keyCode, bubbles: true, cancelable: true };
412
421
  const cancelled = !target.dispatchEvent(
413
422
  new KeyboardEvent("keydown", opts)
414
423
  );
@@ -1139,11 +1148,17 @@ async function contentScriptHandler(commands) {
1139
1148
  enabled: true,
1140
1149
  });
1141
1150
  if (!check.pass) {
1142
- results.push({
1143
- success: false,
1144
- error: `${op}: ${check.reason} for "${params.selector}"`,
1145
- });
1146
- break;
1151
+ // If failure is only hit-test related, force-click anyway
1152
+ const isHitTestOnly = check.reason.includes("pointer events") ||
1153
+ check.reason.includes("obscured") ||
1154
+ check.reason.includes("no element at coordinates");
1155
+ if (!isHitTestOnly) {
1156
+ results.push({
1157
+ success: false,
1158
+ error: `${op}: ${check.reason} for "${params.selector}"`,
1159
+ });
1160
+ break;
1161
+ }
1147
1162
  }
1148
1163
  const data = executeOp(op, params);
1149
1164
  results.push({ success: true, data });
@@ -1183,11 +1198,16 @@ async function contentScriptHandler(commands) {
1183
1198
  receivesEvents: true,
1184
1199
  });
1185
1200
  if (!check.pass) {
1186
- results.push({
1187
- success: false,
1188
- error: `${op}: ${check.reason} for "${params.selector || params.source}"`,
1189
- });
1190
- break;
1201
+ const isHitTestOnly = check.reason.includes("pointer events") ||
1202
+ check.reason.includes("obscured") ||
1203
+ check.reason.includes("no element at coordinates");
1204
+ if (!isHitTestOnly) {
1205
+ results.push({
1206
+ success: false,
1207
+ error: `${op}: ${check.reason} for "${params.selector || params.source}"`,
1208
+ });
1209
+ break;
1210
+ }
1191
1211
  }
1192
1212
  const data = executeOp(op, params);
1193
1213
  results.push({ success: true, data });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser-Ctl",
4
- "version": "0.2.11",
4
+ "version": "0.2.13",
5
5
  "description": "Developer tool for CLI-driven browser automation. Control Chrome via command-line — navigate, click, type, query DOM, capture screenshots, and download files, all through a local WebSocket bridge.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browser-ctl
3
- Version: 0.2.12
3
+ Version: 0.2.13
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "browser-ctl"
3
- version = "0.2.12"
3
+ version = "0.2.13"
4
4
  description = "Control your browser from the command line via a Chrome extension + WebSocket bridge"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes