Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
eaa1469143
|
|||
|
f79ff0e3c2
|
|||
|
a8b433aa29
|
|||
|
94c87e244b
|
|||
|
9096efd36a
|
|||
|
98396a7c7e
|
|||
|
30a42ba6d5
|
|||
|
533e9d328d
|
|||
|
9177e989bd
|
|||
|
7fd966014f
|
|||
|
217641d0ef
|
|||
|
0d5c49c19a
|
|||
|
c1a5ef9dd7
|
|||
|
b98c4ae116
|
|||
|
fcd2e8b87b
|
|||
|
b87f536ecd
|
|||
|
a2aa031d71
|
|||
|
8593916e5a
|
|||
|
4b2abbbfc5
|
|||
|
9f03e29807
|
|||
|
e1ff67e259
|
|||
|
a8421e97f5
|
|||
|
753e4c4449
|
|||
|
f1734cd2c1
|
|||
|
22f39a1a77
|
|||
|
a9071abc9a
|
|||
|
edafd349df
|
|||
|
9435dcc716
|
|||
|
bd37c68e80
|
|||
|
ffa76f424a
|
|||
|
647867d05e
|
|||
|
f836844791
|
|||
|
6f7c4fc7ea
|
|||
|
1b0e090466
|
|||
|
d904f4ca63
|
|||
|
7ee664153b
|
|||
|
fb78fd0471
|
|||
|
5ff340a6d3
|
|||
|
2f982fa714
|
|||
|
6785b9f70c
|
|||
|
1bf44c0eef
|
|||
|
cf0c9555d0
|
|||
|
a7da6cfab0
|
|||
|
88b4f5ed11
|
|||
|
36abde501c
|
|||
|
1aff084429
|
|||
|
1c5fd0ffee
|
|||
|
fc4ce8f74d
|
|||
|
cd2ebc2982
|
@@ -14,6 +14,18 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install extension build dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run check:extension
|
||||
|
||||
- name: Read extension version
|
||||
id: version
|
||||
run: |
|
||||
@@ -29,8 +41,11 @@ jobs:
|
||||
|
||||
- name: Build extension archive
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cd extension
|
||||
rm -rf extension-package
|
||||
mkdir -p dist extension-package
|
||||
cp extension/manifest.json extension/background.js extension/content.js extension/icon.svg extension-package/
|
||||
cp -R extension/icons extension-package/icons
|
||||
cd extension-package
|
||||
zip -r "../dist/browser-cli-extension-v${{ steps.version.outputs.version }}.zip" .
|
||||
|
||||
- name: Publish extension release asset
|
||||
|
||||
@@ -21,3 +21,27 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest
|
||||
|
||||
remote-protocol-compat:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser-cli-client-version:
|
||||
- "0.9.3"
|
||||
- "0.9.5"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --group dev --managed-python
|
||||
|
||||
- name: Run remote protocol compatibility matrix
|
||||
env:
|
||||
BROWSER_CLI_COMPAT_CLIENT_VERSION: ${{ matrix.browser-cli-client-version }}
|
||||
run: uv run pytest tests/test_remote_protocol_matrix.py -v
|
||||
|
||||
+9
-2
@@ -1,4 +1,11 @@
|
||||
__pycache__/
|
||||
.vscode/
|
||||
# TypeScript / Node
|
||||
extension/background.js
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
@@ -8,7 +8,7 @@ Control your real, running browser from the terminal or a Python script — no h
|
||||
|
||||
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
|
||||
|
||||
It works by pairing a small Chrome/Brave extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local IPC channel.
|
||||
It works by pairing a small browser extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local IPC channel.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,9 +23,9 @@ terminal / python script
|
||||
│
|
||||
│ Native Messaging Protocol (stdin/stdout, 4-byte length prefix + JSON)
|
||||
▼
|
||||
Chrome Extension (background service worker)
|
||||
Browser Extension (background worker/page)
|
||||
│
|
||||
│ chrome.* APIs
|
||||
│ extension APIs
|
||||
▼
|
||||
Your running browser
|
||||
```
|
||||
@@ -53,7 +53,7 @@ Every response:
|
||||
|
||||
## Installation
|
||||
|
||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, or Vivaldi
|
||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi
|
||||
|
||||
```sh
|
||||
git clone <repo>
|
||||
@@ -63,8 +63,8 @@ uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi
|
||||
```
|
||||
|
||||
The `install` command will:
|
||||
1. Ask you to load the `extension/` folder as an unpacked extension in your browser (`brave://extensions` → Developer mode → Load unpacked)
|
||||
2. Ask you to paste the extension ID shown on the extension card
|
||||
1. Ask you to load the browser-specific extension package
|
||||
2. For Chromium-family browsers, ask you to paste the extension ID shown on the extension card
|
||||
3. Write the native messaging manifest to your OS so the browser can find the host
|
||||
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
|
||||
|
||||
@@ -95,8 +95,9 @@ browser-cli/
|
||||
│ └── session.py # session save/load
|
||||
├── extension/
|
||||
│ ├── manifest.json # MV3 extension manifest
|
||||
│ ├── background.js # Service worker command dispatcher
|
||||
│ └── content.js # Content-script helpers
|
||||
│ ├── content.js # Content-script helpers
|
||||
│ └── src/ # TypeScript source split by command area
|
||||
│ └── index.ts # Builds generated extension/background.js
|
||||
├── examples/
|
||||
│ ├── demo.py # Python API walkthrough
|
||||
│ └── demo.sh # Bash CLI walkthrough
|
||||
@@ -402,9 +403,28 @@ bash examples/demo.sh
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run check:extension # type-check, build extension/background.js, syntax-check bundle
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
On NixOS or hosts without global Node/npm:
|
||||
|
||||
```sh
|
||||
nix-shell # automatically runs npm ci when node_modules is missing/outdated
|
||||
npm run check:extension
|
||||
```
|
||||
|
||||
The extension source lives in `extension/src/`. `extension/background.js` is generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. On NixOS, use `nix-shell` first if npm is not installed globally.
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Chrome internal pages** (`chrome://`, `brave://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
|
||||
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
|
||||
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
|
||||
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
|
||||
- **Linux and macOS only** — Windows native messaging paths are not yet handled.
|
||||
|
||||
+309
-27
@@ -19,7 +19,7 @@ Usage:
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||
from browser_cli.models import Group, Tab
|
||||
|
||||
__all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
|
||||
@@ -33,36 +33,52 @@ class BrowserCounts:
|
||||
|
||||
|
||||
class BrowserCLI:
|
||||
def __init__(self, browser: str | None = None):
|
||||
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
|
||||
"""
|
||||
Args:
|
||||
browser: Profile alias to target. Required when multiple browser
|
||||
instances are active. Equivalent to ``--browser`` on the CLI.
|
||||
remote: Connect to a remote browser exposed via ``browser-cli serve``.
|
||||
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
|
||||
Can be combined with ``browser`` to route to a specific
|
||||
remote profile.
|
||||
key: Path to Ed25519 private key PEM for pubkey auth, or ``"agent"``
|
||||
to use a key from the SSH agent (YubiKey, gpg-agent, etc.).
|
||||
Defaults to ``~/.config/browser-cli/client.key.pem`` if that file exists.
|
||||
"""
|
||||
self._browser = browser
|
||||
self._remote = remote
|
||||
self._key = key if key else None
|
||||
|
||||
def _cmd(self, command: str, args: dict | None = None):
|
||||
return send_command(command, args, profile=self._browser)
|
||||
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
|
||||
|
||||
def _multi_browser_targets(self):
|
||||
if self._browser is not None:
|
||||
return []
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1:
|
||||
if self._remote:
|
||||
targets = remote_browser_targets(self._remote, key=self._key)
|
||||
else:
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
return []
|
||||
return targets
|
||||
|
||||
def _collect_multi_browser(self, command: str, args: dict | None = None):
|
||||
results = []
|
||||
for target in self._multi_browser_targets():
|
||||
targets = self._multi_browser_targets()
|
||||
for target in targets:
|
||||
try:
|
||||
data = send_command(command, args, profile=target.profile)
|
||||
if target.remote:
|
||||
data = send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
|
||||
else:
|
||||
data = send_command(command, args, profile=target.profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
results.append((target, data))
|
||||
if results:
|
||||
return results
|
||||
if self._multi_browser_targets():
|
||||
if targets:
|
||||
raise BrowserNotConnected(
|
||||
"Cannot resolve a browser socket automatically.\n"
|
||||
"Make sure the browser is running with the browser-cli extension enabled,\n"
|
||||
@@ -72,7 +88,14 @@ class BrowserCLI:
|
||||
|
||||
# ── Internal factories ────────────────────────────────────────────────
|
||||
|
||||
def _make_tab(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Tab:
|
||||
def _make_tab(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
) -> Tab:
|
||||
tab = Tab(
|
||||
id=data["id"],
|
||||
window_id=data.get("windowId", 0),
|
||||
@@ -83,10 +106,20 @@ class BrowserCLI:
|
||||
group_id=data.get("groupId") or None,
|
||||
browser=browser_name,
|
||||
)
|
||||
tab._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile)
|
||||
tab._browser = self if browser_profile is None else BrowserCLI(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
)
|
||||
return tab
|
||||
|
||||
def _make_group(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Group:
|
||||
def _make_group(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
) -> Group:
|
||||
group = Group(
|
||||
id=data["id"],
|
||||
title=data.get("title") or "",
|
||||
@@ -95,7 +128,10 @@ class BrowserCLI:
|
||||
tab_count=data.get("tabCount", 0),
|
||||
browser=browser_name,
|
||||
)
|
||||
group._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile)
|
||||
group._browser = self if browser_profile is None else BrowserCLI(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
)
|
||||
return group
|
||||
|
||||
# ── Navigation ────────────────────────────────────────────────────────
|
||||
@@ -122,6 +158,47 @@ class BrowserCLI:
|
||||
"""Navigate a specific tab to *url*."""
|
||||
self._cmd("navigate.to", {"tabId": tab_id, "url": url})
|
||||
|
||||
def open_wait(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
background: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> "Tab":
|
||||
"""Open URL in a new tab and block until fully loaded. Returns the Tab."""
|
||||
data = self._cmd("navigate.open_wait", {
|
||||
"url": url, "timeout": int(timeout * 1000),
|
||||
"background": background, "window": window, "group": group,
|
||||
})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("navigate.open_wait returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
def wait_for_load(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
ready_state: str = "complete",
|
||||
) -> Tab:
|
||||
"""Block until the tab finishes loading. Returns the Tab when ready.
|
||||
|
||||
Args:
|
||||
tab_id: Tab to watch. Defaults to the active tab.
|
||||
timeout: Max seconds to wait before raising ``RuntimeError``.
|
||||
ready_state: ``"complete"`` (default) or ``"interactive"``.
|
||||
"""
|
||||
data = self._cmd("navigate.wait", {
|
||||
"tabId": tab_id,
|
||||
"timeout": int(timeout * 1000),
|
||||
"readyState": ready_state,
|
||||
})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("navigate.wait returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────────
|
||||
|
||||
def search(
|
||||
@@ -148,7 +225,12 @@ class BrowserCLI:
|
||||
multi_results = self._collect_multi_browser("tabs.list", {})
|
||||
if multi_results:
|
||||
return [
|
||||
self._make_tab(tab, browser_profile=target.profile, browser_name=target.display_name)
|
||||
self._make_tab(
|
||||
tab,
|
||||
browser_profile=target.profile,
|
||||
browser_name=target.display_name,
|
||||
browser_remote=target.remote,
|
||||
)
|
||||
for target, tabs in multi_results
|
||||
for tab in (tabs or [])
|
||||
]
|
||||
@@ -190,6 +272,46 @@ class BrowserCLI:
|
||||
result = self._cmd("tabs.unmute", {"tabId": tab_id})
|
||||
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
|
||||
|
||||
def tabs_pin(self, tab_id: int | None = None) -> int:
|
||||
"""Pin the active tab or a specific tab. Returns the target tab ID."""
|
||||
result = self._cmd("tabs.pin", {"tabId": tab_id})
|
||||
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
|
||||
|
||||
def tabs_unpin(self, tab_id: int | None = None) -> int:
|
||||
"""Unpin the active tab or a specific tab. Returns the target tab ID."""
|
||||
result = self._cmd("tabs.unpin", {"tabId": tab_id})
|
||||
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
|
||||
|
||||
def tabs_watch_url(
|
||||
self,
|
||||
pattern: str,
|
||||
*,
|
||||
tab_id: int | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> "Tab":
|
||||
"""Block until the tab URL matches regex pattern. Returns the Tab."""
|
||||
data = self._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("tabs.watch_url returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
def tabs_screenshot(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
format: str = "png",
|
||||
quality: int | None = None,
|
||||
) -> str:
|
||||
"""Capture the visible area of a tab. Returns a base64 data URL.
|
||||
|
||||
Args:
|
||||
tab_id: Tab to capture. Defaults to the active tab.
|
||||
format: ``"png"`` (default) or ``"jpeg"``.
|
||||
quality: JPEG quality 0-100 (ignored for PNG).
|
||||
"""
|
||||
result = self._cmd("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
|
||||
return result.get("dataUrl", "") if isinstance(result, dict) else str(result)
|
||||
|
||||
def window_active_tab(self, window_id: int) -> Tab:
|
||||
"""Return active tab for a specific browser window."""
|
||||
data = self._cmd("tabs.active_in_window", {"windowId": window_id})
|
||||
@@ -257,7 +379,12 @@ class BrowserCLI:
|
||||
multi_results = self._collect_multi_browser("group.list", {})
|
||||
if multi_results:
|
||||
return [
|
||||
self._make_group(group, browser_profile=target.profile, browser_name=target.display_name)
|
||||
self._make_group(
|
||||
group,
|
||||
browser_profile=target.profile,
|
||||
browser_name=target.display_name,
|
||||
browser_remote=target.remote,
|
||||
)
|
||||
for target, groups in multi_results
|
||||
for group in (groups or [])
|
||||
]
|
||||
@@ -314,7 +441,7 @@ class BrowserCLI:
|
||||
for target, windows in multi_results
|
||||
for window in (windows or [])
|
||||
]
|
||||
return self._cmd("windows.list", {})
|
||||
return self._cmd("windows.list", {}) or []
|
||||
|
||||
def windows_rename(self, window_id: int, name: str) -> None:
|
||||
self._cmd("windows.rename", {"windowId": window_id, "name": name})
|
||||
@@ -324,12 +451,12 @@ class BrowserCLI:
|
||||
|
||||
def windows_open(self, url: str | None = None) -> dict:
|
||||
"""Open a new browser window, optionally on a URL."""
|
||||
return self._cmd("windows.open", {"url": url})
|
||||
return self._cmd("windows.open", {"url": url}) or {}
|
||||
|
||||
# ── DOM ───────────────────────────────────────────────────────────────
|
||||
|
||||
def dom_query(self, selector: str) -> list[dict]:
|
||||
return self._cmd("dom.query", {"selector": selector})
|
||||
return self._cmd("dom.query", {"selector": selector}) or []
|
||||
|
||||
def dom_click(self, selector: str) -> None:
|
||||
self._cmd("dom.click", {"selector": selector})
|
||||
@@ -338,24 +465,179 @@ class BrowserCLI:
|
||||
self._cmd("dom.type", {"selector": selector, "text": text})
|
||||
|
||||
def dom_attr(self, selector: str, attr: str) -> list[str]:
|
||||
return self._cmd("dom.attr", {"selector": selector, "attr": attr})
|
||||
return self._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
|
||||
|
||||
def dom_text(self, selector: str) -> list[str]:
|
||||
return self._cmd("dom.text", {"selector": selector})
|
||||
return self._cmd("dom.text", {"selector": selector}) or []
|
||||
|
||||
def dom_exists(self, selector: str) -> bool:
|
||||
return self._cmd("dom.exists", {"selector": selector})
|
||||
return self._cmd("dom.exists", {"selector": selector}) or False
|
||||
|
||||
def dom_scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
|
||||
"""Scroll to a CSS selector or to pixel coordinates."""
|
||||
self._cmd("dom.scroll", {"selector": selector, "x": x, "y": y})
|
||||
|
||||
def dom_select(self, selector: str, value: str) -> None:
|
||||
"""Set the value of a <select> element."""
|
||||
self._cmd("dom.select", {"selector": selector, "value": value})
|
||||
|
||||
def dom_eval(self, code: str, tab_id: int | None = None):
|
||||
"""Evaluate JavaScript in the page's main world and return the result."""
|
||||
return self._cmd("dom.eval", {"code": code, "tabId": tab_id})
|
||||
|
||||
def dom_key(self, key: str, selector: str | None = None) -> None:
|
||||
"""Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
|
||||
self._cmd("dom.key", {"key": key, "selector": selector})
|
||||
|
||||
def dom_hover(self, selector: str) -> None:
|
||||
"""Dispatch mouseover/mouseenter on an element."""
|
||||
self._cmd("dom.hover", {"selector": selector})
|
||||
|
||||
def dom_check(self, selector: str) -> None:
|
||||
"""Check a checkbox."""
|
||||
self._cmd("dom.check", {"selector": selector})
|
||||
|
||||
def dom_uncheck(self, selector: str) -> None:
|
||||
"""Uncheck a checkbox."""
|
||||
self._cmd("dom.uncheck", {"selector": selector})
|
||||
|
||||
def dom_clear(self, selector: str) -> None:
|
||||
"""Clear the value of an input element."""
|
||||
self._cmd("dom.clear", {"selector": selector})
|
||||
|
||||
def dom_focus(self, selector: str) -> None:
|
||||
"""Focus an element."""
|
||||
self._cmd("dom.focus", {"selector": selector})
|
||||
|
||||
def dom_submit(self, selector: str) -> None:
|
||||
"""Submit the form containing the matched element."""
|
||||
self._cmd("dom.submit", {"selector": selector})
|
||||
|
||||
def dom_poll(
|
||||
self,
|
||||
selector: str,
|
||||
pattern: str,
|
||||
*,
|
||||
attr: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
interval: float = 0.5,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Poll selector's text/value until it matches regex pattern.
|
||||
|
||||
Returns ``{"selector": ..., "value": ..., "pattern": ...}`` when matched.
|
||||
"""
|
||||
return self._cmd("dom.poll", {
|
||||
"selector": selector,
|
||||
"pattern": pattern,
|
||||
"attr": attr,
|
||||
"timeout": int(timeout * 1000),
|
||||
"interval": int(interval * 1000),
|
||||
"tabId": tab_id,
|
||||
})
|
||||
|
||||
def dom_wait_for(
|
||||
self,
|
||||
selector: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
visible: bool = False,
|
||||
hidden: bool = False,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Wait until a CSS selector appears (or disappears) in the DOM.
|
||||
|
||||
Args:
|
||||
selector: CSS selector to watch.
|
||||
timeout: Max seconds to wait before raising ``RuntimeError``.
|
||||
visible: Wait until the element has non-zero dimensions.
|
||||
hidden: Wait until the element is absent or has ``offsetParent == null``.
|
||||
tab_id: Tab to watch. Defaults to the active tab.
|
||||
"""
|
||||
return self._cmd("dom.wait_for", {
|
||||
"selector": selector,
|
||||
"timeout": int(timeout * 1000),
|
||||
"visible": visible,
|
||||
"hidden": hidden,
|
||||
"tabId": tab_id,
|
||||
})
|
||||
|
||||
# ── Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
def page_info(self) -> dict:
|
||||
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
|
||||
return self._cmd("page.info", {}) or {}
|
||||
|
||||
# ── Storage ───────────────────────────────────────────────────────────
|
||||
|
||||
def storage_get(
|
||||
self,
|
||||
key: str | None = None,
|
||||
*,
|
||||
type: str = "local",
|
||||
tab_id: int | None = None,
|
||||
) -> str | dict | None:
|
||||
"""Get a localStorage/sessionStorage entry (or all entries if key omitted)."""
|
||||
return self._cmd("storage.get", {"key": key, "type": type, "tabId": tab_id})
|
||||
|
||||
def storage_set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
*,
|
||||
type: str = "local",
|
||||
tab_id: int | None = None,
|
||||
) -> None:
|
||||
"""Set a localStorage/sessionStorage entry."""
|
||||
self._cmd("storage.set", {"key": key, "value": value, "type": type, "tabId": tab_id})
|
||||
|
||||
# ── Cookies ───────────────────────────────────────────────────────────
|
||||
|
||||
def cookies_list(
|
||||
self,
|
||||
*,
|
||||
url: str | None = None,
|
||||
domain: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""List cookies, optionally filtered by url, domain, or name."""
|
||||
return self._cmd("cookies.list", {"url": url, "domain": domain, "name": name}) or []
|
||||
|
||||
def cookies_get(self, url: str, name: str) -> dict | None:
|
||||
"""Get a single cookie by url and name."""
|
||||
return self._cmd("cookies.get", {"url": url, "name": name})
|
||||
|
||||
def cookies_set(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
value: str,
|
||||
*,
|
||||
domain: str | None = None,
|
||||
path: str | None = None,
|
||||
secure: bool | None = None,
|
||||
http_only: bool | None = None,
|
||||
expiration_date: float | None = None,
|
||||
same_site: str | None = None,
|
||||
) -> dict:
|
||||
"""Set a cookie. Returns the created cookie dict."""
|
||||
return self._cmd("cookies.set", {
|
||||
"url": url, "name": name, "value": value,
|
||||
"domain": domain, "path": path,
|
||||
"secure": secure, "httpOnly": http_only,
|
||||
"expirationDate": expiration_date, "sameSite": same_site,
|
||||
})
|
||||
|
||||
# ── Extract ───────────────────────────────────────────────────────────
|
||||
|
||||
def extract_links(self) -> list[dict]:
|
||||
return self._cmd("extract.links", {})
|
||||
return self._cmd("extract.links", {}) or []
|
||||
|
||||
def extract_images(self) -> list[dict]:
|
||||
return self._cmd("extract.images", {})
|
||||
return self._cmd("extract.images", {}) or []
|
||||
|
||||
def extract_text(self) -> str:
|
||||
return self._cmd("extract.text", {})
|
||||
return self._cmd("extract.text", {}) or ""
|
||||
|
||||
def extract_json(self, selector: str):
|
||||
return self._cmd("extract.json", {"selector": selector})
|
||||
@@ -372,7 +654,7 @@ class BrowserCLI:
|
||||
self._cmd("session.load", {"name": name})
|
||||
|
||||
def session_diff(self, name_a: str, name_b: str) -> dict:
|
||||
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b})
|
||||
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
|
||||
|
||||
def session_list(self) -> list[dict]:
|
||||
"""Return saved sessions.
|
||||
@@ -386,7 +668,7 @@ class BrowserCLI:
|
||||
for target, sessions in multi_results
|
||||
for session in (sessions or [])
|
||||
]
|
||||
return self._cmd("session.list", {})
|
||||
return self._cmd("session.list", {}) or []
|
||||
|
||||
def session_remove(self, name: str) -> None:
|
||||
self._cmd("session.remove", {"name": name})
|
||||
@@ -404,8 +686,8 @@ class BrowserCLI:
|
||||
|
||||
try:
|
||||
transformed = filter_fn(tabs)
|
||||
except Exception:
|
||||
transformed = None
|
||||
except (AttributeError, TypeError):
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
|
||||
if isinstance(transformed, list):
|
||||
return transformed
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Ed25519 keypair management, ML-KEM key exchange, and auth helpers."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
load_pem_private_key,
|
||||
)
|
||||
|
||||
_CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli"
|
||||
DEFAULT_KEY_PATH = _CONFIG_DIR / "client.key.pem"
|
||||
DEFAULT_AUTHORIZED_KEYS_PATH = _CONFIG_DIR / "authorized_keys"
|
||||
|
||||
# ── SSH agent protocol constants ───────────────────────────────────────────────
|
||||
_SSH_AGENTC_REQUEST_IDENTITIES = 11
|
||||
_SSH_AGENT_IDENTITIES_ANSWER = 12
|
||||
_SSH_AGENTC_SIGN_REQUEST = 13
|
||||
_SSH_AGENT_SIGN_RESPONSE = 14
|
||||
|
||||
|
||||
def _pack_str(s: bytes) -> bytes:
|
||||
return struct.pack(">I", len(s)) + s
|
||||
|
||||
|
||||
def _unpack_str(data: bytes, off: int) -> tuple[bytes, int]:
|
||||
n = struct.unpack_from(">I", data, off)[0]
|
||||
return data[off + 4 : off + 4 + n], off + 4 + n
|
||||
|
||||
|
||||
def _agent_roundtrip(msg: bytes) -> bytes:
|
||||
sock_path = os.environ.get("SSH_AUTH_SOCK")
|
||||
if not sock_path:
|
||||
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(10)
|
||||
sock.connect(sock_path)
|
||||
sock.sendall(struct.pack(">I", len(msg)) + msg)
|
||||
raw_len = b""
|
||||
while len(raw_len) < 4:
|
||||
chunk = sock.recv(4 - len(raw_len))
|
||||
if not chunk:
|
||||
raise RuntimeError("SSH agent closed connection")
|
||||
raw_len += chunk
|
||||
n = struct.unpack(">I", raw_len)[0]
|
||||
resp = b""
|
||||
while len(resp) < n:
|
||||
chunk = sock.recv(n - len(resp))
|
||||
if not chunk:
|
||||
raise RuntimeError("SSH agent closed connection mid-response")
|
||||
resp += chunk
|
||||
return resp
|
||||
|
||||
|
||||
# ── AgentKey ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class AgentKey:
|
||||
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
|
||||
blob: bytes
|
||||
comment: str
|
||||
|
||||
@property
|
||||
def pubkey_bytes(self) -> bytes:
|
||||
_algo, off = _unpack_str(self.blob, 0)
|
||||
key_bytes, _ = _unpack_str(self.blob, off)
|
||||
return key_bytes
|
||||
|
||||
|
||||
# ── Agent helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def agent_list_keys() -> list[AgentKey]:
|
||||
"""Return all Ed25519 keys currently held by the SSH agent."""
|
||||
resp = _agent_roundtrip(bytes([_SSH_AGENTC_REQUEST_IDENTITIES]))
|
||||
if resp[0] != _SSH_AGENT_IDENTITIES_ANSWER:
|
||||
raise RuntimeError(f"Unexpected agent response: {resp[0]}")
|
||||
n_keys = struct.unpack_from(">I", resp, 1)[0]
|
||||
keys: list[AgentKey] = []
|
||||
off = 5
|
||||
for _ in range(n_keys):
|
||||
blob, off = _unpack_str(resp, off)
|
||||
comment, off = _unpack_str(resp, off)
|
||||
algo, _ = _unpack_str(blob, 0)
|
||||
if algo == b"ssh-ed25519":
|
||||
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
|
||||
return keys
|
||||
|
||||
|
||||
def agent_find_key(selector: str | None = None) -> AgentKey | None:
|
||||
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
|
||||
try:
|
||||
keys = agent_list_keys()
|
||||
except Exception:
|
||||
return None
|
||||
for key in keys:
|
||||
if key.comment == "(none)":
|
||||
continue
|
||||
if selector is None or selector in key.comment:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
|
||||
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
|
||||
msg = (
|
||||
bytes([_SSH_AGENTC_SIGN_REQUEST])
|
||||
+ _pack_str(key.blob)
|
||||
+ _pack_str(data)
|
||||
+ struct.pack(">I", 0)
|
||||
)
|
||||
resp = _agent_roundtrip(msg)
|
||||
if resp[0] != _SSH_AGENT_SIGN_RESPONSE:
|
||||
raise RuntimeError(f"SSH agent refused to sign (response code {resp[0]})")
|
||||
sig_blob, _ = _unpack_str(resp, 1)
|
||||
_algo, soff = _unpack_str(sig_blob, 0)
|
||||
raw_sig, _ = _unpack_str(sig_blob, soff)
|
||||
if len(raw_sig) != 64:
|
||||
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
|
||||
return raw_sig
|
||||
|
||||
|
||||
# ── File-based key helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def generate_keypair() -> tuple[bytes, str]:
|
||||
"""Return (private_key_pem_bytes, public_key_hex)."""
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
pem = priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
||||
pub_hex = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||
return pem, pub_hex
|
||||
|
||||
|
||||
def load_private_key(path: Path) -> Ed25519PrivateKey:
|
||||
return load_pem_private_key(path.read_bytes(), password=None)
|
||||
|
||||
|
||||
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
|
||||
if isinstance(key, AgentKey):
|
||||
return key.pubkey_bytes.hex()
|
||||
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||
|
||||
|
||||
# ── Canonical payload + sign/verify ───────────────────────────────────────────
|
||||
|
||||
def canonical_payload(msg: dict) -> bytes:
|
||||
"""Deterministic JSON encoding of msg without auth protocol fields."""
|
||||
return json.dumps(
|
||||
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
||||
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
|
||||
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
||||
if pq_shared_secret is not None:
|
||||
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
|
||||
return data
|
||||
|
||||
|
||||
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
||||
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
|
||||
data = _auth_message(nonce, msg, pq_shared_secret)
|
||||
if isinstance(key, AgentKey):
|
||||
return agent_sign_raw(key, data)
|
||||
return key.sign(data)
|
||||
|
||||
|
||||
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
|
||||
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
|
||||
try:
|
||||
pub_bytes = bytes.fromhex(pub_hex)
|
||||
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
|
||||
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
|
||||
return True
|
||||
except (InvalidSignature, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
|
||||
|
||||
PQ_KEX_ALG = "ML-KEM-768"
|
||||
PQ_TRANSPORT_ALG = "ML-KEM-768+ChaCha20Poly1305"
|
||||
|
||||
|
||||
def pq_kex_server_keypair():
|
||||
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
|
||||
|
||||
Returns ``None`` when the installed cryptography/OpenSSL backend does not
|
||||
support ML-KEM yet. The serve/client protocol treats this as graceful
|
||||
downgrade instead of breaking local installs on older OpenSSL builds.
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric import mlkem
|
||||
priv = mlkem.MLKEM768PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes_raw()
|
||||
return priv, pub
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
|
||||
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
|
||||
from cryptography.hazmat.primitives.asymmetric import mlkem
|
||||
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
|
||||
shared_secret, ciphertext = pub.encapsulate()
|
||||
return ciphertext.hex(), shared_secret
|
||||
|
||||
|
||||
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
|
||||
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
|
||||
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
|
||||
|
||||
|
||||
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=None,
|
||||
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
|
||||
).derive(shared_secret)
|
||||
|
||||
|
||||
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
|
||||
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
|
||||
nonce = secrets.token_bytes(12)
|
||||
key = _pq_transport_key(shared_secret, direction)
|
||||
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
|
||||
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
|
||||
|
||||
|
||||
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
|
||||
"""Decrypt an app-layer frame produced by pq_encrypt()."""
|
||||
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
|
||||
raise ValueError("unsupported encrypted transport envelope")
|
||||
key = _pq_transport_key(shared_secret, direction)
|
||||
return ChaCha20Poly1305(key).decrypt(
|
||||
bytes.fromhex(str(envelope["nonce"])),
|
||||
bytes.fromhex(str(envelope["ciphertext"])),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def new_nonce() -> str:
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
|
||||
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
|
||||
if not path.exists():
|
||||
return []
|
||||
result = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
pubkey = parts[0]
|
||||
name = parts[1].strip() if len(parts) > 1 else ""
|
||||
result.append((pubkey, name))
|
||||
return result
|
||||
|
||||
|
||||
def load_authorized_keys(path: Path) -> list[str]:
|
||||
return [pk for pk, _ in load_authorized_keys_with_names(path)]
|
||||
|
||||
|
||||
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
|
||||
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
|
||||
if pub_hex in existing:
|
||||
return False
|
||||
line = (f"{pub_hex} {name}".rstrip()) + "\n"
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
return True
|
||||
+279
-59
@@ -6,7 +6,6 @@ import click
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import stat
|
||||
import shutil
|
||||
import re
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
@@ -21,18 +20,40 @@ from browser_cli.commands.dom import dom_group
|
||||
from browser_cli.commands.extract import extract_group
|
||||
from browser_cli.commands.session import session_group
|
||||
from browser_cli.commands.search import search_group
|
||||
from browser_cli.commands.page import page_group
|
||||
from browser_cli.commands.storage import storage_group
|
||||
from browser_cli.commands.cookies import cookies_group
|
||||
from browser_cli.commands.serve import cmd_serve
|
||||
from browser_cli.client import (
|
||||
send_command,
|
||||
BrowserNotConnected,
|
||||
REGISTRY_PATH,
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
remote_target_for_alias,
|
||||
remote_browser_targets,
|
||||
)
|
||||
from browser_cli.platform import install_base_dir, is_windows
|
||||
from browser_cli.registry import load_registry
|
||||
|
||||
console = Console()
|
||||
|
||||
# Click's Group.shell_complete hardcodes no limit for get_short_help_str (defaults to 45 chars);
|
||||
# patch to use a wider limit so zsh completion descriptions aren't truncated.
|
||||
def _patched_group_shell_complete(self, ctx, incomplete):
|
||||
from click.shell_completion import CompletionItem
|
||||
results = [
|
||||
CompletionItem(name, help=command.get_short_help_str(limit=shutil.get_terminal_size().columns))
|
||||
for name, command in self.commands.items()
|
||||
if not command.hidden and name.startswith(incomplete)
|
||||
]
|
||||
results.extend(click.Command.shell_complete(self, ctx, incomplete))
|
||||
return results
|
||||
|
||||
click.Group.shell_complete = _patched_group_shell_complete
|
||||
|
||||
NATIVE_HOST_NAME = "com.browsercli.host"
|
||||
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
|
||||
|
||||
NATIVE_HOST_DIRS = {
|
||||
"chrome": {
|
||||
@@ -79,26 +100,31 @@ def _rename_target_profile(target_browser: str | None) -> str | None:
|
||||
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
|
||||
target_profile = _rename_target_profile(target_browser)
|
||||
|
||||
profiles: dict[str, str] = {}
|
||||
if REGISTRY_PATH.exists():
|
||||
try:
|
||||
profiles = json.loads(REGISTRY_PATH.read_text())
|
||||
except Exception:
|
||||
profiles = {}
|
||||
profiles: dict[str, str] = load_registry(REGISTRY_PATH)
|
||||
|
||||
if alias in profiles and alias != target_profile:
|
||||
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
||||
|
||||
|
||||
def _native_host_wrapper_path() -> Path:
|
||||
base_dir = install_base_dir()
|
||||
def _native_host_exe() -> Path:
|
||||
base = install_base_dir()
|
||||
if is_windows():
|
||||
return base_dir / "libexec" / "native-host.cmd"
|
||||
return base_dir / "libexec" / "native-host"
|
||||
return base / "libexec" / "browser-cli-native-host.cmd"
|
||||
return base / "libexec" / "browser-cli-native-host"
|
||||
|
||||
|
||||
def _native_host_script_path() -> Path:
|
||||
return _native_host_wrapper_path().with_name("native_host.py")
|
||||
def _write_native_host_exe(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if is_windows():
|
||||
path.write_text(
|
||||
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native_host import main; main()" %*\r\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
path.write_text(
|
||||
f'#!{sys.executable}\nfrom browser_cli.native_host import main\nmain()\n'
|
||||
)
|
||||
path.chmod(path.stat().st_mode | 0o111)
|
||||
|
||||
|
||||
def _windows_registry_views():
|
||||
@@ -160,16 +186,183 @@ def _print_version(ctx, param, value):
|
||||
"--browser", default=None, metavar="ALIAS",
|
||||
help="Browser profile alias to target (required when multiple browsers are active).",
|
||||
)
|
||||
@click.option(
|
||||
"--remote", default=None, metavar="HOST[:PORT]",
|
||||
help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.",
|
||||
)
|
||||
@click.option(
|
||||
"--key", default=None, metavar="PATH",
|
||||
help="Ed25519 private key PEM for pubkey auth with a remote serve instance.",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(ctx, browser):
|
||||
def main(ctx, browser, remote, key):
|
||||
"""Control your running browser from the terminal via a Chrome extension."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["browser"] = browser
|
||||
ctx.obj["browser_explicit"] = browser is not None
|
||||
if browser:
|
||||
os.environ["BROWSER_CLI_PROFILE"] = browser
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
|
||||
ctx.obj["remote"] = remote
|
||||
ctx.obj["key"] = key
|
||||
if remote:
|
||||
os.environ["BROWSER_CLI_REMOTE"] = remote
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_REMOTE", None))
|
||||
if key:
|
||||
os.environ["BROWSER_CLI_KEY"] = key
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
|
||||
|
||||
|
||||
# ── auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@click.group("auth")
|
||||
def auth_group():
|
||||
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
|
||||
|
||||
|
||||
@auth_group.command("keygen")
|
||||
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
|
||||
@click.option("--force", is_flag=True, help="Overwrite existing key.")
|
||||
def cmd_auth_keygen(output, force):
|
||||
"""Generate an Ed25519 keypair for pubkey auth."""
|
||||
from browser_cli.auth import DEFAULT_KEY_PATH, generate_keypair
|
||||
|
||||
key_path = Path(output) if output else DEFAULT_KEY_PATH
|
||||
if key_path.exists() and not force:
|
||||
console.print(f"[red]Key already exists:[/red] {key_path} (use --force to overwrite)")
|
||||
sys.exit(1)
|
||||
pem, pub_hex = generate_keypair()
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(pem)
|
||||
console.print(f"[green]✓[/green] Private key: {key_path}")
|
||||
console.print(f"\nPublic key:\n [bold cyan]{pub_hex}[/bold cyan]")
|
||||
console.print(f"\nOn the serve host, trust this key:")
|
||||
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
|
||||
|
||||
|
||||
@auth_group.command("trust")
|
||||
@click.argument("pubkey")
|
||||
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@click.pass_context
|
||||
def cmd_auth_trust(ctx, pubkey, name, keys_file):
|
||||
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
|
||||
|
||||
if len(pubkey) != 64:
|
||||
console.print("[red]Invalid public key:[/red] expected 64 hex characters (Ed25519 raw public key)")
|
||||
sys.exit(1)
|
||||
try:
|
||||
bytes.fromhex(pubkey)
|
||||
except ValueError:
|
||||
console.print("[red]Invalid public key:[/red] not valid hex")
|
||||
sys.exit(1)
|
||||
|
||||
remote = (ctx.obj or {}).get("remote")
|
||||
if remote:
|
||||
from browser_cli.client import send_command
|
||||
result = send_command(
|
||||
"browser-cli.auth.trust",
|
||||
args={"pubkey": pubkey, "name": name},
|
||||
remote=remote,
|
||||
key=(ctx.obj or {}).get("key"),
|
||||
)
|
||||
added = (result or {}).get("added", False)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
|
||||
return
|
||||
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
added = add_authorized_key(path, pubkey, name)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f" File: {path}")
|
||||
console.print(f"\nStart the server with:")
|
||||
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
|
||||
|
||||
|
||||
@auth_group.command("show")
|
||||
@click.option("--key", "key_src", default=None, metavar="PATH|agent[:<selector>]",
|
||||
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.")
|
||||
def cmd_auth_show(key_src):
|
||||
"""Print the Ed25519 public key that browser-cli will use for auth."""
|
||||
from browser_cli.auth import DEFAULT_KEY_PATH, agent_find_key, load_private_key, public_key_hex
|
||||
|
||||
src = key_src or os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
|
||||
|
||||
if src == "agent" or src.startswith("agent:"):
|
||||
selector = src[6:] or None
|
||||
key = agent_find_key(selector)
|
||||
if key is None:
|
||||
console.print("[red]No Ed25519 key found in SSH agent.[/red]")
|
||||
console.print(" Make sure gpg-agent / ssh-agent is running and the key is loaded.")
|
||||
sys.exit(1)
|
||||
console.print(f"[dim]source:[/dim] agent ({key.comment})")
|
||||
console.print(public_key_hex(key))
|
||||
return
|
||||
|
||||
path = Path(src)
|
||||
if not path.exists():
|
||||
console.print(f"[red]No key found at {path}[/red]")
|
||||
console.print(" Run: [dim]browser-cli auth keygen[/dim]")
|
||||
console.print(" Or use: [dim]browser-cli auth show --key agent[/dim]")
|
||||
sys.exit(1)
|
||||
try:
|
||||
priv = load_private_key(path)
|
||||
console.print(f"[dim]source:[/dim] {path}")
|
||||
console.print(public_key_hex(priv))
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to load key:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@auth_group.command("keys")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@click.pass_context
|
||||
def cmd_auth_keys(ctx, keys_file):
|
||||
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
|
||||
from rich.table import Table
|
||||
|
||||
remote = (ctx.obj or {}).get("remote")
|
||||
if remote:
|
||||
from browser_cli.client import send_command
|
||||
result = send_command(
|
||||
"browser-cli.auth.keys",
|
||||
remote=remote,
|
||||
key=(ctx.obj or {}).get("key"),
|
||||
)
|
||||
entries = result or []
|
||||
source_label = remote
|
||||
else:
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
|
||||
source_label = str(path)
|
||||
|
||||
if not entries:
|
||||
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
|
||||
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
|
||||
return
|
||||
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Public Key")
|
||||
for entry in entries:
|
||||
name = entry.get("name") or "[dim]—[/dim]"
|
||||
table.add_row(name, entry.get("pubkey", ""))
|
||||
console.print(table)
|
||||
|
||||
|
||||
main.add_command(auth_group)
|
||||
|
||||
# ── Sub-command groups ─────────────────────────────────────────────────────────
|
||||
main.add_command(nav_group)
|
||||
main.add_command(tabs_group)
|
||||
@@ -179,6 +372,10 @@ main.add_command(dom_group)
|
||||
main.add_command(extract_group)
|
||||
main.add_command(session_group)
|
||||
main.add_command(search_group)
|
||||
main.add_command(page_group)
|
||||
main.add_command(storage_group)
|
||||
main.add_command(cookies_group)
|
||||
main.add_command(cmd_serve)
|
||||
|
||||
|
||||
# ── clients ────────────────────────────────────────────────────────────────────
|
||||
@@ -190,29 +387,70 @@ def clients_group(ctx):
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
profiles: dict[str, str] = {}
|
||||
if REGISTRY_PATH.exists():
|
||||
try:
|
||||
profiles = json.loads(REGISTRY_PATH.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
all_clients = []
|
||||
for profile_name, sock_path in profiles.items():
|
||||
display_profile = display_browser_name(profile_name, sock_path)
|
||||
|
||||
browser_alias = (ctx.obj or {}).get("browser")
|
||||
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
key = (ctx.obj or {}).get("key")
|
||||
|
||||
if not remote and browser_alias:
|
||||
# --browser <host> without --remote: resolve host alias to a remote endpoint,
|
||||
# then show ALL clients from that remote (not just the one resolved profile).
|
||||
resolved = remote_target_for_alias(browser_alias)
|
||||
if resolved:
|
||||
try:
|
||||
targets = remote_browser_targets(resolved.remote)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
for target in targets:
|
||||
try:
|
||||
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = target.display_name
|
||||
all_clients.append(c)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
elif remote:
|
||||
try:
|
||||
result = send_command("clients.list", profile=profile_name)
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = display_profile
|
||||
c["profile"] = c.get("profile") or browser_alias or "remote"
|
||||
all_clients.append(c)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
# Socket registered but browser no longer connected
|
||||
all_clients.append({
|
||||
"profile": display_profile,
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
})
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
profiles: dict[str, str] = {}
|
||||
if REGISTRY_PATH.exists():
|
||||
profiles = load_registry(REGISTRY_PATH)
|
||||
|
||||
for profile_name, sock_path in profiles.items():
|
||||
display_profile = display_browser_name(profile_name, sock_path)
|
||||
try:
|
||||
result = send_command("clients.list", profile=profile_name)
|
||||
for c in (result or []):
|
||||
c["profile"] = display_profile
|
||||
all_clients.append(c)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
# Socket registered but browser no longer connected
|
||||
all_clients.append({
|
||||
"profile": display_profile,
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
})
|
||||
|
||||
for target in active_browser_targets():
|
||||
if target.remote is None:
|
||||
continue
|
||||
try:
|
||||
result = send_command("clients.list", profile=target.profile, remote=target.remote)
|
||||
for c in (result or []):
|
||||
c["profile"] = target.display_name
|
||||
all_clients.append(c)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
if not all_clients:
|
||||
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
|
||||
@@ -261,24 +499,10 @@ def cmd_clients_rename(target_browser, alias):
|
||||
def cmd_install(browser):
|
||||
"""Register the native messaging host and print extension load instructions."""
|
||||
|
||||
# Install wrapper outside PATH — the browser uses the absolute path from the
|
||||
# native messaging manifest, so only `browser-cli` needs to be on PATH.
|
||||
wrapper_path = _native_host_wrapper_path()
|
||||
native_host_script_path = _native_host_script_path()
|
||||
wrapper_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path)
|
||||
if not is_windows():
|
||||
native_host_script_path.chmod(
|
||||
native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
)
|
||||
wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n'
|
||||
wrapper_path.write_text(wrapper_content)
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
else:
|
||||
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
|
||||
wrapper_path.write_text(wrapper_content, encoding="utf-8")
|
||||
host_exe = _native_host_exe()
|
||||
_write_native_host_exe(host_exe)
|
||||
|
||||
# Ask for extension ID
|
||||
# Load extension
|
||||
ext_urls = {
|
||||
"chrome": "chrome://extensions",
|
||||
"chromium": "chrome://extensions",
|
||||
@@ -291,23 +515,22 @@ def cmd_install(browser):
|
||||
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
||||
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
|
||||
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent / 'extension'}[/cyan]")
|
||||
console.print(" 4. Copy the [bold]Extension ID[/bold] shown on the extension card\n")
|
||||
console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
|
||||
|
||||
extension_id = click.prompt("Paste your extension ID here")
|
||||
extension_id = extension_id.strip()
|
||||
extension_id = EXTENSION_ID
|
||||
|
||||
# Build native messaging manifest
|
||||
manifest = {
|
||||
"name": NATIVE_HOST_NAME,
|
||||
"description": "browser-cli native messaging host",
|
||||
"path": str(wrapper_path),
|
||||
"path": str(host_exe),
|
||||
"type": "stdio",
|
||||
"allowed_origins": [f"chrome-extension://{extension_id}/"],
|
||||
}
|
||||
|
||||
installed = []
|
||||
if is_windows():
|
||||
manifest_dir = wrapper_path.parent
|
||||
manifest_dir = host_exe.parent
|
||||
manifest_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
@@ -334,10 +557,7 @@ def cmd_install(browser):
|
||||
console.print(f"[green]✓[/green] Registered native host: {p}")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
|
||||
console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}")
|
||||
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}")
|
||||
if is_windows():
|
||||
console.print("\n[green]✓[/green] Wrote native host manifest:", manifest_path)
|
||||
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
|
||||
|
||||
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
|
||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||
|
||||
+344
-23
@@ -10,8 +10,10 @@ Profile selection order:
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
@@ -19,8 +21,51 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
||||
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
|
||||
from browser_cli.registry import load_registry
|
||||
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
_USER_AGENT = f"browser-cli/{_pkg_version('browser-cli')}"
|
||||
except Exception:
|
||||
_USER_AGENT = "browser-cli/0"
|
||||
|
||||
REGISTRY_PATH = registry_path()
|
||||
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
|
||||
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
|
||||
|
||||
_DEFAULT_REMOTE_PORT = 443
|
||||
|
||||
|
||||
def _looks_like_domain(host: str) -> bool:
|
||||
"""True if host looks like a domain name rather than an IP address or localhost."""
|
||||
if host in {"localhost", "127.0.0.1", "::1"}:
|
||||
return False
|
||||
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
|
||||
return False
|
||||
return '.' in host and any(c.isalpha() for c in host)
|
||||
|
||||
|
||||
def _normalize_endpoint(endpoint: str) -> str:
|
||||
"""Strip :443 from domain-like endpoints so they are stored without the default port."""
|
||||
if not endpoint:
|
||||
return endpoint
|
||||
host, sep, port = endpoint.rpartition(":")
|
||||
if sep and port == "443" and _looks_like_domain(host):
|
||||
return host
|
||||
return endpoint
|
||||
|
||||
|
||||
def _resolve_connect_endpoint(endpoint: str) -> str:
|
||||
"""Return host:port for TCP connection; domain without port defaults to :443."""
|
||||
_, sep, _ = endpoint.rpartition(":")
|
||||
if not sep:
|
||||
if _looks_like_domain(endpoint):
|
||||
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
|
||||
raise BrowserNotConnected(
|
||||
f"Invalid remote endpoint '{endpoint}': expected host:port"
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
class BrowserNotConnected(Exception):
|
||||
@@ -32,6 +77,7 @@ class BrowserTarget:
|
||||
profile: str
|
||||
display_name: str
|
||||
socket_path: str
|
||||
remote: str | None = None
|
||||
|
||||
|
||||
def _active_endpoints(reg: dict) -> dict:
|
||||
@@ -47,17 +93,125 @@ def display_browser_name(profile_name: str, sock_path: str) -> str:
|
||||
return Path(sock_path).stem or profile_name
|
||||
|
||||
|
||||
def active_browser_targets() -> list[BrowserTarget]:
|
||||
if not REGISTRY_PATH.exists():
|
||||
return []
|
||||
def _load_remotes() -> dict[str, dict[str, str]]:
|
||||
if not REMOTE_REGISTRY_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
reg = json.loads(REGISTRY_PATH.read_text())
|
||||
data = json.loads(REMOTE_REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
return [
|
||||
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
|
||||
for profile, sock_path in _active_endpoints(reg).items()
|
||||
]
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
# normalize keys so old entries stored as "domain:443" match current lookups
|
||||
return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
|
||||
|
||||
|
||||
|
||||
def _is_valid_key_spec(s: str) -> bool:
|
||||
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
|
||||
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and ("/" in s or Path(s).suffix in {".pem", ".key"}))
|
||||
|
||||
|
||||
def save_remote_key(endpoint: str, key_spec: str) -> None:
|
||||
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
|
||||
if not endpoint or not key_spec:
|
||||
return
|
||||
if not _is_valid_key_spec(key_spec):
|
||||
return # refuse to save serialized objects or other garbage
|
||||
remotes = _load_remotes()
|
||||
current = remotes.get(endpoint, {})
|
||||
current["key"] = key_spec
|
||||
remotes[endpoint] = current
|
||||
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(remotes, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def key_for_remote(endpoint: str | None) -> str | None:
|
||||
if not endpoint:
|
||||
return None
|
||||
cfg = _load_remotes().get(endpoint) or {}
|
||||
key = cfg.get("key")
|
||||
if not key:
|
||||
return None
|
||||
key_str = str(key)
|
||||
# reject corrupted values (e.g. str(AgentKey(...)) saved by an older bug)
|
||||
if not _is_valid_key_spec(key_str):
|
||||
return None
|
||||
return key_str
|
||||
|
||||
|
||||
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
|
||||
host, sep, port = endpoint.rpartition(":")
|
||||
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
|
||||
display_endpoint = host
|
||||
else:
|
||||
display_endpoint = endpoint # normalized domain (no port) or non-default port
|
||||
return f"{display_endpoint}:{display_name or profile_name}"
|
||||
|
||||
|
||||
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
"""Return browser targets advertised by a single remote endpoint."""
|
||||
remote_targets = send_command("browser-cli.targets", remote=endpoint, key=key)
|
||||
targets: list[BrowserTarget] = []
|
||||
for item in remote_targets or []:
|
||||
profile = str(item.get("profile") or "default")
|
||||
display = str(item.get("displayName") or profile)
|
||||
targets.append(
|
||||
BrowserTarget(
|
||||
profile=profile,
|
||||
display_name=_remote_display_name(endpoint, profile, display),
|
||||
socket_path="",
|
||||
remote=endpoint,
|
||||
)
|
||||
)
|
||||
return targets
|
||||
|
||||
|
||||
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for endpoint in _load_remotes():
|
||||
try:
|
||||
targets.extend(remote_browser_targets(endpoint, key=key))
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
return targets
|
||||
|
||||
|
||||
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
||||
if not alias:
|
||||
return None
|
||||
targets = _remote_browser_targets()
|
||||
for target in targets:
|
||||
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
|
||||
if alias in {target.display_name, endpoint_profile}:
|
||||
return target
|
||||
|
||||
endpoint_matches = []
|
||||
for target in targets:
|
||||
if not target.remote:
|
||||
continue
|
||||
remote_host, sep, _remote_port = target.remote.rpartition(":")
|
||||
if alias == target.remote or (sep and alias == remote_host):
|
||||
endpoint_matches.append(target)
|
||||
if len(endpoint_matches) == 1:
|
||||
return endpoint_matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
if REGISTRY_PATH.exists():
|
||||
reg = load_registry(REGISTRY_PATH)
|
||||
targets.extend(
|
||||
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
|
||||
for profile, sock_path in _active_endpoints(reg).items()
|
||||
)
|
||||
if include_remotes:
|
||||
targets.extend(_remote_browser_targets(key=key))
|
||||
return targets
|
||||
|
||||
|
||||
def _resolve_socket(profile: str | None = None) -> str:
|
||||
@@ -66,17 +220,14 @@ def _resolve_socket(profile: str | None = None) -> str:
|
||||
|
||||
if target:
|
||||
if REGISTRY_PATH.exists():
|
||||
try:
|
||||
reg = json.loads(REGISTRY_PATH.read_text())
|
||||
if target in reg:
|
||||
return reg[target]
|
||||
except Exception:
|
||||
pass
|
||||
reg = load_registry(REGISTRY_PATH)
|
||||
if target in reg:
|
||||
return reg[target]
|
||||
return endpoint_for_alias(target)
|
||||
|
||||
# Auto-detect: error when multiple browser instances are active
|
||||
try:
|
||||
active = active_browser_targets()
|
||||
active = active_browser_targets(include_remotes=False)
|
||||
if len(active) > 1:
|
||||
aliases = [target.profile for target in active]
|
||||
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
|
||||
@@ -98,38 +249,206 @@ def _resolve_socket(profile: str | None = None) -> str:
|
||||
)
|
||||
|
||||
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None) -> Any:
|
||||
def _load_private_key(key_path: "Path | str | None" = None):
|
||||
"""Load an Ed25519 signing key.
|
||||
|
||||
Sources (in priority order):
|
||||
1. Explicit key_path / --key flag
|
||||
2. BROWSER_CLI_KEY environment variable
|
||||
3. Default PEM file (~/.config/browser-cli/client.key.pem)
|
||||
|
||||
Pass "agent" or "agent:<selector>" to use a key from the SSH agent
|
||||
(works with YubiKey via gpg-agent, TPM, or regular ssh-agent).
|
||||
"""
|
||||
raw = str(key_path) if key_path is not None else os.environ.get("BROWSER_CLI_KEY", str(_DEFAULT_KEY_PATH))
|
||||
|
||||
if raw == "agent" or raw.startswith("agent:"):
|
||||
selector = raw[6:] or None # "agent:cardno:..." → "cardno:..."
|
||||
from browser_cli.auth import agent_find_key
|
||||
return agent_find_key(selector)
|
||||
|
||||
path = Path(raw)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
from browser_cli.auth import load_private_key
|
||||
return load_private_key(path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||
connect_ep = _resolve_connect_endpoint(endpoint)
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
port = int(port_str)
|
||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw_sock.settimeout(30)
|
||||
try:
|
||||
raw_sock.connect((host, port))
|
||||
if port == 443:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
sock = ctx.wrap_socket(raw_sock, server_hostname=host)
|
||||
else:
|
||||
sock = raw_sock
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
with sock:
|
||||
|
||||
# receive challenge
|
||||
challenge_raw = _recv_all(sock)
|
||||
if challenge_raw is None:
|
||||
raise BrowserNotConnected(f"No challenge received from {endpoint}")
|
||||
try:
|
||||
challenge = json.loads(challenge_raw)
|
||||
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
nonce_hex = None
|
||||
|
||||
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
||||
if min_ver:
|
||||
from browser_cli.version_manager import parse_version
|
||||
try:
|
||||
client_ver = _USER_AGENT.split("/", 1)[1]
|
||||
if parse_version(client_ver) < parse_version(min_ver):
|
||||
raise BrowserNotConnected(
|
||||
f"Client version {client_ver} is too old for this server "
|
||||
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
pq_shared_secret = None
|
||||
if nonce_hex and private_key is not None:
|
||||
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
|
||||
nonce = bytes.fromhex(nonce_hex)
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
|
||||
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
|
||||
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
||||
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
|
||||
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
||||
else:
|
||||
sys.stderr.write(
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
)
|
||||
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
||||
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
|
||||
if pq_shared_secret is not None:
|
||||
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
||||
msg = {
|
||||
"id": clean_msg.get("id"),
|
||||
"user_agent": clean_msg.get("user_agent"),
|
||||
"pubkey": public_key_hex(private_key),
|
||||
"sig": sig.hex(),
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": encrypted,
|
||||
}
|
||||
else:
|
||||
sys.stderr.write(
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
)
|
||||
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
sock.sendall(framed)
|
||||
response = _recv_all(sock)
|
||||
if response is not None and pq_shared_secret is not None:
|
||||
try:
|
||||
from browser_cli.auth import pq_decrypt
|
||||
envelope = json.loads(response)
|
||||
if isinstance(envelope, dict) and "encrypted" in envelope:
|
||||
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
|
||||
except Exception as e:
|
||||
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
|
||||
return response
|
||||
|
||||
|
||||
def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, key=key)
|
||||
if len(targets) == 1:
|
||||
return targets[0].profile
|
||||
if len(targets) > 1:
|
||||
aliases = [target.profile for target in targets]
|
||||
examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
|
||||
raise BrowserNotConnected(
|
||||
f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
|
||||
f"Use --browser <alias> to select one:\n{examples}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, key: "Path | None" = None) -> Any:
|
||||
"""Send a command to the browser and return the response data."""
|
||||
sock_path = _resolve_socket(profile)
|
||||
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
if remote_endpoint:
|
||||
remote_endpoint = _normalize_endpoint(remote_endpoint)
|
||||
remote_alias_target = None
|
||||
if not remote_endpoint and requested_profile:
|
||||
remote_alias_target = remote_target_for_alias(requested_profile)
|
||||
if remote_alias_target:
|
||||
remote_endpoint = remote_alias_target.remote
|
||||
requested_profile = remote_alias_target.profile
|
||||
|
||||
msg = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"command": command,
|
||||
"args": args or {},
|
||||
}
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
if remote_endpoint:
|
||||
msg["user_agent"] = _USER_AGENT
|
||||
# key priority: explicit flag > saved per-remote config > BROWSER_CLI_KEY env > default file
|
||||
key_spec = key if key is not None else key_for_remote(remote_endpoint)
|
||||
private_key = _load_private_key(key_spec)
|
||||
# persist explicit key spec so future calls don't need --key
|
||||
if key is not None:
|
||||
save_remote_key(remote_endpoint, str(key))
|
||||
route_profile = requested_profile
|
||||
_no_route_commands = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
|
||||
if not route_profile and command not in _no_route_commands:
|
||||
route_profile = _auto_route_remote(remote_endpoint, key=key_spec)
|
||||
if route_profile:
|
||||
msg["_route"] = route_profile
|
||||
else:
|
||||
private_key = None
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
if remote_endpoint:
|
||||
response = _send_remote(remote_endpoint, msg, private_key)
|
||||
elif is_windows():
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
sock_path = _resolve_socket(profile)
|
||||
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
||||
conn.send_bytes(payload)
|
||||
response = conn.recv_bytes()
|
||||
else:
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
sock_path = _resolve_socket(profile)
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(sock_path)
|
||||
sock.sendall(framed)
|
||||
sock.sendall(struct.pack("<I", len(payload)) + payload)
|
||||
response = _recv_all(sock)
|
||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||
if remote_endpoint:
|
||||
raise BrowserNotConnected(
|
||||
f"Cannot connect to remote browser at {remote_endpoint}.\n"
|
||||
"Make sure browser-cli serve is running on the remote host."
|
||||
)
|
||||
profile_hint = f" (profile: {profile})" if profile else ""
|
||||
raise BrowserNotConnected(
|
||||
f"Cannot connect to browser{profile_hint}.\n"
|
||||
"Make sure:\n"
|
||||
" 1. The browser-cli extension is installed and enabled\n"
|
||||
" 2. The native host is registered: uv run browser-cli install chrome\n"
|
||||
" 2. The native host is registered: uv run browser-cli install <browser>\n"
|
||||
" 3. Your browser is running\n"
|
||||
" Tip: use BROWSER_CLI_PROFILE=<name> to select a specific profile"
|
||||
)
|
||||
|
||||
if response is None:
|
||||
raise ConnectionError("Connection closed before full response received")
|
||||
result = json.loads(response)
|
||||
if not result.get("success", True):
|
||||
raise RuntimeError(result.get("error", "unknown error from browser"))
|
||||
@@ -139,6 +458,8 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
def _recv_all(sock: socket.socket) -> bytes:
|
||||
raw_len = _recv_exact(sock, 4)
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
if msg_len > _MAX_MSG_BYTES:
|
||||
raise ConnectionError(f"Response too large ({msg_len} bytes)")
|
||||
return _recv_exact(sock, msg_len)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import click
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||
from rich.console import Console
|
||||
|
||||
_console = Console()
|
||||
|
||||
|
||||
def _handle(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except BrowserNotConnected as e:
|
||||
_console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
_console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _handle_multi(command, args=None, profile=None, remote=None):
|
||||
try:
|
||||
if remote:
|
||||
return send_command(command, args or {}, profile=profile, remote=remote)
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _multi_browser_targets():
|
||||
root = click.get_current_context().find_root()
|
||||
if root.obj.get("browser_explicit"):
|
||||
return []
|
||||
remote = root.obj.get("remote")
|
||||
key = root.obj.get("key")
|
||||
if remote:
|
||||
targets = remote_browser_targets(remote, key=key)
|
||||
else:
|
||||
targets = active_browser_targets(key=key)
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
return []
|
||||
return targets
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("cookies")
|
||||
def cookies_group():
|
||||
"""Manage browser cookies."""
|
||||
|
||||
|
||||
@cookies_group.command("list")
|
||||
@click.option("--url", default=None, help="Filter by URL")
|
||||
@click.option("--domain", default=None, help="Filter by domain")
|
||||
@click.option("--name", default=None, help="Filter by cookie name")
|
||||
def cookies_list(url, domain, name):
|
||||
"""List cookies, optionally filtered by URL, domain, or name."""
|
||||
cookies = _handle("cookies.list", {"url": url, "domain": domain, "name": name}) or []
|
||||
if not cookies:
|
||||
console.print("[yellow]No cookies found[/yellow]")
|
||||
return
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Value")
|
||||
table.add_column("Domain")
|
||||
table.add_column("Path")
|
||||
table.add_column("Secure", width=7)
|
||||
table.add_column("HttpOnly", width=9)
|
||||
for c in cookies:
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
(c.get("value") or "")[:60],
|
||||
c.get("domain", ""),
|
||||
c.get("path", ""),
|
||||
"[green]✓[/green]" if c.get("secure") else "",
|
||||
"[green]✓[/green]" if c.get("httpOnly") else "",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@cookies_group.command("get")
|
||||
@click.argument("url")
|
||||
@click.argument("name")
|
||||
def cookies_get(url, name):
|
||||
"""Get the value of a single cookie by URL and NAME."""
|
||||
cookie = _handle("cookies.get", {"url": url, "name": name})
|
||||
if cookie is None:
|
||||
console.print(f"[yellow]Cookie '{name}' not found for {url}[/yellow]")
|
||||
raise SystemExit(1)
|
||||
console.print(cookie.get("value", ""))
|
||||
|
||||
|
||||
@cookies_group.command("set")
|
||||
@click.argument("url")
|
||||
@click.argument("name")
|
||||
@click.argument("value")
|
||||
@click.option("--domain", default=None)
|
||||
@click.option("--path", default=None)
|
||||
@click.option("--secure", is_flag=True)
|
||||
@click.option("--http-only", "http_only", is_flag=True)
|
||||
@click.option("--expires", "expiration_date", type=float, default=None, help="Unix timestamp")
|
||||
@click.option("--same-site", type=click.Choice(["no_restriction", "lax", "strict"]), default=None)
|
||||
def cookies_set(url, name, value, domain, path, secure, http_only, expiration_date, same_site):
|
||||
"""Set a cookie on URL."""
|
||||
_handle("cookies.set", {
|
||||
"url": url, "name": name, "value": value,
|
||||
"domain": domain, "path": path,
|
||||
"secure": secure or None,
|
||||
"httpOnly": http_only or None,
|
||||
"expirationDate": expiration_date,
|
||||
"sameSite": same_site,
|
||||
})
|
||||
console.print(f"[green]Set cookie:[/green] {name}={value!r} on {url}")
|
||||
+131
-12
@@ -1,5 +1,5 @@
|
||||
import click
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
import json
|
||||
@@ -7,17 +7,6 @@ import json
|
||||
console = Console()
|
||||
|
||||
|
||||
def _handle(command, args=None):
|
||||
try:
|
||||
return send_command(command, args or {})
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@click.group("dom")
|
||||
def dom_group():
|
||||
"""Query and interact with page DOM elements."""
|
||||
@@ -87,3 +76,133 @@ def dom_exists(selector):
|
||||
else:
|
||||
console.print(f"[red]not found[/red]: {selector}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@dom_group.command("scroll")
|
||||
@click.argument("selector", required=False)
|
||||
@click.option("--x", type=int, default=None, help="Horizontal scroll position (px)")
|
||||
@click.option("--y", type=int, default=None, help="Vertical scroll position (px)")
|
||||
def dom_scroll(selector, x, y):
|
||||
"""Scroll to a CSS SELECTOR or to an X/Y coordinate."""
|
||||
_handle("dom.scroll", {"selector": selector, "x": x, "y": y})
|
||||
target = selector or f"({x or 0}, {y or 0})"
|
||||
console.print(f"[green]Scrolled to:[/green] {target}")
|
||||
|
||||
|
||||
@dom_group.command("select")
|
||||
@click.argument("selector")
|
||||
@click.argument("value")
|
||||
def dom_select(selector, value):
|
||||
"""Set the VALUE of a <select> dropdown matching CSS SELECTOR."""
|
||||
_handle("dom.select", {"selector": selector, "value": value})
|
||||
console.print(f"[green]Selected '{value}' in:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("eval")
|
||||
@click.argument("code")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
def dom_eval(code, tab_id):
|
||||
"""Evaluate JavaScript CODE in the page and print the result."""
|
||||
result = _handle("dom.eval", {"code": code, "tabId": tab_id})
|
||||
if result is None:
|
||||
console.print("[dim]null[/dim]")
|
||||
else:
|
||||
console.print(json.dumps(result, indent=2) if isinstance(result, (dict, list)) else str(result))
|
||||
|
||||
|
||||
@dom_group.command("wait-for")
|
||||
@click.argument("selector")
|
||||
@click.option("--timeout", type=float, default=10.0, show_default=True, help="Max seconds to wait")
|
||||
@click.option("--visible", is_flag=True, help="Wait until element is visible (non-zero size)")
|
||||
@click.option("--hidden", is_flag=True, help="Wait until element is absent or hidden")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
def dom_wait_for(selector, timeout, visible, hidden, tab_id):
|
||||
"""Wait until CSS SELECTOR appears (or disappears) in the DOM."""
|
||||
_handle("dom.wait_for", {
|
||||
"selector": selector,
|
||||
"timeout": int(timeout * 1000),
|
||||
"visible": visible,
|
||||
"hidden": hidden,
|
||||
"tabId": tab_id,
|
||||
})
|
||||
state = "hidden" if hidden else ("visible" if visible else "present")
|
||||
console.print(f"[green]Ready ({state}):[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("key")
|
||||
@click.argument("key")
|
||||
@click.option("--selector", default=None, help="CSS selector to target (default: focused element)")
|
||||
def dom_key(key, selector):
|
||||
"""Dispatch a keyboard KEY event (e.g. Enter, Tab, Escape, ArrowDown)."""
|
||||
_handle("dom.key", {"key": key, "selector": selector})
|
||||
target = selector or "active element"
|
||||
console.print(f"[green]Key '{key}' sent to:[/green] {target}")
|
||||
|
||||
|
||||
@dom_group.command("hover")
|
||||
@click.argument("selector")
|
||||
def dom_hover(selector):
|
||||
"""Dispatch mouseover/mouseenter on the element matching CSS SELECTOR."""
|
||||
_handle("dom.hover", {"selector": selector})
|
||||
console.print(f"[green]Hovered:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("check")
|
||||
@click.argument("selector")
|
||||
def dom_check(selector):
|
||||
"""Check a checkbox matching CSS SELECTOR."""
|
||||
_handle("dom.check", {"selector": selector})
|
||||
console.print(f"[green]Checked:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("uncheck")
|
||||
@click.argument("selector")
|
||||
def dom_uncheck(selector):
|
||||
"""Uncheck a checkbox matching CSS SELECTOR."""
|
||||
_handle("dom.uncheck", {"selector": selector})
|
||||
console.print(f"[green]Unchecked:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("clear")
|
||||
@click.argument("selector")
|
||||
def dom_clear(selector):
|
||||
"""Clear the value of an input matching CSS SELECTOR."""
|
||||
_handle("dom.clear", {"selector": selector})
|
||||
console.print(f"[green]Cleared:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("focus")
|
||||
@click.argument("selector")
|
||||
def dom_focus(selector):
|
||||
"""Focus the element matching CSS SELECTOR."""
|
||||
_handle("dom.focus", {"selector": selector})
|
||||
console.print(f"[green]Focused:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("submit")
|
||||
@click.argument("selector")
|
||||
def dom_submit(selector):
|
||||
"""Submit the form that contains the element matching CSS SELECTOR."""
|
||||
_handle("dom.submit", {"selector": selector})
|
||||
console.print(f"[green]Submitted form for:[/green] {selector}")
|
||||
|
||||
|
||||
@dom_group.command("poll")
|
||||
@click.argument("selector")
|
||||
@click.argument("pattern")
|
||||
@click.option("--attr", default=None, help="Attribute or property to read (default: textContent/value)")
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
||||
@click.option("--interval", type=float, default=0.5, show_default=True, help="Poll interval in seconds")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
def dom_poll(selector, pattern, attr, timeout, interval, tab_id):
|
||||
"""Poll SELECTOR until its text/value matches regex PATTERN."""
|
||||
result = _handle("dom.poll", {
|
||||
"selector": selector,
|
||||
"pattern": pattern,
|
||||
"attr": attr,
|
||||
"timeout": int(timeout * 1000),
|
||||
"interval": int(interval * 1000),
|
||||
"tabId": tab_id,
|
||||
})
|
||||
value = result.get("value", "") if isinstance(result, dict) else ""
|
||||
console.print(f"[green]Matched:[/green] {selector!r} = {value!r}")
|
||||
|
||||
@@ -3,7 +3,7 @@ import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import click
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
@@ -423,17 +423,6 @@ def _convert_html_to_markdown(html):
|
||||
return _clean_markdown_output(markdown)
|
||||
|
||||
|
||||
def _handle(command, args=None):
|
||||
try:
|
||||
return send_command(command, args or {})
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@click.group("extract")
|
||||
def extract_group():
|
||||
"""Extract content from the active tab."""
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
import click
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
|
||||
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _handle(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _handle_multi(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _multi_browser_targets():
|
||||
root = click.get_current_context().find_root()
|
||||
if root.obj.get("browser_explicit"):
|
||||
return []
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1:
|
||||
return []
|
||||
return targets
|
||||
|
||||
|
||||
def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
|
||||
if not groups:
|
||||
console.print("[yellow]No groups found[/yellow]")
|
||||
@@ -71,7 +43,7 @@ def group_list():
|
||||
if targets:
|
||||
groups = []
|
||||
for target in targets:
|
||||
result = _handle_multi("group.list", profile=target.profile)
|
||||
result = _handle_multi("group.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
groups.extend({**group, "browser": target.display_name} for group in result)
|
||||
@@ -104,7 +76,7 @@ def group_count():
|
||||
total = 0
|
||||
rows = 0
|
||||
for target in targets:
|
||||
count = _handle_multi("group.count", profile=target.profile)
|
||||
count = _handle_multi("group.count", profile=target.profile, remote=target.remote)
|
||||
if count is None:
|
||||
continue
|
||||
count = int(count or 0)
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import click
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _handle(command, args):
|
||||
try:
|
||||
return send_command(command, args)
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@click.group("nav")
|
||||
def nav_group():
|
||||
"""Navigate — open URLs, reload, go back/forward, focus tabs."""
|
||||
@@ -78,3 +67,29 @@ def cmd_focus(pattern):
|
||||
console.print(f"[green]Focused:[/green] {result.get('url', result)}")
|
||||
else:
|
||||
console.print(f"[yellow]No tab found matching:[/yellow] {pattern}")
|
||||
|
||||
|
||||
@nav_group.command("open-wait")
|
||||
@click.argument("url")
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load")
|
||||
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
|
||||
@click.option("--window", "window_name", default=None, help="Open in named window")
|
||||
@click.option("--group", "group_name", default=None, help="Open in tab group")
|
||||
def cmd_open_wait(url, timeout, bg, window_name, group_name):
|
||||
"""Open URL in a new tab and wait until fully loaded."""
|
||||
result = _handle("navigate.open_wait", {
|
||||
"url": url, "timeout": int(timeout * 1000),
|
||||
"background": bg, "window": window_name, "group": group_name,
|
||||
})
|
||||
title = result.get("title", "") if isinstance(result, dict) else ""
|
||||
console.print(f"[green]Loaded:[/green] {url}" + (f" — {title}" if title else ""))
|
||||
|
||||
|
||||
@nav_group.command("wait")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
||||
@click.option("--ready-state", type=click.Choice(["complete", "interactive"]), default="complete", show_default=True, help="Target ready state")
|
||||
def cmd_wait(tab_id, timeout, ready_state):
|
||||
"""Wait until tab finishes loading."""
|
||||
result = _handle("navigate.wait", {"tabId": tab_id, "timeout": int(timeout * 1000), "readyState": ready_state})
|
||||
console.print(f"[green]Ready:[/green] {result.get('url', '')} — {result.get('title', '')}")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("page")
|
||||
def page_group():
|
||||
"""Inspect current page metadata."""
|
||||
|
||||
|
||||
@page_group.command("info")
|
||||
def page_info():
|
||||
"""Show title, URL, readyState, language, and meta tags of the active tab."""
|
||||
info = _handle("page.info") or {}
|
||||
table = Table(show_header=False)
|
||||
table.add_column("Field", style="bold cyan", no_wrap=True)
|
||||
table.add_column("Value")
|
||||
table.add_row("Title", info.get("title") or "")
|
||||
table.add_row("URL", info.get("url") or "")
|
||||
table.add_row("Ready", info.get("readyState") or "")
|
||||
table.add_row("Lang", info.get("lang") or "")
|
||||
for key, val in (info.get("meta") or {}).items():
|
||||
table.add_row(f"meta:{key}", val)
|
||||
console.print(table)
|
||||
@@ -1,6 +1,6 @@
|
||||
import click
|
||||
from urllib.parse import quote_plus
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
@@ -71,14 +71,7 @@ def _build_command(engine_key: str, help_text: str) -> click.Command:
|
||||
def _cmd(query, bg, window, group):
|
||||
terms = " ".join(query)
|
||||
url = ENGINES[engine_key].format(query=quote_plus(terms))
|
||||
try:
|
||||
send_command("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
_handle("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
|
||||
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
||||
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import re, threading, secrets, socket, struct, click, json, sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.client import _recv_exact, _recv_all
|
||||
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
|
||||
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, MAX_MSG_BYTES, parse_version, get_installed_version
|
||||
|
||||
_UA_PATTERN = re.compile(r"^browser-cli/\d")
|
||||
_CONN_LIMIT = threading.BoundedSemaphore(64)
|
||||
console = Console()
|
||||
|
||||
|
||||
def _framed_send(sock: socket.socket, data: bytes) -> None:
|
||||
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||
|
||||
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
addr_str = f"{addr[0]}:{addr[1]}"
|
||||
profile_str = f"[dim]{profile}[/dim] " if profile else ""
|
||||
if error:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
|
||||
else:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||
|
||||
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None) -> None:
|
||||
from browser_cli.client import _resolve_socket, BrowserNotConnected
|
||||
from browser_cli.platform import is_windows
|
||||
|
||||
response_secret = None
|
||||
|
||||
def _send_payload(data: bytes) -> None:
|
||||
if response_secret is not None:
|
||||
from browser_cli.auth import pq_encrypt
|
||||
data = json.dumps({"encrypted": pq_encrypt(response_secret, "response", data)}).encode()
|
||||
_framed_send(client_sock, data)
|
||||
|
||||
def _send_error(msg_id, msg:str) -> None:
|
||||
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
|
||||
try:
|
||||
_send_payload(err)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _send_ok(msg_id, payload) -> None:
|
||||
out = json.dumps({"id": msg_id, "success": True, "data": payload}).encode()
|
||||
try:
|
||||
_send_payload(out)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
header = _recv_exact(client_sock, 4)
|
||||
msg_len = struct.unpack("<I", header)[0]
|
||||
if msg_len > MAX_MSG_BYTES:
|
||||
_send_error(None, f"message too large ({msg_len} bytes)")
|
||||
return
|
||||
payload = _recv_exact(client_sock, msg_len)
|
||||
except (ConnectionError, OSError):
|
||||
return
|
||||
|
||||
try:
|
||||
msg = json.loads(payload)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
_send_error(None, "invalid JSON")
|
||||
_log(addr, "?", None, "ERROR", "invalid JSON")
|
||||
return
|
||||
|
||||
# ── user-agent + version check ────────────────────────────────────────────
|
||||
msg_id = msg.get("id")
|
||||
ua = msg.get("user_agent") or ""
|
||||
if not _UA_PATTERN.match(ua):
|
||||
_send_error(msg_id, "forbidden: client required")
|
||||
_log(addr, msg.get("command", "?"), None, "DENIED", f"bad user-agent: {ua!r}")
|
||||
return
|
||||
client_ver = "0"
|
||||
try:
|
||||
client_ver = ua.split("/", 1)[1]
|
||||
if parse_version(client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
|
||||
_send_error(msg_id, f"client version {client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
|
||||
_log(addr, msg.get("command", "?"), None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
|
||||
return
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
msg = adapt_auth(msg, client_ver)
|
||||
command = msg.get("command", "?")
|
||||
|
||||
# ── auth ──────────────────────────────────────────────────────────────────
|
||||
if auth_keys is not None:
|
||||
pub = msg.get("pubkey") or ""
|
||||
sig = msg.get("sig") or ""
|
||||
if not pub or not sig:
|
||||
_send_error(msg_id, "unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
|
||||
_log(addr, command, None, "DENIED", "missing pubkey/sig")
|
||||
return
|
||||
if pub not in auth_keys:
|
||||
_send_error(msg_id, "unauthorized: untrusted public key")
|
||||
_log(addr, command, None, "DENIED", "untrusted key")
|
||||
return
|
||||
pq_shared_secret = None
|
||||
transport_encrypted = False
|
||||
if pq_private_key is not None:
|
||||
kex = msg.get("pq_kex") or {}
|
||||
pq_required = parse_version(client_ver) >= parse_version("0.9.5")
|
||||
if not isinstance(kex, dict) or kex.get("alg") != "ML-KEM-768" or not kex.get("ciphertext"):
|
||||
if pq_required:
|
||||
_send_error(msg_id, "unauthorized: post-quantum key exchange required")
|
||||
_log(addr, command, None, "DENIED", "missing pq kex")
|
||||
return
|
||||
else:
|
||||
try:
|
||||
from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
|
||||
pq_shared_secret = pq_kex_server_decapsulate(pq_private_key, str(kex["ciphertext"]))
|
||||
if "encrypted" in msg:
|
||||
decrypted_msg = json.loads(pq_decrypt(pq_shared_secret, "request", msg["encrypted"]))
|
||||
if not isinstance(decrypted_msg, dict):
|
||||
raise ValueError("encrypted request is not a JSON object")
|
||||
decrypted_msg["pubkey"] = pub
|
||||
decrypted_msg["sig"] = sig
|
||||
decrypted_msg["pq_kex"] = kex
|
||||
msg = adapt_auth(decrypted_msg, client_ver)
|
||||
msg_id = msg.get("id", msg_id)
|
||||
command = msg.get("command", "?")
|
||||
transport_encrypted = True
|
||||
elif pq_required:
|
||||
_send_error(msg_id, "unauthorized: post-quantum encrypted transport required")
|
||||
_log(addr, command, None, "DENIED", "missing pq transport")
|
||||
return
|
||||
except Exception:
|
||||
_send_error(msg_id, "unauthorized: invalid post-quantum encrypted transport")
|
||||
_log(addr, command, None, "DENIED", "bad pq transport")
|
||||
return
|
||||
|
||||
from browser_cli.auth import verify
|
||||
if not verify(pub, bytes.fromhex(nonce), msg, sig, pq_shared_secret):
|
||||
_send_error(msg_id, "unauthorized: invalid signature")
|
||||
_log(addr, command, None, "DENIED", "bad signature")
|
||||
return
|
||||
response_secret = pq_shared_secret if transport_encrypted else None
|
||||
|
||||
if command == "browser-cli.targets":
|
||||
from browser_cli.client import active_browser_targets
|
||||
targets = [
|
||||
{"profile": target.profile, "displayName": target.display_name}
|
||||
for target in active_browser_targets(include_remotes=False)
|
||||
]
|
||||
_send_ok(msg_id, targets)
|
||||
_log(addr, command, None, "OK")
|
||||
return
|
||||
|
||||
if command == "browser-cli.auth.keys":
|
||||
if auth_keys_path is None:
|
||||
_send_error(msg_id, "no authorized keys file configured on this server")
|
||||
_log(addr, command, None, "ERROR", "no authorized keys file")
|
||||
return
|
||||
from browser_cli.auth import load_authorized_keys_with_names
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
|
||||
_send_ok(msg_id, entries)
|
||||
_log(addr, command, None, "OK")
|
||||
return
|
||||
|
||||
if command == "browser-cli.auth.trust":
|
||||
if auth_keys_path is None:
|
||||
_send_error(msg_id, "no authorized keys file configured on this server")
|
||||
_log(addr, command, None, "ERROR", "no authorized keys file")
|
||||
return
|
||||
from browser_cli.auth import add_authorized_key
|
||||
args = msg.get("args") or {}
|
||||
pubkey = str(args.get("pubkey") or "")
|
||||
name = str(args.get("name") or "")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
|
||||
_send_error(msg_id, "invalid pubkey: expected 64 lowercase hex characters")
|
||||
_log(addr, command, None, "ERROR", "invalid pubkey")
|
||||
return
|
||||
added = add_authorized_key(auth_keys_path, pubkey, name)
|
||||
_send_ok(msg_id, {"added": added})
|
||||
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
|
||||
return
|
||||
|
||||
resolved_profile = msg.get("_route") or profile
|
||||
|
||||
# ── strip protocol fields, apply request compat shim, forward ─────────────
|
||||
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted"}
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in strip}
|
||||
clean_msg = adapt_request(clean_msg, client_ver)
|
||||
clean_payload = json.dumps(clean_msg).encode()
|
||||
clean_header = struct.pack("<I", len(clean_payload))
|
||||
|
||||
try:
|
||||
sock_path = _resolve_socket(resolved_profile)
|
||||
except BrowserNotConnected as e:
|
||||
_send_error(msg_id, str(e))
|
||||
_log(addr, command, resolved_profile, "ERROR", "browser not connected")
|
||||
return
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
with PipeClient(sock_path, family="AF_PIPE") as pipe:
|
||||
pipe.send_bytes(clean_payload)
|
||||
resp_payload = pipe.recv_bytes()
|
||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||
_send_payload(resp_payload)
|
||||
else:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
|
||||
local.connect(sock_path)
|
||||
local.sendall(clean_header + clean_payload)
|
||||
resp_payload = _recv_all(local)
|
||||
resp_payload = adapt_response(resp_payload, command, client_ver)
|
||||
_send_payload(resp_payload)
|
||||
|
||||
resp_data = json.loads(resp_payload)
|
||||
if resp_data.get("success", True):
|
||||
_log(addr, command, resolved_profile, "OK")
|
||||
else:
|
||||
_log(addr, command, resolved_profile, "ERROR", resp_data.get("error", ""))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
_send_error(msg_id, str(e))
|
||||
_log(addr, command, resolved_profile, "ERROR", str(e))
|
||||
|
||||
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
|
||||
if not _CONN_LIMIT.acquire(blocking=False):
|
||||
client_sock.close()
|
||||
return
|
||||
client_sock.settimeout(30)
|
||||
try:
|
||||
with client_sock:
|
||||
# reload on every connection so auth trust --remote takes effect immediately
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
auth_keys: list[str] | None = load_authorized_keys(auth_keys_path)
|
||||
else:
|
||||
auth_keys = None
|
||||
nonce = secrets.token_hex(32)
|
||||
pq_private_key = None
|
||||
challenge_msg = {
|
||||
"type": "challenge",
|
||||
"nonce": nonce,
|
||||
"server_version": get_installed_version(),
|
||||
"min_client_version": PROTOCOL_MIN_CLIENT,
|
||||
}
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
|
||||
pq_keypair = pq_kex_server_keypair()
|
||||
if pq_keypair is not None:
|
||||
pq_private_key, pq_public_key = pq_keypair
|
||||
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
|
||||
challenge = json.dumps(challenge_msg).encode()
|
||||
try:
|
||||
_framed_send(client_sock, challenge)
|
||||
except OSError:
|
||||
return
|
||||
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key)
|
||||
finally:
|
||||
_CONN_LIMIT.release()
|
||||
|
||||
|
||||
@click.command("serve")
|
||||
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
|
||||
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
|
||||
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
|
||||
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
|
||||
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
|
||||
@click.pass_context
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
|
||||
"""Expose this browser over TCP so remote hosts can control it."""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
|
||||
if host in ("0.0.0.0", "::"):
|
||||
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
|
||||
|
||||
if auth_keys_file:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
auth_keys_path = Path(auth_keys_file)
|
||||
if not load_authorized_keys(auth_keys_path):
|
||||
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
|
||||
elif no_auth:
|
||||
auth_keys_path = None
|
||||
else:
|
||||
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
|
||||
sys.exit(1)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
server.bind((host, port))
|
||||
except OSError as e:
|
||||
server.close()
|
||||
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
||||
sys.exit(1)
|
||||
server.listen(16)
|
||||
|
||||
current_ver = get_installed_version()
|
||||
browser_hint = f" (browser: {profile})" if profile else ""
|
||||
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
|
||||
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
n = len(load_authorized_keys(auth_keys_path))
|
||||
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||
else:
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||
|
||||
console.print("Ctrl-C to stop.\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
|
||||
except KeyboardInterrupt:
|
||||
console.print("[yellow]Stopped.[/yellow]")
|
||||
finally:
|
||||
server.close()
|
||||
@@ -1,38 +1,10 @@
|
||||
import click
|
||||
from browser_cli.client import active_browser_targets, send_command, BrowserNotConnected
|
||||
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _handle(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _handle_multi(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _multi_browser_targets():
|
||||
root = click.get_current_context().find_root()
|
||||
if root.obj.get("browser_explicit"):
|
||||
return []
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1:
|
||||
return []
|
||||
return targets
|
||||
|
||||
|
||||
@click.group("session")
|
||||
def session_group():
|
||||
"""Save and restore browser sessions."""
|
||||
@@ -92,7 +64,7 @@ def session_list():
|
||||
if targets:
|
||||
sessions = []
|
||||
for target in targets:
|
||||
result = _handle_multi("session.list", profile=target.profile)
|
||||
result = _handle_multi("session.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
sessions.extend({**session, "browser": target.display_name} for session in result)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
import click
|
||||
from browser_cli.commands import _handle
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("storage")
|
||||
def storage_group():
|
||||
"""Read and write the page's localStorage / sessionStorage."""
|
||||
|
||||
|
||||
@storage_group.command("get")
|
||||
@click.argument("key", required=False)
|
||||
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
def storage_get(key, store_type, tab_id):
|
||||
"""Get a localStorage KEY (or dump all keys if omitted)."""
|
||||
result = _handle("storage.get", {"key": key, "type": store_type, "tabId": tab_id})
|
||||
if result is None:
|
||||
console.print("[dim]null[/dim]")
|
||||
elif isinstance(result, dict):
|
||||
console.print(json.dumps(result, indent=2))
|
||||
else:
|
||||
console.print(str(result))
|
||||
|
||||
|
||||
@storage_group.command("set")
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
def storage_set(key, value, store_type, tab_id):
|
||||
"""Set localStorage KEY to VALUE."""
|
||||
_handle("storage.set", {"key": key, "value": value, "type": store_type, "tabId": tab_id})
|
||||
console.print(f"[green]Set[/green] {store_type}[{key!r}] = {value!r}")
|
||||
@@ -1,39 +1,13 @@
|
||||
import base64
|
||||
import binascii
|
||||
import click
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
|
||||
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _handle(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _handle_multi(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _multi_browser_targets():
|
||||
root = click.get_current_context().find_root()
|
||||
if root.obj.get("browser_explicit"):
|
||||
return []
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1:
|
||||
return []
|
||||
return targets
|
||||
|
||||
|
||||
def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
|
||||
if not tabs:
|
||||
console.print("[yellow]No tabs found[/yellow]")
|
||||
@@ -75,7 +49,7 @@ def tabs_list():
|
||||
if targets:
|
||||
tabs = []
|
||||
for target in targets:
|
||||
result = _handle_multi("tabs.list", profile=target.profile)
|
||||
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
tabs.extend({**tab, "browser": target.display_name} for tab in result)
|
||||
@@ -162,7 +136,7 @@ def tabs_count(pattern):
|
||||
total = 0
|
||||
rows = 0
|
||||
for target in targets:
|
||||
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile)
|
||||
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote)
|
||||
if count is None:
|
||||
continue
|
||||
count = int(count or 0)
|
||||
@@ -236,3 +210,59 @@ def tabs_unmute(tab_id):
|
||||
result = _handle("tabs.unmute", {"tabId": tab_id})
|
||||
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
|
||||
console.print(f"[green]Unmuted tab {target}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("pin")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
def tabs_pin(tab_id):
|
||||
"""Pin the active tab or a specific tab."""
|
||||
result = _handle("tabs.pin", {"tabId": tab_id})
|
||||
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
|
||||
console.print(f"[green]Pinned tab {target}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("unpin")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
def tabs_unpin(tab_id):
|
||||
"""Unpin the active tab or a specific tab."""
|
||||
result = _handle("tabs.unpin", {"tabId": tab_id})
|
||||
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
|
||||
console.print(f"[green]Unpinned tab {target}[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("watch-url")
|
||||
@click.argument("pattern")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
||||
def tabs_watch_url(pattern, tab_id, timeout):
|
||||
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
|
||||
result = _handle("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
|
||||
url = result.get("url", "") if isinstance(result, dict) else ""
|
||||
console.print(f"[green]URL matched:[/green] {url}")
|
||||
|
||||
|
||||
@tabs_group.command("screenshot")
|
||||
@click.argument("output", required=False, metavar="FILE")
|
||||
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
||||
@click.option("--format", "fmt", type=click.Choice(["png", "jpeg"]), default="png", show_default=True)
|
||||
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
|
||||
def tabs_screenshot(output, tab_id, fmt, quality):
|
||||
"""Capture a screenshot of the active (or specified) tab.
|
||||
|
||||
Saves to FILE if given, otherwise prints the base64 data URL.
|
||||
"""
|
||||
result = _handle("tabs.screenshot", {"tabId": tab_id, "format": fmt, "quality": quality})
|
||||
data_url = result.get("dataUrl", "") if isinstance(result, dict) else ""
|
||||
if output:
|
||||
header = f"data:image/{fmt};base64,"
|
||||
if not data_url.startswith(header):
|
||||
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
|
||||
try:
|
||||
raw = base64.b64decode(data_url[len(header):])
|
||||
except binascii.Error as e:
|
||||
raise click.ClickException(f"Failed to decode screenshot data: {e}")
|
||||
with open(output, "wb") as f:
|
||||
f.write(raw)
|
||||
console.print(f"[green]Screenshot saved:[/green] {output}")
|
||||
else:
|
||||
console.print(data_url)
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
import click
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
|
||||
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _handle(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except BrowserNotConnected as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _handle_multi(command, args=None, profile=None):
|
||||
try:
|
||||
return send_command(command, args or {}, profile=profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _multi_browser_targets():
|
||||
root = click.get_current_context().find_root()
|
||||
if root.obj.get("browser_explicit"):
|
||||
return []
|
||||
targets = active_browser_targets()
|
||||
if len(targets) <= 1:
|
||||
return []
|
||||
return targets
|
||||
|
||||
|
||||
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
||||
if not windows:
|
||||
console.print("[yellow]No windows found[/yellow]")
|
||||
@@ -69,7 +41,7 @@ def windows_list():
|
||||
if targets:
|
||||
windows = []
|
||||
for target in targets:
|
||||
result = _handle_multi("windows.list", profile=target.profile)
|
||||
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote)
|
||||
if result is None:
|
||||
continue
|
||||
windows.extend({**window, "browser": target.display_name} for window in result)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from browser_cli.compat.commands import adapt_request, adapt_response
|
||||
from browser_cli.compat.auth import adapt_auth
|
||||
|
||||
__all__ = ["adapt_auth", "adapt_request", "adapt_response"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Auth-field normalizers — applied to the raw incoming message *before* the
|
||||
auth check runs. Protocol fields (pubkey, sig, …) are still present here.
|
||||
|
||||
Add one entry per breaking auth-field change:
|
||||
("X.Y.Z", transformer_fn)
|
||||
|
||||
Entries must stay in ascending version order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Callable
|
||||
from browser_cli.version_manager import parse_version
|
||||
|
||||
|
||||
# ── v0.9.3 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _auth_0_9_3(msg: dict) -> dict:
|
||||
"""pubkey validation tightened to lowercase hex; normalize for older clients."""
|
||||
changed: dict = {}
|
||||
pk = msg.get("pubkey")
|
||||
if isinstance(pk, str) and pk:
|
||||
changed["pubkey"] = pk.lower()
|
||||
if msg.get("command") == "browser-cli.auth.trust":
|
||||
args = msg.get("args") or {}
|
||||
trust_pk = args.get("pubkey")
|
||||
if isinstance(trust_pk, str) and trust_pk:
|
||||
changed["args"] = {**args, "pubkey": trust_pk.lower()}
|
||||
return {**msg, **changed} if changed else msg
|
||||
|
||||
|
||||
# ── registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
|
||||
("0.9.3", _auth_0_9_3),
|
||||
]
|
||||
|
||||
|
||||
def adapt_auth(msg: dict, client_version: str) -> dict:
|
||||
"""Apply all auth normalizers needed to bring msg up to the current format."""
|
||||
cv = parse_version(client_version)
|
||||
for version, fn in _AUTH_COMPAT:
|
||||
if cv < parse_version(version):
|
||||
msg = fn(msg)
|
||||
return msg
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Command-format shims — applied to clean_msg (protocol fields already stripped)
|
||||
before forwarding to the native host, and to responses before sending back.
|
||||
|
||||
Add one entry per breaking command-format change:
|
||||
("X.Y.Z", request_fn, response_fn)
|
||||
|
||||
- request_fn(msg: dict) -> dict or None
|
||||
- response_fn(resp: bytes, command: str) -> bytes or None
|
||||
|
||||
Entries must stay in ascending version order.
|
||||
adapt_request walks forward (oldest first); adapt_response walks backward.
|
||||
|
||||
Current baseline: 0.9.3 — no command-format shims needed yet.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Callable
|
||||
from browser_cli.version_manager import parse_version
|
||||
|
||||
|
||||
# ── registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
|
||||
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
|
||||
]
|
||||
|
||||
|
||||
def adapt_request(msg: dict, client_version: str) -> dict:
|
||||
"""Upgrade a client message to the current browser command format."""
|
||||
cv = parse_version(client_version)
|
||||
for version, req_fn, _ in _COMPAT:
|
||||
if cv < parse_version(version) and req_fn is not None:
|
||||
msg = req_fn(msg)
|
||||
return msg
|
||||
|
||||
|
||||
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
|
||||
"""Downgrade a native-host response to the format the client expects."""
|
||||
cv = parse_version(client_version)
|
||||
for version, _, resp_fn in reversed(_COMPAT):
|
||||
if cv < parse_version(version) and resp_fn is not None:
|
||||
resp = resp_fn(resp, command)
|
||||
return resp
|
||||
@@ -29,9 +29,9 @@ class Tab:
|
||||
id: int
|
||||
window_id: int
|
||||
active: bool
|
||||
muted: bool
|
||||
title: str
|
||||
url: str
|
||||
muted: bool = False
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
group_id: int | None = None
|
||||
browser: str | None = None
|
||||
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
|
||||
|
||||
+139
-44
@@ -7,6 +7,7 @@ It relays messages between extension (stdin/stdout Native Messaging protocol)
|
||||
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
@@ -18,21 +19,52 @@ from multiprocessing.connection import Listener
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
|
||||
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
|
||||
from browser_cli.registry import update_registry
|
||||
|
||||
SOCKET_PATH: str = "" # set after hello handshake
|
||||
PENDING: dict[str, queue.Queue] = {}
|
||||
PENDING_LOCK = threading.Lock()
|
||||
WRITE_LOCK = threading.Lock()
|
||||
REGISTRY_PATH = registry_path()
|
||||
PAGE_SIZE = int(os.environ.get("BROWSER_CLI_PAGE_SIZE", "100"))
|
||||
PAGEABLE_COMMANDS = {
|
||||
"tabs.list",
|
||||
"tabs.filter",
|
||||
"tabs.query",
|
||||
"group.list",
|
||||
"group.tabs",
|
||||
"group.query",
|
||||
"windows.list",
|
||||
"dom.query",
|
||||
"dom.text",
|
||||
"dom.attr",
|
||||
"extract.links",
|
||||
"extract.images",
|
||||
"extract.json",
|
||||
"cookies.list",
|
||||
"session.list",
|
||||
}
|
||||
|
||||
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
|
||||
|
||||
def _read_exact_stream(stream, n: int) -> bytes | None:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = stream.read(n - len(buf))
|
||||
if not chunk:
|
||||
return None # real EOF
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def read_native_message(stream) -> dict | None:
|
||||
raw_len = stream.read(4)
|
||||
if len(raw_len) < 4:
|
||||
raw_len = _read_exact_stream(stream, 4)
|
||||
if raw_len is None:
|
||||
return None
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
data = stream.read(msg_len)
|
||||
if len(data) < msg_len:
|
||||
data = _read_exact_stream(stream, msg_len)
|
||||
if data is None:
|
||||
return None
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
@@ -48,20 +80,14 @@ def write_native_message(stream, msg: dict) -> None:
|
||||
|
||||
def _registry_add(alias: str, sock_path: str) -> None:
|
||||
try:
|
||||
reg = json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {}
|
||||
reg[alias] = sock_path
|
||||
REGISTRY_PATH.write_text(json.dumps(reg))
|
||||
update_registry(alias, sock_path, REGISTRY_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _registry_remove(alias: str) -> None:
|
||||
try:
|
||||
if not REGISTRY_PATH.exists():
|
||||
return
|
||||
reg = json.loads(REGISTRY_PATH.read_text())
|
||||
reg.pop(alias, None)
|
||||
REGISTRY_PATH.write_text(json.dumps(reg))
|
||||
update_registry(alias, None, REGISTRY_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -107,24 +133,32 @@ def stdin_reader(alias: str):
|
||||
|
||||
# --- Thread B: accept CLI socket connections ---
|
||||
|
||||
def socket_server(sock_path: str):
|
||||
def socket_server(sock_path: str, bound_sock: "socket.socket | None" = None):
|
||||
if is_windows():
|
||||
while True:
|
||||
listener = None
|
||||
try:
|
||||
listener = Listener(sock_path, family="AF_PIPE")
|
||||
conn = listener.accept()
|
||||
except OSError:
|
||||
if listener is not None:
|
||||
try:
|
||||
listener.close()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
|
||||
return
|
||||
|
||||
path = Path(sock_path)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(sock_path)
|
||||
sock.listen(16)
|
||||
sock = bound_sock
|
||||
if sock is None:
|
||||
path = Path(sock_path)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(sock_path)
|
||||
os.chmod(sock_path, 0o600)
|
||||
sock.listen(16)
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -143,21 +177,7 @@ def handle_cli_connection(conn, listener=None) -> None:
|
||||
if "id" not in cmd:
|
||||
cmd["id"] = str(uuid.uuid4())
|
||||
|
||||
msg_id = cmd["id"]
|
||||
response_queue: queue.Queue = queue.Queue()
|
||||
|
||||
with PENDING_LOCK:
|
||||
PENDING[msg_id] = response_queue
|
||||
|
||||
write_native_message(sys.stdout.buffer, cmd)
|
||||
|
||||
try:
|
||||
result = response_queue.get(timeout=30)
|
||||
except queue.Empty:
|
||||
result = {"id": msg_id, "success": False, "error": "timeout waiting for browser response"}
|
||||
|
||||
with PENDING_LOCK:
|
||||
PENDING.pop(msg_id, None)
|
||||
result = _handle_browser_command(cmd)
|
||||
|
||||
response = json.dumps(result).encode("utf-8")
|
||||
if is_windows():
|
||||
@@ -179,6 +199,74 @@ def handle_cli_connection(conn, listener=None) -> None:
|
||||
listener.close()
|
||||
|
||||
|
||||
def _handle_browser_command(cmd: dict) -> dict:
|
||||
command = cmd.get("command")
|
||||
if command in PAGEABLE_COMMANDS:
|
||||
return _collect_paged_browser_command(cmd)
|
||||
return _send_browser_command(cmd)
|
||||
|
||||
|
||||
def _send_browser_command(cmd: dict, timeout: int = 30) -> dict:
|
||||
msg_id = cmd.get("id") or str(uuid.uuid4())
|
||||
cmd["id"] = msg_id
|
||||
response_queue: queue.Queue = queue.Queue()
|
||||
|
||||
with PENDING_LOCK:
|
||||
PENDING[msg_id] = response_queue
|
||||
|
||||
try:
|
||||
with WRITE_LOCK:
|
||||
write_native_message(sys.stdout.buffer, cmd)
|
||||
|
||||
try:
|
||||
return response_queue.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return {"id": msg_id, "success": False, "error": "timeout waiting for browser response"}
|
||||
finally:
|
||||
with PENDING_LOCK:
|
||||
PENDING.pop(msg_id, None)
|
||||
|
||||
|
||||
def _collect_paged_browser_command(cmd: dict) -> dict:
|
||||
original_id = cmd.get("id") or str(uuid.uuid4())
|
||||
offset = 0
|
||||
items = []
|
||||
total = None
|
||||
max_pages = math.ceil(10_000 / PAGE_SIZE)
|
||||
pages_fetched = 0
|
||||
|
||||
while True:
|
||||
if pages_fetched >= max_pages:
|
||||
return {"id": original_id, "success": False, "error": f"paging loop exceeded {max_pages} pages — extension bug?"}
|
||||
pages_fetched += 1
|
||||
page_cmd = dict(cmd)
|
||||
page_cmd["id"] = str(uuid.uuid4())
|
||||
page_args = dict(cmd.get("args") or {})
|
||||
page_args["__page"] = {"offset": offset, "limit": PAGE_SIZE}
|
||||
page_cmd["args"] = page_args
|
||||
|
||||
result = _send_browser_command(page_cmd)
|
||||
result["id"] = original_id
|
||||
if not result.get("success", True):
|
||||
return result
|
||||
|
||||
data = result.get("data")
|
||||
if not isinstance(data, dict) or data.get("__browserCliPage") is not True:
|
||||
return result
|
||||
|
||||
page_items = data.get("items") or []
|
||||
if not isinstance(page_items, list):
|
||||
return {"id": original_id, "success": False, "error": "invalid paged response from browser"}
|
||||
items.extend(page_items)
|
||||
total = data.get("total", total)
|
||||
next_offset = data.get("nextOffset")
|
||||
if next_offset is None:
|
||||
break
|
||||
offset = int(next_offset)
|
||||
|
||||
return {"id": original_id, "success": True, "data": items, "pageSize": PAGE_SIZE, "total": total}
|
||||
|
||||
|
||||
# --- Socket helpers (length-prefixed framing) ---
|
||||
|
||||
def _send_all(conn: socket.socket, data: bytes) -> None:
|
||||
@@ -191,6 +279,8 @@ def _recv_all(conn: socket.socket) -> bytes | None:
|
||||
if raw_len is None:
|
||||
return None
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
if msg_len > _MAX_MSG_BYTES:
|
||||
return None
|
||||
return _recv_exact(conn, msg_len)
|
||||
|
||||
|
||||
@@ -221,21 +311,26 @@ def main():
|
||||
if first_msg and first_msg.get("type") == "hello":
|
||||
alias = _resolve_profile_alias(first_msg)
|
||||
else:
|
||||
# No hello — use a generated alias and re-queue the first command if needed.
|
||||
# No hello — use a generated alias; first_msg is dropped (no response path).
|
||||
alias = str(uuid.uuid4())
|
||||
if first_msg:
|
||||
msg_id = first_msg.get("id")
|
||||
if msg_id:
|
||||
q: queue.Queue = queue.Queue()
|
||||
with PENDING_LOCK:
|
||||
PENDING[msg_id] = q
|
||||
write_native_message(sys.stdout.buffer, first_msg)
|
||||
|
||||
runtime_dir().mkdir(mode=0o700, exist_ok=True)
|
||||
sock_path = _socket_path_for(alias)
|
||||
|
||||
if not is_windows():
|
||||
path = Path(sock_path)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
bound_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
bound_sock.bind(sock_path)
|
||||
os.chmod(sock_path, 0o600)
|
||||
bound_sock.listen(16)
|
||||
else:
|
||||
bound_sock = None
|
||||
|
||||
_registry_add(alias, sock_path)
|
||||
|
||||
t = threading.Thread(target=socket_server, args=(sock_path,), daemon=True)
|
||||
t = threading.Thread(target=socket_server, args=(sock_path,), kwargs={"bound_sock": bound_sock}, daemon=True)
|
||||
t.start()
|
||||
|
||||
stdin_reader(alias)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Runtime registry helpers for active browser-cli native host endpoints."""
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from browser_cli.platform import registry_path
|
||||
|
||||
REGISTRY_PATH = registry_path()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _file_lock(path: Path) -> Iterator[None]:
|
||||
"""Best-effort cross-process lock for registry read/modify/write updates."""
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
lock_path = path.with_suffix(path.suffix + ".lock")
|
||||
with lock_path.open("a+") as lock_file:
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import msvcrt
|
||||
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _coerce_registry(data) -> dict[str, str]:
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {str(alias): str(endpoint) for alias, endpoint in data.items() if alias and endpoint}
|
||||
|
||||
def load_registry(path: Path | None = None) -> dict[str, str]:
|
||||
"""Load the active browser registry.
|
||||
|
||||
Older native hosts wrote this file non-atomically, so tolerate trailing
|
||||
garbage from interrupted/concurrent writes and keep the first valid JSON
|
||||
object when possible.
|
||||
"""
|
||||
registry = path or REGISTRY_PATH
|
||||
if not registry.exists():
|
||||
return {}
|
||||
try:
|
||||
text = registry.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return {}
|
||||
if not text.strip():
|
||||
return {}
|
||||
try:
|
||||
return _coerce_registry(json.loads(text))
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
data, _ = json.JSONDecoder().raw_decode(text)
|
||||
return _coerce_registry(data)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
def save_registry(data: dict[str, str], path: Path | None = None) -> None:
|
||||
"""Atomically write the active browser registry."""
|
||||
registry = path or REGISTRY_PATH
|
||||
registry.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
payload = json.dumps(_coerce_registry(data), sort_keys=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=registry.name + ".", suffix=".tmp", dir=registry.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
|
||||
tmp.write(payload)
|
||||
tmp.flush()
|
||||
os.fsync(tmp.fileno())
|
||||
os.replace(tmp_name, registry)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def update_registry(alias: str, endpoint: str | None, path: Path | None = None) -> None:
|
||||
"""Add/update an alias, or remove it when endpoint is None."""
|
||||
registry = path or REGISTRY_PATH
|
||||
with _file_lock(registry):
|
||||
data = load_registry(registry)
|
||||
if endpoint is None:
|
||||
data.pop(alias, None)
|
||||
else:
|
||||
data[alias] = endpoint
|
||||
save_registry(data, registry)
|
||||
@@ -0,0 +1,18 @@
|
||||
from importlib.metadata import version as _pkg_version
|
||||
|
||||
PROTOCOL_MIN_CLIENT = "0.9.0"
|
||||
MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||
|
||||
|
||||
def parse_version(v: str) -> tuple[int, ...]:
|
||||
try:
|
||||
return tuple(int(x) for x in v.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0,)
|
||||
|
||||
|
||||
def get_installed_version() -> str:
|
||||
try:
|
||||
return _pkg_version("browser-cli")
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "com.browsercli.host",
|
||||
"description": "browser-cli native messaging host",
|
||||
"path": "/REPLACE_WITH_ABSOLUTE_PATH/browser-cli/libexec/native-host",
|
||||
"type": "stdio",
|
||||
"allowed_origins": [
|
||||
"chrome-extension://REPLACE_WITH_EXTENSION_ID/"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.5.12",
|
||||
"version": "0.9.8",
|
||||
"description": "Control your browser from the terminal via browser-cli",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
@@ -10,9 +10,12 @@
|
||||
"windows",
|
||||
"storage",
|
||||
"alarms",
|
||||
"nativeMessaging"
|
||||
"nativeMessaging",
|
||||
"cookies"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
@@ -28,5 +31,6 @@
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlfCvygCocGbU2Bm2Rg6cnvHN0Lt25gJGJ/XX7VuAccrp4dH+Whj3Fw2vYSjgx90wuWuMl5fsWSsSX9H1k1vp7ImGzszCDnScn+o+KRWrVCQVRD1NEaKavuHoaHyc3Hs+njrM8c7c6u2ygdItZkggwPU0U1dKkixP/DWR9oG13Gr4u39p/xHxITiBh0DROYdoKBzw/J+vT7zWITKyG7QBgLMuoaYc15oqRIm7raBW1GIn1A5V2WPpBM9rMAli4vCyc9rbqsUqO1Yu4SrNIoG+wfz3MED3ajylDH6Jh1bsf1l5EZNDR/EpqBsSQcEV0VXX7nkqchqgzh3bgT9psiUQAQIDAQAB"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, isScriptableUrl } from '../core';
|
||||
export async function storageGet({ key, type = "local", tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (k, t) => {
|
||||
const store = t === "session" ? sessionStorage : localStorage;
|
||||
if (k) return store.getItem(k);
|
||||
return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)]));
|
||||
},
|
||||
args: [key || null, type],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
}
|
||||
|
||||
export async function storageSet({ key, value, type = "local", tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (k, v, t) => {
|
||||
const store = t === "session" ? sessionStorage : localStorage;
|
||||
store.setItem(k, typeof v === "string" ? v : JSON.stringify(v));
|
||||
return true;
|
||||
},
|
||||
args: [key, value, type],
|
||||
});
|
||||
return results[0]?.result ?? false;
|
||||
}
|
||||
|
||||
export async function cookiesList({ url, domain, name } = {}) {
|
||||
const details = {};
|
||||
if (url) details.url = url;
|
||||
if (domain) details.domain = domain;
|
||||
if (name) details.name = name;
|
||||
return await chrome.cookies.getAll(details);
|
||||
}
|
||||
|
||||
export async function cookiesGet({ url, name }) {
|
||||
return await chrome.cookies.get({ url, name });
|
||||
}
|
||||
|
||||
export async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) {
|
||||
const details = { url, name, value };
|
||||
if (domain != null) details.domain = domain;
|
||||
if (path != null) details.path = path;
|
||||
if (secure != null) details.secure = secure;
|
||||
if (httpOnly != null) details.httpOnly = httpOnly;
|
||||
if (expirationDate != null) details.expirationDate = expirationDate;
|
||||
if (sameSite != null) details.sameSite = sameSite;
|
||||
return await chrome.cookies.set(details);
|
||||
}
|
||||
|
||||
// This function is serialized and injected into the page by chrome.scripting
|
||||
@@ -0,0 +1,140 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl } from '../core';
|
||||
import { contentDispatch } from './injected';
|
||||
|
||||
function fallbackForErrorPageDomOp(funcName, tab) {
|
||||
switch (funcName) {
|
||||
case "domExists":
|
||||
return false;
|
||||
case "domQuery":
|
||||
case "domAttr":
|
||||
case "domText":
|
||||
case "extractLinks":
|
||||
case "extractImages":
|
||||
return [];
|
||||
case "extractText":
|
||||
case "extractMarkdown":
|
||||
return "";
|
||||
case "pageInfo":
|
||||
return {
|
||||
title: tab.title || "",
|
||||
url: tab.url || tab.pendingUrl || "",
|
||||
readyState: "error",
|
||||
lang: null,
|
||||
meta: {},
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function domOp(funcName, args = {}) {
|
||||
const tab = args?.tabId ? await chrome.tabs.get(args.tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
const fallback = fallbackForErrorPageDomOp(funcName, tab);
|
||||
if (fallback !== undefined) return fallback;
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: contentDispatch,
|
||||
args: [funcName, args],
|
||||
});
|
||||
return results[0]?.result;
|
||||
} catch (e) {
|
||||
if (isErrorPageScriptError(e)) {
|
||||
const fallback = fallbackForErrorPageDomOp(funcName, tab);
|
||||
if (fallback !== undefined) return fallback;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function domEval({ code, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (!isScriptableUrl(tabUrl) || isBrowserErrorUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (c) => (0, eval)(c),
|
||||
args: [code],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
}
|
||||
|
||||
export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
if (hidden) return { selector, found: false };
|
||||
throw new Error(`Cannot wait for DOM on browser error page ${tabUrl}`);
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, vis, hid) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (hid) return !el || el.offsetParent === null;
|
||||
if (!el) return false;
|
||||
if (vis) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
args: [selector, visible, hidden],
|
||||
});
|
||||
if (results[0]?.result) return { selector, found: !hidden };
|
||||
} catch (e) {
|
||||
if (hidden && isErrorPageScriptError(e)) return { selector, found: false };
|
||||
if (!isErrorPageScriptError(e)) throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
throw new Error(`Cannot poll DOM on browser error page ${tabUrl}`);
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, a) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
if (a) return el.getAttribute(a) ?? el[a] ?? null;
|
||||
return el.value !== undefined ? el.value : el.textContent.trim();
|
||||
},
|
||||
args: [selector, attr || null],
|
||||
});
|
||||
const value = results[0]?.result;
|
||||
if (value != null && regex.test(String(value))) return { selector, value, pattern };
|
||||
} catch (e) {
|
||||
if (!isErrorPageScriptError(e)) throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @ts-nocheck
|
||||
import { buildTabBlocks, resolveGroupId, tabInfo } from '../core';
|
||||
export async function groupList() {
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const all = await chrome.tabs.query({});
|
||||
return groups.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
color: g.color,
|
||||
collapsed: g.collapsed,
|
||||
windowId: g.windowId,
|
||||
tabCount: all.filter(t => t.groupId === g.id).length,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function groupTabs({ groupId }) {
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t => t.groupId === groupId).map(tabInfo);
|
||||
}
|
||||
|
||||
export async function groupCount() {
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
export async function groupQuery({ search }) {
|
||||
const q = search.toLowerCase();
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
export async function groupClose({ groupId }) {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groupTabs = tabs.filter(t => t.groupId === groupId);
|
||||
await chrome.tabs.ungroup(groupTabs.map(t => t.id));
|
||||
return { groupId };
|
||||
}
|
||||
|
||||
export async function groupOpen({ name }) {
|
||||
const tab = await chrome.tabs.create({ active: true });
|
||||
const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
||||
await chrome.tabGroups.update(groupId, { title: name });
|
||||
return { id: groupId, name };
|
||||
}
|
||||
|
||||
export async function groupAddTab({ group, url }) {
|
||||
const groupId = await resolveGroupId(group);
|
||||
const existingTabs = await chrome.tabs.query({ groupId });
|
||||
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
|
||||
await chrome.tabs.group({ tabIds: [tab.id], groupId });
|
||||
// If a URL was provided, close any blank placeholder tabs left from group creation
|
||||
if (url) {
|
||||
const placeholders = existingTabs.filter(t =>
|
||||
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
|
||||
);
|
||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
||||
}
|
||||
return { tabId: tab.id, groupId };
|
||||
}
|
||||
|
||||
export async function groupMove({ group, forward, backward }) {
|
||||
const groupId = await resolveGroupId(group);
|
||||
const groupInfo = await chrome.tabGroups.get(groupId);
|
||||
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
|
||||
allTabs.sort((a, b) => a.index - b.index);
|
||||
|
||||
const blocks = buildTabBlocks(allTabs);
|
||||
const currentIdx = blocks.findIndex(block => block.groupId === groupId);
|
||||
if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`);
|
||||
|
||||
const currentBlock = blocks[currentIdx];
|
||||
const currentLength = currentBlock.tabIds.length;
|
||||
|
||||
if (forward) {
|
||||
const nextBlock = blocks[currentIdx + 1];
|
||||
if (!nextBlock) return { groupId, moved: false };
|
||||
const targetIndex =
|
||||
nextBlock.groupId === null
|
||||
? currentBlock.startIndex + 1
|
||||
: nextBlock.endIndex - currentLength + 1;
|
||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
||||
} else if (backward) {
|
||||
const previousBlock = blocks[currentIdx - 1];
|
||||
if (!previousBlock) return { groupId, moved: false };
|
||||
const targetIndex =
|
||||
previousBlock.groupId === null
|
||||
? currentBlock.startIndex - 1
|
||||
: previousBlock.startIndex;
|
||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
||||
}
|
||||
|
||||
return { groupId, moved: true };
|
||||
}
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,560 @@
|
||||
// @ts-nocheck
|
||||
export function contentDispatch(funcName, args) {
|
||||
function domQuery({ selector }) {
|
||||
return Array.from(document.querySelectorAll(selector)).map(el => ({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: el.textContent.trim().slice(0, 200),
|
||||
attrs: Object.fromEntries(Array.from(el.attributes).map(a => [a.name, a.value])),
|
||||
}));
|
||||
}
|
||||
function domClick({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.click();
|
||||
return true;
|
||||
}
|
||||
function domType({ selector, text }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.focus();
|
||||
el.value = text;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domAttr({ selector, attr }) {
|
||||
return Array.from(document.querySelectorAll(selector))
|
||||
.map(el => el.getAttribute(attr))
|
||||
.filter(v => v !== null);
|
||||
}
|
||||
function domText({ selector }) {
|
||||
return Array.from(document.querySelectorAll(selector))
|
||||
.map(el => el.textContent.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
function domExists({ selector }) {
|
||||
return document.querySelector(selector) !== null;
|
||||
}
|
||||
function domKey({ selector, key }) {
|
||||
const el = selector ? document.querySelector(selector) : document.activeElement;
|
||||
if (selector && !el) throw new Error(`No element: ${selector}`);
|
||||
const target = el || document.body;
|
||||
["keydown", "keypress", "keyup"].forEach(type => {
|
||||
target.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
function domHover({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domCheck({ selector, checked }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.checked = checked;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domClear({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.value = "";
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domFocus({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
function domSubmit({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
const form = el.tagName === "FORM" ? el : el.closest("form");
|
||||
if (!form) throw new Error(`No form found for: ${selector}`);
|
||||
form.submit();
|
||||
return true;
|
||||
}
|
||||
function pageInfo() {
|
||||
const metas = {};
|
||||
document.querySelectorAll("meta[name], meta[property]").forEach(m => {
|
||||
const k = m.getAttribute("name") || m.getAttribute("property");
|
||||
if (k) metas[k] = m.getAttribute("content") || "";
|
||||
});
|
||||
return {
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
lang: document.documentElement.lang || null,
|
||||
meta: metas,
|
||||
};
|
||||
}
|
||||
function domScroll({ selector, x, y }) {
|
||||
if (selector) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return true;
|
||||
}
|
||||
window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" });
|
||||
return true;
|
||||
}
|
||||
function domSelect({ selector, value }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function extractLinks() {
|
||||
const seen = new Set();
|
||||
return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => {
|
||||
const href = a.href;
|
||||
if (!href || seen.has(href)) return links;
|
||||
seen.add(href);
|
||||
links.push({
|
||||
text: a.textContent.trim().slice(0, 100),
|
||||
href,
|
||||
});
|
||||
return links;
|
||||
}, []);
|
||||
}
|
||||
function extractImages() {
|
||||
const seen = new Set();
|
||||
return Array.from(document.querySelectorAll("img")).reduce((images, img) => {
|
||||
const src =
|
||||
img.src ||
|
||||
img.getAttribute("data-src") ||
|
||||
img.getAttribute("data-lazy-src") ||
|
||||
img.getAttribute("data-original") ||
|
||||
(img.srcset ? img.srcset.split(",")[0].trim().split(" ")[0] : "") ||
|
||||
"";
|
||||
if (!src || seen.has(src)) return images;
|
||||
seen.add(src);
|
||||
const FAKE_ALT = new Set(["true", "false", "null", "undefined", "image", "img"]);
|
||||
const alt = img.alt && !FAKE_ALT.has(img.alt.trim().toLowerCase()) ? img.alt.trim() : "";
|
||||
images.push({ alt, src });
|
||||
return images;
|
||||
}, []);
|
||||
}
|
||||
function extractText() {
|
||||
return document.body.innerText;
|
||||
}
|
||||
function extractJson({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
return JSON.parse(el.textContent);
|
||||
}
|
||||
function extractMarkdown({ selector }) {
|
||||
const BLOCKS = new Set([
|
||||
"article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption",
|
||||
"figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr",
|
||||
"li", "main", "nav", "ol", "p", "pre", "section", "table", "tbody", "td", "tfoot",
|
||||
"th", "thead", "tr", "ul"
|
||||
]);
|
||||
const NOISE_SELECTOR = [
|
||||
"script",
|
||||
"style",
|
||||
"noscript",
|
||||
"template",
|
||||
"svg",
|
||||
"canvas",
|
||||
"iframe",
|
||||
"dialog",
|
||||
"button",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"option",
|
||||
"form",
|
||||
"[hidden]",
|
||||
"[aria-hidden='true']",
|
||||
".sr-only",
|
||||
"[class*='sr-only']",
|
||||
"[class*='file-tile']",
|
||||
"form[data-type='unified-composer']",
|
||||
".composer-btn",
|
||||
"[data-composer-surface='true']",
|
||||
"#thread-bottom-container",
|
||||
"[data-testid*='action-button']",
|
||||
].join(", ");
|
||||
|
||||
function normalizeText(value) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeInline(value) {
|
||||
return value
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n[ \t]+/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function collapseBlankLines(value) {
|
||||
return value
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeMarkdown(text) {
|
||||
return text.replace(/([\\`[\]])/g, "\\$1");
|
||||
}
|
||||
|
||||
function escapeTableCell(text) {
|
||||
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
||||
}
|
||||
|
||||
function absoluteUrl(attr, fallback) {
|
||||
return attr || fallback || "";
|
||||
}
|
||||
|
||||
function isNoiseElement(node) {
|
||||
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true;
|
||||
if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true;
|
||||
if (node.hasAttribute("hidden")) return true;
|
||||
if ((node.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
|
||||
if (node.matches(".sr-only, [class*='sr-only']")) return true;
|
||||
if (node.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
|
||||
if (node.matches("[data-testid*='action-button']")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripNoise(root) {
|
||||
const clone = root.cloneNode(true);
|
||||
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
|
||||
return clone;
|
||||
}
|
||||
|
||||
function candidateScore(node) {
|
||||
const text = normalizeText(node.innerText || "");
|
||||
if (!text) return -Infinity;
|
||||
|
||||
const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length;
|
||||
const paragraphs = node.querySelectorAll("p").length;
|
||||
const listItems = node.querySelectorAll("li").length;
|
||||
const tables = node.querySelectorAll("table").length;
|
||||
const codeBlocks = node.querySelectorAll("pre, code").length;
|
||||
const images = node.querySelectorAll("img, figure").length;
|
||||
const mainLike = node.matches("main, article, [role='main']") ? 1 : 0;
|
||||
const proseBlocks = node.matches(".markdown, .prose, [data-message-author-role='assistant']") ? 1 : 0;
|
||||
const buttons = node.querySelectorAll("button, input, textarea, select").length;
|
||||
const forms = node.querySelectorAll("form").length;
|
||||
const svgs = node.querySelectorAll("svg, canvas").length;
|
||||
|
||||
return text.length
|
||||
+ (mainLike * 4000)
|
||||
+ (proseBlocks * 5000)
|
||||
+ (headings * 250)
|
||||
+ (paragraphs * 60)
|
||||
+ (listItems * 35)
|
||||
+ (tables * 80)
|
||||
+ (codeBlocks * 60)
|
||||
+ (images * 25)
|
||||
- (buttons * 120)
|
||||
- (forms * 200)
|
||||
- (svgs * 40);
|
||||
}
|
||||
|
||||
function pickRoot() {
|
||||
if (selector) {
|
||||
const matched = document.querySelector(selector);
|
||||
if (!matched) throw new Error(`No element: ${selector}`);
|
||||
return matched;
|
||||
}
|
||||
|
||||
const candidates = Array.from(document.querySelectorAll(
|
||||
"main, article, [role='main'], section, .markdown, .prose, [data-message-author-role]"
|
||||
))
|
||||
.filter(node => normalizeText(node.innerText || "").length > 0);
|
||||
if (!candidates.length) return document.body;
|
||||
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function inlineText(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeMarkdown(node.textContent || "");
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
if (isNoiseElement(node)) return "";
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (tag === "br") return "\n";
|
||||
if (tag === "img") {
|
||||
const src = absoluteUrl(node.getAttribute("src"), node.src);
|
||||
if (!src) return "";
|
||||
const alt = normalizeText(node.getAttribute("alt") || "");
|
||||
return alt ? `` : ``;
|
||||
}
|
||||
if (tag === "a") {
|
||||
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
||||
const href = absoluteUrl(node.getAttribute("href"), node.href);
|
||||
if (!href) return text;
|
||||
return `[${text || href}](${href})`;
|
||||
}
|
||||
if (tag === "code") {
|
||||
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
||||
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
|
||||
}
|
||||
if (tag === "strong" || tag === "b") {
|
||||
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
||||
return text ? `**${text}**` : "";
|
||||
}
|
||||
if (tag === "em" || tag === "i") {
|
||||
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
||||
return text ? `*${text}*` : "";
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for (const child of node.childNodes) {
|
||||
const rendered = inlineText(child);
|
||||
if (!rendered) continue;
|
||||
chunks.push(rendered);
|
||||
if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has(child.tagName.toLowerCase())) {
|
||||
chunks.push("\n");
|
||||
}
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function textBlock(node) {
|
||||
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
|
||||
}
|
||||
|
||||
function preserveNodeText(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || "";
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (tag === "br") return "\n";
|
||||
|
||||
const parts = [];
|
||||
for (const child of node.childNodes) {
|
||||
const rendered = preserveNodeText(child);
|
||||
if (!rendered) continue;
|
||||
parts.push(rendered);
|
||||
}
|
||||
|
||||
if (["div", "p", "li"].includes(tag)) {
|
||||
return `${parts.join("")}\n`;
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function repairFlattenedDiagram(text) {
|
||||
if (text.includes("\n")) return text;
|
||||
const markerCount = (text.match(/[│▼├└]/g) || []).length;
|
||||
if (markerCount < 2) return text;
|
||||
|
||||
let repaired = text;
|
||||
repaired = repaired.replace(/\s{2,}([│▼])/g, "\n $1");
|
||||
repaired = repaired.replace(/([│▼])\s{2,}/g, "$1\n");
|
||||
repaired = repaired.replace(/([│▼])(?=[^\s\n│▼├└])/g, "$1\n");
|
||||
repaired = repaired.replace(/(?<=[^\s\n])([├└])/g, "\n$1");
|
||||
repaired = repaired.replace(/([^\s\n])(\()/g, "$1\n$2");
|
||||
return repaired
|
||||
.split("\n")
|
||||
.map(line => line.replace(/\s+$/, ""))
|
||||
.filter(line => line.trim())
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function convertDashListsToBranches(lines) {
|
||||
const converted = [];
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const match = lines[index].match(/^(\s*)-\s+(.*)$/);
|
||||
if (!match) {
|
||||
converted.push(lines[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const indent = match[1];
|
||||
const items = [];
|
||||
while (index < lines.length) {
|
||||
const nextMatch = lines[index].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\s+(.*)$`));
|
||||
if (!nextMatch) break;
|
||||
items.push(nextMatch[1]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
items.forEach((item, itemIndex) => {
|
||||
const branch = itemIndex === items.length - 1 ? "└" : "├";
|
||||
converted.push(`${indent}${branch} ${item}`);
|
||||
});
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
function normalizeCodeBlock(text) {
|
||||
let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, ""));
|
||||
while (lines.length && !lines[0].trim()) lines.shift();
|
||||
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
||||
|
||||
const flattened = repairFlattenedDiagram(lines.join("\n"));
|
||||
lines = flattened ? flattened.split("\n") : [];
|
||||
lines = lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if ((trimmed === "│" || trimmed === "▼") && !/^\s+[│▼]\s*$/.test(line)) {
|
||||
return ` ${trimmed}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
lines = convertDashListsToBranches(lines);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function tableToMarkdown(table) {
|
||||
const rows = Array.from(table.querySelectorAll("tr"))
|
||||
.map(row => Array.from(row.children)
|
||||
.filter(cell => cell.tagName === "TD" || cell.tagName === "TH")
|
||||
.map(cell => escapeTableCell(textBlock(cell)))
|
||||
)
|
||||
.filter(cells => cells.length > 0);
|
||||
if (!rows.length) return "";
|
||||
|
||||
const widths = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
const normalizedRows = rows.map(row => {
|
||||
const next = row.slice();
|
||||
while (next.length < widths) next.push("");
|
||||
return next;
|
||||
});
|
||||
|
||||
let headers = normalizedRows[0];
|
||||
let bodyRows = normalizedRows.slice(1);
|
||||
const firstRowIsBlank = headers.every(cell => !cell.trim());
|
||||
if (firstRowIsBlank && normalizedRows.length > 1) {
|
||||
headers = normalizedRows[1];
|
||||
bodyRows = normalizedRows.slice(2);
|
||||
}
|
||||
|
||||
const firstRow = table.querySelector("tr");
|
||||
const thead = table.querySelector("thead");
|
||||
const firstRowHasTh = firstRow && Array.from(firstRow.children).some(cell => cell.tagName === "TH");
|
||||
if (!(thead || firstRowHasTh || firstRowIsBlank)) {
|
||||
headers = new Array(widths).fill("");
|
||||
bodyRows = normalizedRows;
|
||||
}
|
||||
|
||||
const separator = new Array(widths).fill("---");
|
||||
const lines = [
|
||||
`| ${headers.join(" | ")} |`,
|
||||
`| ${separator.join(" | ")} |`,
|
||||
];
|
||||
for (const row of bodyRows) {
|
||||
lines.push(`| ${row.join(" | ")} |`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function listToMarkdown(list, depth = 0) {
|
||||
const ordered = list.tagName.toLowerCase() === "ol";
|
||||
const items = [];
|
||||
const children = Array.from(list.children).filter(child => child.tagName === "LI");
|
||||
children.forEach((item, index) => {
|
||||
const marker = ordered ? `${index + 1}. ` : "- ";
|
||||
const indent = " ".repeat(depth);
|
||||
const nested = [];
|
||||
const content = [];
|
||||
|
||||
for (const child of item.childNodes) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (child.tagName === "UL" || child.tagName === "OL")) {
|
||||
nested.push(listToMarkdown(child, depth + 1));
|
||||
} else {
|
||||
content.push(inlineText(child));
|
||||
}
|
||||
}
|
||||
|
||||
const line = collapseBlankLines(normalizeInline(content.join("")));
|
||||
if (line) {
|
||||
const lineParts = line.split("\n");
|
||||
items.push(`${indent}${marker}${lineParts[0]}`);
|
||||
const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
|
||||
lineParts.slice(1).forEach(part => items.push(`${continuationIndent}${part}`));
|
||||
}
|
||||
nested.filter(Boolean).forEach(block => items.push(block));
|
||||
});
|
||||
return items.join("\n");
|
||||
}
|
||||
|
||||
function blockToMarkdown(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeText(node.textContent || "");
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
if (isNoiseElement(node)) return "";
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (tag === "table") return tableToMarkdown(node);
|
||||
if (tag === "ul" || tag === "ol") return listToMarkdown(node);
|
||||
if (node.matches(".cm-editor[data-is-code-block-view='true']")) {
|
||||
const lines = Array.from(node.querySelectorAll(".cm-line")).map(line => {
|
||||
const text = preserveNodeText(line);
|
||||
return text === "\n" ? "" : text.replace(/\n$/, "");
|
||||
});
|
||||
const code = normalizeCodeBlock(lines.join("\n"));
|
||||
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
||||
}
|
||||
if (tag === "pre") {
|
||||
const code = normalizeCodeBlock(preserveNodeText(node));
|
||||
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
||||
}
|
||||
if (tag === "blockquote") {
|
||||
const content = collapseBlankLines(Array.from(node.childNodes).map(blockToMarkdown).join("\n\n"));
|
||||
return content
|
||||
.split("\n")
|
||||
.map(line => line ? `> ${line}` : ">")
|
||||
.join("\n");
|
||||
}
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
const level = Number(tag.slice(1));
|
||||
const text = textBlock(node);
|
||||
return text ? `${"#".repeat(level)} ${text}` : "";
|
||||
}
|
||||
if (tag === "p" || tag === "figcaption") {
|
||||
return textBlock(node);
|
||||
}
|
||||
if (tag === "hr") {
|
||||
return "---";
|
||||
}
|
||||
if (tag === "img") {
|
||||
return inlineText(node);
|
||||
}
|
||||
|
||||
const childBlocks = Array.from(node.childNodes)
|
||||
.map(child => blockToMarkdown(child))
|
||||
.filter(Boolean);
|
||||
if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n"));
|
||||
|
||||
return textBlock(node);
|
||||
}
|
||||
|
||||
const root = stripNoise(pickRoot());
|
||||
const markdown = blockToMarkdown(root);
|
||||
return collapseBlankLines(markdown);
|
||||
}
|
||||
|
||||
const fns = { domQuery, domClick, domType, domAttr, domText, domExists,
|
||||
domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit,
|
||||
pageInfo,
|
||||
extractLinks, extractImages, extractText, extractJson, extractMarkdown };
|
||||
const fn = fns[funcName];
|
||||
if (!fn) throw new Error(`Unknown content function: ${funcName}`);
|
||||
return fn(args);
|
||||
}
|
||||
|
||||
// ── Session ───────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,97 @@
|
||||
// @ts-nocheck
|
||||
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core';
|
||||
export async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) {
|
||||
let windowId;
|
||||
if (explicitWindowId != null) {
|
||||
windowId = explicitWindowId;
|
||||
} else if (windowName) {
|
||||
const aliases = await getAliases();
|
||||
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
|
||||
if (entry) windowId = parseInt(entry[0]);
|
||||
}
|
||||
const tab = await chrome.tabs.create({ url, active: !background, windowId });
|
||||
if (groupNameOrId != null) {
|
||||
let groupId;
|
||||
try {
|
||||
groupId = await resolveGroupId(groupNameOrId);
|
||||
// Close any blank placeholder tabs that were created when the group was made
|
||||
const groupTabs = await chrome.tabs.query({ groupId });
|
||||
const placeholders = groupTabs.filter(t =>
|
||||
t.id !== tab.id &&
|
||||
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
|
||||
);
|
||||
await chrome.tabs.group({ tabIds: [tab.id], groupId });
|
||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
||||
} catch (e) {
|
||||
if (!e.message.startsWith("No tab group found")) throw e;
|
||||
// Group doesn't exist — create it with the tab already in it
|
||||
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
||||
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
|
||||
}
|
||||
}
|
||||
return { id: tab.id, url: tab.url };
|
||||
}
|
||||
|
||||
export async function navTo({ tabId, url }) {
|
||||
const tab = await chrome.tabs.update(tabId, { url });
|
||||
return { id: tab.id, url: tab.url || url };
|
||||
}
|
||||
|
||||
export async function navReload({ tabId }, bypassCache) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.reload(tab.id, { bypassCache });
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
export async function navBack({ tabId }) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.goBack(tab.id);
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
export async function navForward({ tabId }) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.goForward(tab.id);
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
export async function navFocus({ pattern }) {
|
||||
// If pattern is a plain integer, treat it as a tab ID
|
||||
const asInt = parseInt(pattern);
|
||||
let match;
|
||||
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
|
||||
match = await chrome.tabs.get(asInt);
|
||||
} else {
|
||||
const all = await chrome.tabs.query({});
|
||||
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
|
||||
}
|
||||
if (!match) return null;
|
||||
await chrome.windows.update(match.windowId, { focused: true });
|
||||
await chrome.tabs.update(match.id, { active: true });
|
||||
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
|
||||
}
|
||||
|
||||
export async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const interval = 200;
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
const currentUrl = t.url || t.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(currentUrl)) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
|
||||
}
|
||||
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
|
||||
return tabInfo(t);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) {
|
||||
const opened = await navOpen({ url, background, window: windowName, group });
|
||||
return await navWait({ tabId: opened.id, timeout });
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,129 @@
|
||||
// @ts-nocheck
|
||||
import { getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor } from '../core';
|
||||
export async function sessionSave({ name }) {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const groupById = new Map(groups.map(group => [group.id, group]));
|
||||
const sessionTabs = tabs
|
||||
.filter(tab => Boolean(tab.url))
|
||||
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
|
||||
.map(tab => {
|
||||
const entry = { url: tab.url };
|
||||
if (tab.groupId >= 0) {
|
||||
const group = groupById.get(tab.groupId);
|
||||
entry.group = {
|
||||
key: `${tab.windowId}:${tab.groupId}`,
|
||||
title: group?.title || "",
|
||||
color: normalizeGroupColor(group?.color),
|
||||
collapsed: Boolean(group?.collapsed),
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
const sessions = await getSessions();
|
||||
sessions[name] = {
|
||||
tabs: sessionTabs,
|
||||
urls: sessionTabs.map(tab => tab.url),
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
await chrome.storage.local.set({ sessions });
|
||||
return { name, tabs: sessionTabs.length };
|
||||
}
|
||||
|
||||
export async function sessionLoad({ name }) {
|
||||
const sessions = await getSessions();
|
||||
const session = sessions[name];
|
||||
if (!session) throw new Error(`Session '${name}' not found`);
|
||||
|
||||
const sessionTabs = getSessionTabs(session);
|
||||
const createdTabs = [];
|
||||
|
||||
for (const entry of sessionTabs) {
|
||||
const tab = await chrome.tabs.create({ url: entry.url, active: false });
|
||||
createdTabs.push({ tabId: tab.id, entry });
|
||||
}
|
||||
|
||||
const groups = new Map();
|
||||
for (const { tabId, entry } of createdTabs) {
|
||||
if (!entry.group) continue;
|
||||
const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { meta: entry.group, tabIds: [] });
|
||||
}
|
||||
groups.get(key).tabIds.push(tabId);
|
||||
}
|
||||
|
||||
for (const { meta, tabIds } of groups.values()) {
|
||||
const restoredGroupId = await chrome.tabs.group({ tabIds });
|
||||
await chrome.tabGroups.update(restoredGroupId, {
|
||||
title: meta.title || "",
|
||||
color: normalizeGroupColor(meta.color),
|
||||
collapsed: Boolean(meta.collapsed),
|
||||
});
|
||||
}
|
||||
|
||||
return { name, tabs: sessionTabs.length };
|
||||
}
|
||||
|
||||
export async function sessionList() {
|
||||
const sessions = await getSessions();
|
||||
return Object.entries(sessions).map(([name, s]) => ({
|
||||
name,
|
||||
tabs: getSessionTabs(s).length,
|
||||
savedAt: s.savedAt || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function sessionRemove({ name }) {
|
||||
const sessions = await getSessions();
|
||||
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
|
||||
delete sessions[name];
|
||||
await chrome.storage.local.set({ sessions });
|
||||
return { name };
|
||||
}
|
||||
|
||||
export async function sessionDiff({ nameA, nameB }) {
|
||||
const sessions = await getSessions();
|
||||
const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url));
|
||||
const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url));
|
||||
return {
|
||||
added: [...b].filter(u => !a.has(u)),
|
||||
removed: [...a].filter(u => !b.has(u)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sessionAutoSave({ enabled }) {
|
||||
await chrome.storage.local.set({ autoSave: enabled });
|
||||
chrome.tabs.onUpdated.removeListener(autoSaveHandler);
|
||||
chrome.tabs.onRemoved.removeListener(autoSaveHandler);
|
||||
if (enabled) {
|
||||
chrome.tabs.onUpdated.addListener(autoSaveHandler);
|
||||
chrome.tabs.onRemoved.addListener(autoSaveHandler);
|
||||
}
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
export async function autoSaveHandler() {
|
||||
const { autoSave } = await chrome.storage.local.get("autoSave");
|
||||
if (!autoSave) return;
|
||||
await sessionSave({ name: "__auto__" });
|
||||
}
|
||||
|
||||
// ── Misc ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function clientsList() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const alias = await getProfileAlias();
|
||||
return [{
|
||||
name: "Chrome",
|
||||
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown",
|
||||
platform: navigator.platform,
|
||||
extensionVersion: manifest.version,
|
||||
profile: alias,
|
||||
}];
|
||||
}
|
||||
|
||||
export async function clientsRenameProfile({ alias }) {
|
||||
await chrome.storage.local.set({ profileAlias: alias });
|
||||
return { alias };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, getAliases, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core';
|
||||
export async function tabsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
const tabs = [];
|
||||
for (const w of windows) {
|
||||
for (const t of w.tabs) {
|
||||
tabs.push({
|
||||
...tabInfo(t),
|
||||
windowAlias: aliases[t.windowId] || null,
|
||||
pinned: t.pinned,
|
||||
favIconUrl: t.favIconUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
export async function tabsClose({ tabId, inactive, duplicates }) {
|
||||
let toClose = [];
|
||||
if (duplicates) {
|
||||
const all = await chrome.tabs.query({});
|
||||
const seen = new Set();
|
||||
for (const t of all) {
|
||||
if (!t.url) continue;
|
||||
if (seen.has(t.url)) toClose.push(t.id);
|
||||
else seen.add(t.url);
|
||||
}
|
||||
} else if (inactive) {
|
||||
const all = await chrome.tabs.query({});
|
||||
toClose = all.filter(t => !t.active).map(t => t.id);
|
||||
} else if (tabId) {
|
||||
toClose = [tabId];
|
||||
}
|
||||
if (toClose.length) await chrome.tabs.remove(toClose);
|
||||
return { closed: toClose.length };
|
||||
}
|
||||
|
||||
export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) {
|
||||
const moveProps = {};
|
||||
if (windowId != null) moveProps.windowId = windowId;
|
||||
|
||||
if (forward || backward) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
|
||||
else moveProps.index = Math.max(0, tab.index - 1);
|
||||
} else if (index != null) {
|
||||
moveProps.index = index;
|
||||
} else {
|
||||
moveProps.index = -1;
|
||||
}
|
||||
|
||||
await chrome.tabs.move(tabId, moveProps);
|
||||
if (groupId != null) {
|
||||
await chrome.tabs.group({ tabIds: [tabId], groupId });
|
||||
}
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
export async function tabsActive({ tabId }) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
export async function tabsActiveInWindow({ windowId }) {
|
||||
const activeTabs = await chrome.tabs.query({ windowId, active: true });
|
||||
const tab = activeTabs[0];
|
||||
if (!tab) {
|
||||
throw new Error(`No active tab found for window ${windowId}`);
|
||||
}
|
||||
return tabInfo(tab);
|
||||
}
|
||||
|
||||
export async function tabsStatus({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
return tabInfo(tab);
|
||||
}
|
||||
|
||||
export async function tabsFilter({ pattern }) {
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
|
||||
}
|
||||
|
||||
export async function tabsCount({ pattern }) {
|
||||
const all = await chrome.tabs.query({});
|
||||
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
|
||||
return all.length;
|
||||
}
|
||||
|
||||
export async function tabsQuery({ search }) {
|
||||
const q = search.toLowerCase();
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t =>
|
||||
(t.url && t.url.toLowerCase().includes(q)) ||
|
||||
(t.title && t.title.toLowerCase().includes(q))
|
||||
).map(tabInfo);
|
||||
}
|
||||
|
||||
export async function tabsHtml({ tabId }) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
return "";
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot get HTML of ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => document.documentElement.outerHTML,
|
||||
});
|
||||
return results[0]?.result || "";
|
||||
} catch (e) {
|
||||
if (isErrorPageScriptError(e)) return "";
|
||||
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id"));
|
||||
if (i < 2 && transient) {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function tabsDedupe() {
|
||||
return tabsClose({ duplicates: true });
|
||||
}
|
||||
|
||||
export async function tabsSort({ by }) {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
for (const w of windows) {
|
||||
const sorted = [...w.tabs].sort((a, b) => {
|
||||
if (by === "title") return (a.title || "").localeCompare(b.title || "");
|
||||
if (by === "time") return a.id - b.id; // lower id = opened earlier
|
||||
// domain (default)
|
||||
const da = new URL(a.url || a.pendingUrl || "about:blank").hostname;
|
||||
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
|
||||
return da.localeCompare(db);
|
||||
});
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
||||
moved++;
|
||||
}
|
||||
}
|
||||
return { moved };
|
||||
}
|
||||
|
||||
export async function tabsMergeWindows() {
|
||||
const current = await chrome.windows.getCurrent();
|
||||
const all = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
for (const w of all) {
|
||||
if (w.id === current.id) continue;
|
||||
const ids = w.tabs.map(t => t.id);
|
||||
await chrome.tabs.move(ids, { windowId: current.id, index: -1 });
|
||||
moved += ids.length;
|
||||
}
|
||||
return { moved };
|
||||
}
|
||||
|
||||
export async function tabsPin({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: true });
|
||||
return { tabId: tab.id, pinned: true };
|
||||
}
|
||||
|
||||
export async function tabsUnpin({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: false });
|
||||
return { tabId: tab.id, pinned: false };
|
||||
}
|
||||
|
||||
export async function tabsScreenshot({ tabId, format = "png", quality } = {}) {
|
||||
let windowId;
|
||||
if (tabId) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
windowId = tab.windowId;
|
||||
} else {
|
||||
const tab = await getActiveTab();
|
||||
windowId = tab.windowId;
|
||||
}
|
||||
const opts = { format };
|
||||
if (format === "jpeg" && quality != null) opts.quality = quality;
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
|
||||
return { dataUrl, format };
|
||||
}
|
||||
|
||||
export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
let lastUrl = tab.url || tab.pendingUrl || "";
|
||||
let lastStatus = tab.status || "unknown";
|
||||
|
||||
const matches = (url) => {
|
||||
regex.lastIndex = 0;
|
||||
return Boolean(url && regex.test(url));
|
||||
};
|
||||
if (matches(lastUrl)) return tabInfo(tab);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
lastUrl = t.url || t.pendingUrl || "";
|
||||
lastStatus = t.status || "unknown";
|
||||
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
|
||||
if (isBrowserErrorUrl(t.url || "")) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`);
|
||||
}
|
||||
|
||||
export async function tabsMute({ tabId }) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "mute");
|
||||
await chrome.tabs.update(tab.id, { muted: true });
|
||||
return { tabId: tab.id, muted: true };
|
||||
}
|
||||
|
||||
export async function tabsUnmute({ tabId }) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "unmute");
|
||||
await chrome.tabs.update(tab.id, { muted: false });
|
||||
return { tabId: tab.id, muted: false };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @ts-nocheck
|
||||
import { getAliases } from '../core';
|
||||
export async function windowsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
return windows.map(w => ({
|
||||
id: w.id,
|
||||
alias: aliases[w.id] || null,
|
||||
focused: w.focused,
|
||||
state: w.state,
|
||||
tabCount: (w.tabs || []).length,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function windowsRename({ windowId, name }) {
|
||||
const aliases = await getAliases();
|
||||
aliases[windowId] = name;
|
||||
await chrome.storage.local.set({ windowAliases: aliases });
|
||||
return { windowId, name };
|
||||
}
|
||||
|
||||
export async function windowsClose({ windowId }) {
|
||||
await chrome.windows.remove(windowId);
|
||||
return { windowId };
|
||||
}
|
||||
|
||||
export async function windowsOpen({ url }) {
|
||||
const createData = { focused: true };
|
||||
if (url) createData.url = url;
|
||||
const w = await chrome.windows.create(createData);
|
||||
return { id: w.id };
|
||||
}
|
||||
|
||||
// ── DOM / Extract ─────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,158 @@
|
||||
// @ts-nocheck
|
||||
// Shared helpers for browser-cli extension command handlers.
|
||||
export async function getProfileAlias() {
|
||||
const { profileAlias } = await chrome.storage.local.get("profileAlias");
|
||||
return profileAlias || "default";
|
||||
}
|
||||
|
||||
export function isBrowserErrorUrl(url) {
|
||||
const value = String(url || "").toLowerCase();
|
||||
return value.startsWith("chrome-error://") ||
|
||||
value.startsWith("edge-error://") ||
|
||||
value.startsWith("brave-error://") ||
|
||||
value.startsWith("about:neterror") ||
|
||||
value.startsWith("about:certerror") ||
|
||||
value.startsWith("about:blocked") ||
|
||||
value.startsWith("about:tabcrashed");
|
||||
}
|
||||
|
||||
export function isErrorPageScriptError(error) {
|
||||
const message = String(error?.message || error || "").toLowerCase();
|
||||
return message.includes("error page") ||
|
||||
message.includes("chrome-error://") ||
|
||||
message.includes("edge-error://") ||
|
||||
message.includes("brave-error://") ||
|
||||
message.includes("about:neterror") ||
|
||||
message.includes("about:certerror") ||
|
||||
message.includes("about:tabcrashed");
|
||||
}
|
||||
|
||||
export function isTransientScriptError(error) {
|
||||
const message = String(error?.message || error || "");
|
||||
return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error);
|
||||
}
|
||||
|
||||
export async function executeScript(options, retries = 3) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await chrome.scripting.executeScript(options);
|
||||
} catch (e) {
|
||||
if (i < retries - 1 && isTransientScriptError(e)) {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function tabInfo(t) {
|
||||
return {
|
||||
id: t.id,
|
||||
windowId: t.windowId,
|
||||
active: t.active,
|
||||
muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
|
||||
title: t.title,
|
||||
url: t.url,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function isScriptableUrl(url) {
|
||||
if (!url) return false;
|
||||
return !url.startsWith("chrome://") &&
|
||||
!url.startsWith("brave://") &&
|
||||
!url.startsWith("about:") &&
|
||||
!url.startsWith("edge://") &&
|
||||
!url.startsWith("chrome-extension://");
|
||||
}
|
||||
|
||||
export async function getActiveTab() {
|
||||
const activeTabs = await chrome.tabs.query({ active: true });
|
||||
if (!activeTabs.length) throw new Error("No active tab found");
|
||||
|
||||
const windows = await chrome.windows.getAll({ populate: false });
|
||||
const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id));
|
||||
|
||||
const chooseTab = (predicate) => activeTabs.find(predicate);
|
||||
const byFocusAndScriptable = tab => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
|
||||
const byScriptable = tab => isScriptableUrl(tab.url || tab.pendingUrl || "");
|
||||
const byFocus = tab => focusedWindowIds.has(tab.windowId);
|
||||
|
||||
return chooseTab(byFocusAndScriptable)
|
||||
|| chooseTab(byScriptable)
|
||||
|| chooseTab(byFocus)
|
||||
|| activeTabs[0];
|
||||
}
|
||||
|
||||
export async function resolveTabForDirectAction(tabId, actionName) {
|
||||
if (tabId != null) {
|
||||
return chrome.tabs.get(tabId);
|
||||
}
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
if (allTabs.length !== 1) {
|
||||
throw new Error(
|
||||
`Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open`
|
||||
);
|
||||
}
|
||||
return allTabs[0];
|
||||
}
|
||||
|
||||
export async function resolveGroupId(nameOrId) {
|
||||
const asInt = parseInt(nameOrId);
|
||||
if (!isNaN(asInt)) return asInt;
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
|
||||
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
export function buildTabBlocks(tabs) {
|
||||
const blocks = [];
|
||||
for (const tab of tabs) {
|
||||
const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null;
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
if (lastBlock?.groupId === normalizedGroupId) {
|
||||
lastBlock.tabIds.push(tab.id);
|
||||
lastBlock.endIndex = tab.index;
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
groupId: normalizedGroupId,
|
||||
startIndex: tab.index,
|
||||
endIndex: tab.index,
|
||||
tabIds: [tab.id],
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function getSessionTabs(session) {
|
||||
if (!session) return [];
|
||||
if (Array.isArray(session.tabs)) {
|
||||
return session.tabs
|
||||
.map(entry => typeof entry === "string" ? { url: entry } : entry)
|
||||
.filter(entry => entry?.url);
|
||||
}
|
||||
if (Array.isArray(session.urls)) {
|
||||
return session.urls.filter(Boolean).map(url => ({ url }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeGroupColor(color) {
|
||||
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
|
||||
return allowed.has(color) ? color : "grey";
|
||||
}
|
||||
|
||||
export async function getAliases() {
|
||||
const { windowAliases } = await chrome.storage.local.get("windowAliases");
|
||||
return windowAliases || {};
|
||||
}
|
||||
|
||||
export async function getSessions() {
|
||||
const { sessions } = await chrome.storage.local.get("sessions");
|
||||
return sessions || {};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* browser-cli Extension — Background Service Worker
|
||||
*
|
||||
* Connects to the native host (com.browsercli.host) via Native Messaging.
|
||||
*/
|
||||
|
||||
import { getProfileAlias } from './core';
|
||||
import * as nav from './commands/navigation';
|
||||
import * as tabs from './commands/tabs';
|
||||
import * as groups from './commands/groups';
|
||||
import * as windowsCmd from './commands/windows';
|
||||
import * as dom from './commands/dom';
|
||||
import * as browserData from './commands/browser-data';
|
||||
import * as session from './commands/session';
|
||||
|
||||
const NATIVE_HOST = "com.browsercli.host";
|
||||
let port = null;
|
||||
let keepaliveEnabled = true;
|
||||
|
||||
// ── Connection management ─────────────────────────────────────────────────────
|
||||
function sendControlMessage(targetPort, message) {
|
||||
if (!targetPort) return;
|
||||
try {
|
||||
targetPort.postMessage(message);
|
||||
} catch (e) {
|
||||
console.warn("[browser-cli] Failed to send control message:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectPort({ sendBye = false } = {}) {
|
||||
const currentPort = port;
|
||||
if (!currentPort) return;
|
||||
|
||||
if (sendBye) sendControlMessage(currentPort, { type: "bye" });
|
||||
|
||||
if (port === currentPort) port = null;
|
||||
|
||||
try {
|
||||
currentPort.disconnect();
|
||||
} catch (e) {
|
||||
console.warn("[browser-cli] Failed to disconnect native port:", e);
|
||||
}
|
||||
}
|
||||
async function connect() {
|
||||
if (port || !keepaliveEnabled) return;
|
||||
try {
|
||||
const nativePort = chrome.runtime.connectNative(NATIVE_HOST);
|
||||
port = nativePort;
|
||||
nativePort.onMessage.addListener(onMessage);
|
||||
nativePort.onDisconnect.addListener(() => {
|
||||
if (port === nativePort) port = null;
|
||||
const err = chrome.runtime.lastError;
|
||||
if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
|
||||
});
|
||||
// Send hello so native host knows which profile/alias this is
|
||||
const alias = await getProfileAlias();
|
||||
nativePort.postMessage({ type: "hello", alias });
|
||||
console.log("[browser-cli] Connected to native host as profile:", alias);
|
||||
} catch (e) {
|
||||
port = null;
|
||||
console.error("[browser-cli] Failed to connect:", e);
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(connect);
|
||||
chrome.runtime.onStartup.addListener(connect);
|
||||
chrome.runtime.onSuspend.addListener(() => {
|
||||
disconnectPort({ sendBye: true });
|
||||
});
|
||||
chrome.windows.onCreated.addListener(() => {
|
||||
keepaliveEnabled = true;
|
||||
if (!port) connect();
|
||||
});
|
||||
chrome.windows.onRemoved.addListener(async () => {
|
||||
const windows = await chrome.windows.getAll({});
|
||||
if (windows.length > 0) return;
|
||||
|
||||
keepaliveEnabled = false;
|
||||
disconnectPort({ sendBye: true });
|
||||
});
|
||||
|
||||
// Keepalive alarm — prevents service worker suspension and reconnects if needed
|
||||
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "keepalive") {
|
||||
if (!port && keepaliveEnabled) connect();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Message dispatcher ────────────────────────────────────────────────────────
|
||||
|
||||
async function onMessage(msg) {
|
||||
const { id, command, args } = msg;
|
||||
if (!id || !command) return;
|
||||
|
||||
console.log("[browser-cli] ←", command, args);
|
||||
|
||||
let data, error;
|
||||
try {
|
||||
const { __page, ...commandArgs } = args || {};
|
||||
data = await dispatch(command, commandArgs);
|
||||
if (__page && Array.isArray(data)) {
|
||||
data = makePagedData(data, __page);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e.message || String(e);
|
||||
}
|
||||
|
||||
if (error !== undefined) {
|
||||
console.log("[browser-cli] → ERROR", command, error);
|
||||
port.postMessage({ id, success: false, error });
|
||||
} else {
|
||||
console.log("[browser-cli] →", command, data);
|
||||
port.postMessage({ id, success: true, data });
|
||||
}
|
||||
|
||||
if (command === "clients.rename_profile" && error === undefined) {
|
||||
disconnectPort({ sendBye: true });
|
||||
keepaliveEnabled = true;
|
||||
await connect();
|
||||
}
|
||||
}
|
||||
function makePagedData(items, page) {
|
||||
const total = items.length;
|
||||
const offset = Math.max(0, Number(page.offset) || 0);
|
||||
const requestedLimit = Math.max(1, Number(page.limit) || 100);
|
||||
const limit = Math.min(requestedLimit, 1000);
|
||||
const end = Math.min(offset + limit, total);
|
||||
return {
|
||||
__browserCliPage: true,
|
||||
items: items.slice(offset, end),
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
nextOffset: end < total ? end : null,
|
||||
};
|
||||
}
|
||||
async function dispatch(command, args) {
|
||||
switch (command) {
|
||||
// ── Navigation ────────────────────────────────────────────────────────
|
||||
case "navigate.open": return nav.navOpen(args);
|
||||
case "navigate.to": return nav.navTo(args);
|
||||
case "navigate.reload": return nav.navReload(args, false);
|
||||
case "navigate.hard_reload": return nav.navReload(args, true);
|
||||
case "navigate.back": return nav.navBack(args);
|
||||
case "navigate.forward": return nav.navForward(args);
|
||||
case "navigate.focus": return nav.navFocus(args);
|
||||
case "navigate.wait": return nav.navWait(args);
|
||||
case "navigate.open_wait": return nav.navOpenWait(args);
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────
|
||||
case "tabs.list": return tabs.tabsList();
|
||||
case "tabs.close": return tabs.tabsClose(args);
|
||||
case "tabs.move": return tabs.tabsMove(args);
|
||||
case "tabs.active": return tabs.tabsActive(args);
|
||||
case "tabs.active_in_window": return tabs.tabsActiveInWindow(args);
|
||||
case "tabs.status": return tabs.tabsStatus(args);
|
||||
case "tabs.filter": return tabs.tabsFilter(args);
|
||||
case "tabs.count": return tabs.tabsCount(args);
|
||||
case "tabs.query": return tabs.tabsQuery(args);
|
||||
case "tabs.html": return tabs.tabsHtml(args);
|
||||
case "tabs.dedupe": return tabs.tabsDedupe();
|
||||
case "tabs.sort": return tabs.tabsSort(args);
|
||||
case "tabs.merge_windows": return tabs.tabsMergeWindows();
|
||||
case "tabs.mute": return tabs.tabsMute(args);
|
||||
case "tabs.unmute": return tabs.tabsUnmute(args);
|
||||
case "tabs.pin": return tabs.tabsPin(args);
|
||||
case "tabs.unpin": return tabs.tabsUnpin(args);
|
||||
case "tabs.screenshot": return tabs.tabsScreenshot(args);
|
||||
case "tabs.watch_url": return tabs.tabsWatchUrl(args);
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────
|
||||
case "group.list": return groups.groupList();
|
||||
case "group.tabs": return groups.groupTabs(args);
|
||||
case "group.count": return groups.groupCount();
|
||||
case "group.query": return groups.groupQuery(args);
|
||||
case "group.close": return groups.groupClose(args);
|
||||
case "group.open": return groups.groupOpen(args);
|
||||
case "group.add_tab": return groups.groupAddTab(args);
|
||||
case "group.move": return groups.groupMove(args);
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────
|
||||
case "windows.list": return windowsCmd.windowsList();
|
||||
case "windows.rename": return windowsCmd.windowsRename(args);
|
||||
case "windows.close": return windowsCmd.windowsClose(args);
|
||||
case "windows.open": return windowsCmd.windowsOpen(args);
|
||||
|
||||
// ── DOM ───────────────────────────────────────────────────────────────
|
||||
case "dom.query": return dom.domOp("domQuery", args);
|
||||
case "dom.click": return dom.domOp("domClick", args);
|
||||
case "dom.type": return dom.domOp("domType", args);
|
||||
case "dom.attr": return dom.domOp("domAttr", args);
|
||||
case "dom.text": return dom.domOp("domText", args);
|
||||
case "dom.exists": return dom.domOp("domExists", args);
|
||||
case "dom.scroll": return dom.domOp("domScroll", args);
|
||||
case "dom.select": return dom.domOp("domSelect", args);
|
||||
case "dom.key": return dom.domOp("domKey", args);
|
||||
case "dom.hover": return dom.domOp("domHover", args);
|
||||
case "dom.check": return dom.domOp("domCheck", { ...args, checked: true });
|
||||
case "dom.uncheck": return dom.domOp("domCheck", { ...args, checked: false });
|
||||
case "dom.clear": return dom.domOp("domClear", args);
|
||||
case "dom.focus": return dom.domOp("domFocus", args);
|
||||
case "dom.submit": return dom.domOp("domSubmit", args);
|
||||
case "dom.eval": return dom.domEval(args);
|
||||
case "dom.wait_for": return dom.domWaitFor(args);
|
||||
case "dom.poll": return dom.domPoll(args);
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
case "page.info": return dom.domOp("pageInfo", {});
|
||||
|
||||
// ── Storage ───────────────────────────────────────────────────────────
|
||||
case "storage.get": return browserData.storageGet(args);
|
||||
case "storage.set": return browserData.storageSet(args);
|
||||
|
||||
// ── Cookies ───────────────────────────────────────────────────────────
|
||||
case "cookies.list": return browserData.cookiesList(args);
|
||||
case "cookies.get": return browserData.cookiesGet(args);
|
||||
case "cookies.set": return browserData.cookiesSet(args);
|
||||
|
||||
// ── Extract ───────────────────────────────────────────────────────────
|
||||
case "extract.links": return dom.domOp("extractLinks", args);
|
||||
case "extract.images": return dom.domOp("extractImages", args);
|
||||
case "extract.text": return dom.domOp("extractText", args);
|
||||
case "extract.json": return dom.domOp("extractJson", args);
|
||||
case "extract.markdown": return dom.domOp("extractMarkdown", args);
|
||||
case "extract.html": return tabs.tabsHtml({});
|
||||
|
||||
// ── Session ───────────────────────────────────────────────────────────
|
||||
case "session.save": return session.sessionSave(args);
|
||||
case "session.load": return session.sessionLoad(args);
|
||||
case "session.list": return session.sessionList();
|
||||
case "session.remove": return session.sessionRemove(args);
|
||||
case "session.diff": return session.sessionDiff(args);
|
||||
case "session.auto_save": return session.sessionAutoSave(args);
|
||||
|
||||
// ── Misc ──────────────────────────────────────────────────────────────
|
||||
case "clients.list": return session.clientsList();
|
||||
case "clients.rename_profile": return session.clientsRenameProfile(args);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
Generated
+548
@@ -0,0 +1,548 @@
|
||||
{
|
||||
"name": "browser-cli-extension-build",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "browser-cli-extension-build",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chrome": {
|
||||
"version": "0.1.40",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.40.tgz",
|
||||
"integrity": "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filesystem": "*",
|
||||
"@types/har-format": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filesystem": {
|
||||
"version": "0.0.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
|
||||
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filewriter": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filewriter": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
|
||||
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "browser-cli-extension-build",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:extension": "esbuild extension/src/index.ts --bundle --format=iife --target=chrome120 --outfile=extension/background.js",
|
||||
"check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
[project]
|
||||
name = "browser-cli"
|
||||
version = "0.5.12"
|
||||
description = "Control your real running browser from the terminal via a Chrome extension"
|
||||
version = "0.9.8"
|
||||
description = "Control your real running browser from the terminal via a browser extension"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"click>=8",
|
||||
"cryptography>=48",
|
||||
"rich>=13",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Generate or derive Chrome extension key and ID.
|
||||
|
||||
Usage:
|
||||
python scripts/gen_extension_key.py # generate new key pair
|
||||
python scripts/gen_extension_key.py --from-manifest # derive ID from extension/manifest.json
|
||||
python scripts/gen_extension_key.py --key <base64> # derive ID from given public key
|
||||
"""
|
||||
import argparse, hashlib
|
||||
import base64, json, sys
|
||||
from pathlib import Path
|
||||
|
||||
def public_key_to_extension_id(pub_key_der:bytes) -> str:
|
||||
digest = hashlib.sha256(pub_key_der).hexdigest()
|
||||
return "".join(chr(ord("a") + int(c, 16)) for c in digest[:32])
|
||||
|
||||
def derive_from_key_b64(key_b64:str) -> tuple[str, str]:
|
||||
der = base64.b64decode(key_b64)
|
||||
ext_id = public_key_to_extension_id(der)
|
||||
return key_b64, ext_id
|
||||
|
||||
def generate_new_key() -> tuple[str, str, str]:
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
PublicFormat,
|
||||
PrivateFormat,
|
||||
NoEncryption,
|
||||
)
|
||||
except ImportError:
|
||||
print("Install 'cryptography' to generate new keys: pip install cryptography", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
pub_der = private_key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
|
||||
priv_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()).decode()
|
||||
|
||||
key_b64 = base64.b64encode(pub_der).decode()
|
||||
ext_id = public_key_to_extension_id(pub_der)
|
||||
return key_b64, ext_id, priv_pem
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Chrome extension key/ID tool")
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument("--from-manifest", action="store_true", help="Derive ID from extension/manifest.json")
|
||||
group.add_argument("--key", metavar="BASE64", help="Derive ID from given base64 public key")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.from_manifest:
|
||||
manifest_path = Path(__file__).parent.parent / "extension" / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
key_b64 = manifest.get("key")
|
||||
if not key_b64:
|
||||
print("No 'key' field in manifest.json", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
key_b64, ext_id = derive_from_key_b64(key_b64)
|
||||
print(f"Extension ID: {ext_id}")
|
||||
print(f"Key (b64): {key_b64}")
|
||||
|
||||
elif args.key:
|
||||
key_b64, ext_id = derive_from_key_b64(args.key)
|
||||
print(f"Extension ID: {ext_id}")
|
||||
|
||||
else:
|
||||
key_b64, ext_id, priv_pem = generate_new_key()
|
||||
print(f"Extension ID: {ext_id}")
|
||||
print(f"Key (b64): {key_b64}")
|
||||
print()
|
||||
print("Add this to extension/manifest.json:")
|
||||
print(f' "key": "{key_b64}"')
|
||||
print()
|
||||
print("Private key (keep secret, needed to re-derive same ID):")
|
||||
print(priv_pem)
|
||||
@@ -0,0 +1,20 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22
|
||||
uv
|
||||
python3
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "browser-cli dev shell: node $(node --version), npm $(npm --version), uv $(uv --version | awk '{print $2}')"
|
||||
|
||||
if [ -f package-lock.json ]; then
|
||||
if [ ! -f node_modules/.package-lock.json ] || [ package-lock.json -nt node_modules/.package-lock.json ]; then
|
||||
echo "Installing extension dependencies with npm ci..."
|
||||
npm ci
|
||||
fi
|
||||
fi
|
||||
'';
|
||||
}
|
||||
+2
-2
@@ -17,9 +17,9 @@ def browser():
|
||||
"""Returns a connected send_command callable for the testing profile, or skips the test."""
|
||||
try:
|
||||
send_command("tabs.list", profile=TEST_BROWSER_PROFILE)
|
||||
except BrowserNotConnected:
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
pytest.skip(
|
||||
"Browser 'testing' not connected — start Brave/Chrome with the extension loaded for that profile"
|
||||
f"Browser 'testing' not connected — start Brave/Chrome with the extension loaded for that profile ({e})"
|
||||
)
|
||||
|
||||
def _browser(command, args=None):
|
||||
|
||||
+84
-36
@@ -5,6 +5,7 @@ These tests mock `send_command` so no live browser connection is required.
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import browser_cli
|
||||
from browser_cli import BrowserCLI, BrowserCounts, Tab, Group
|
||||
from browser_cli.client import BrowserNotConnected, BrowserTarget
|
||||
|
||||
@@ -63,6 +64,11 @@ class TestBrowserCLIInit:
|
||||
b = BrowserCLI(browser="chrome")
|
||||
assert b._browser == "chrome"
|
||||
|
||||
def test_remote_options_stored(self):
|
||||
b = BrowserCLI(browser="work", remote="host:8765", key=None)
|
||||
assert b._browser == "work"
|
||||
assert b._remote == "host:8765"
|
||||
|
||||
|
||||
# ── Internal factories ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,7 +128,7 @@ class TestNavigation:
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.open",
|
||||
{"url": "https://example.com", "background": False, "window": None, "group": None},
|
||||
profile=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_open_background(self, b, mock_send):
|
||||
@@ -136,33 +142,38 @@ class TestNavigation:
|
||||
|
||||
def test_reload(self, b, mock_send):
|
||||
b.reload(tab_id=5)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_hard_reload(self, b, mock_send):
|
||||
b.hard_reload(tab_id=7)
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None)
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_back(self, b, mock_send):
|
||||
b.back(tab_id=3)
|
||||
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None)
|
||||
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_forward(self, b, mock_send):
|
||||
b.forward(tab_id=3)
|
||||
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None)
|
||||
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_focus_url(self, b, mock_send):
|
||||
b.focus_url("github.com")
|
||||
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None)
|
||||
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_navigate_tab(self, b, mock_send):
|
||||
b.navigate_tab(5, "https://example.com")
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None
|
||||
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_profile_forwarded(self, b_profile, mock_send):
|
||||
b_profile.reload()
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave")
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, key=None)
|
||||
|
||||
def test_remote_forwarded(self, mock_send):
|
||||
b = BrowserCLI(browser="work", remote="host:8765", key=None)
|
||||
b.reload()
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", key=None)
|
||||
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────────────────
|
||||
@@ -195,12 +206,12 @@ class TestExtract:
|
||||
result = b.extract_markdown()
|
||||
|
||||
assert result == "# Title"
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None)
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_extract_markdown_selector(self, b, mock_send):
|
||||
b.extract_markdown("article")
|
||||
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None)
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
|
||||
|
||||
|
||||
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
@@ -235,7 +246,7 @@ class TestTabs:
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.close",
|
||||
{"tabId": 10, "inactive": False, "duplicates": False},
|
||||
profile=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_move(self, b, mock_send):
|
||||
@@ -243,19 +254,19 @@ class TestTabs:
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.move",
|
||||
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
||||
profile=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_active(self, b, mock_send):
|
||||
b.tabs_active(10)
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_window_active_tab(self, b, mock_send):
|
||||
mock_send.return_value = TAB_DATA
|
||||
tab = b.window_active_tab(1)
|
||||
assert isinstance(tab, Tab)
|
||||
assert tab.id == 10
|
||||
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None)
|
||||
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_window_active_tab_missing_raises(self, b, mock_send):
|
||||
mock_send.return_value = None
|
||||
@@ -274,7 +285,6 @@ class TestTabs:
|
||||
def test_tabs_filter_predicate(self, b, mock_send):
|
||||
mock_send.return_value = [TAB_DATA, {**TAB_DATA, "id": 11, "url": "https://youtube.com"}]
|
||||
tabs = b.tabs_filter(lambda tab: "youtube" in tab.url)
|
||||
print(tabs)
|
||||
assert [tab.id for tab in tabs] == [11]
|
||||
|
||||
def test_tabs_filter_list_transformer(self, b, mock_send):
|
||||
@@ -308,7 +318,26 @@ class TestTabs:
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="default"),
|
||||
call("tabs.list", {}, profile="work"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
||||
b = BrowserCLI(remote="host:8765", key=None)
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||
), patch(
|
||||
"browser_cli.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
):
|
||||
mock_send.side_effect = [[TAB_DATA], None]
|
||||
tabs = b.tabs_list()
|
||||
tabs[0].close()
|
||||
|
||||
assert [tab.browser for tab in tabs] == ["host:work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||
@@ -351,7 +380,7 @@ class TestTabs:
|
||||
|
||||
def test_tabs_sort(self, b, mock_send):
|
||||
b.tabs_sort(by="title")
|
||||
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None)
|
||||
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs_merge_windows(self, b, mock_send):
|
||||
mock_send.return_value = {"moved": 4}
|
||||
@@ -384,7 +413,7 @@ class TestGroups:
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
tabs = b.group_tabs(42)
|
||||
assert isinstance(tabs[0], Tab)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_group_count(self, b, mock_send):
|
||||
mock_send.return_value = 7
|
||||
@@ -412,7 +441,26 @@ class TestGroups:
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="default"),
|
||||
call("group.list", {}, profile="work"),
|
||||
call("group.close", {"groupId": 99}, profile="work"),
|
||||
call("group.close", {"groupId": 99}, profile="work", remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
||||
b = BrowserCLI(remote="host:8765", key=None)
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||
), patch(
|
||||
"browser_cli.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
):
|
||||
mock_send.side_effect = [[GROUP_DATA], None]
|
||||
groups = b.group_list()
|
||||
groups[0].close()
|
||||
|
||||
assert [group.browser for group in groups] == ["host:work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||
@@ -435,7 +483,7 @@ class TestGroups:
|
||||
|
||||
def test_group_close(self, b, mock_send):
|
||||
b.group_close(42)
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None)
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_group_create_dict_response(self, b, mock_send):
|
||||
mock_send.return_value = GROUP_DATA
|
||||
@@ -455,7 +503,7 @@ class TestGroups:
|
||||
tab_id = b.group_add_tab(42, "https://example.com")
|
||||
assert tab_id == 55
|
||||
mock_send.assert_called_once_with(
|
||||
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None
|
||||
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_group_add_tab_non_dict_response(self, b, mock_send):
|
||||
@@ -465,7 +513,7 @@ class TestGroups:
|
||||
def test_group_move_forward(self, b, mock_send):
|
||||
b.group_move(42, forward=True)
|
||||
mock_send.assert_called_once_with(
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
|
||||
@@ -495,7 +543,7 @@ class TestWindows:
|
||||
result = b.windows_open()
|
||||
|
||||
assert result == {"id": 5}
|
||||
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None)
|
||||
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_windows_open_with_url(self, b, mock_send):
|
||||
mock_send.return_value = {"id": 9}
|
||||
@@ -503,7 +551,7 @@ class TestWindows:
|
||||
result = b.windows_open("https://example.com")
|
||||
|
||||
assert result == {"id": 9}
|
||||
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
|
||||
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
|
||||
class TestSession:
|
||||
@@ -513,7 +561,7 @@ class TestSession:
|
||||
result = b.session_list()
|
||||
|
||||
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
||||
mock_send.assert_called_once_with("session.list", {}, profile=None)
|
||||
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
|
||||
with patch(
|
||||
@@ -548,26 +596,26 @@ class TestTabModel:
|
||||
|
||||
def test_close(self, tab, mock_send):
|
||||
tab.close()
|
||||
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None)
|
||||
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_activate(self, tab, mock_send):
|
||||
tab.activate()
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_reload(self, tab, mock_send):
|
||||
tab.reload()
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_hard_reload(self, tab, mock_send):
|
||||
tab.hard_reload()
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None)
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_move_forward(self, tab, mock_send):
|
||||
tab.move(forward=True)
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.move",
|
||||
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
||||
profile=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_move_to_group(self, tab, mock_send):
|
||||
@@ -577,12 +625,12 @@ class TestTabModel:
|
||||
def test_html(self, tab, mock_send):
|
||||
mock_send.return_value = "<html/>"
|
||||
assert tab.html() == "<html/>"
|
||||
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None)
|
||||
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_open(self, tab, mock_send):
|
||||
tab.open("https://new.example.com")
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None
|
||||
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_open_background_changes_same_tab(self, tab, mock_send):
|
||||
@@ -590,7 +638,7 @@ class TestTabModel:
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.to",
|
||||
{"tabId": 10, "url": "https://new.example.com"},
|
||||
profile=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_unbound_raises(self):
|
||||
@@ -608,18 +656,18 @@ class TestGroupModel:
|
||||
|
||||
def test_close(self, group, mock_send):
|
||||
group.close()
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None)
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs(self, group, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
tabs = group.tabs()
|
||||
assert isinstance(tabs[0], Tab)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_move_forward(self, group, mock_send):
|
||||
group.move(forward=True)
|
||||
mock_send.assert_called_once_with(
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_move_backward(self, group, mock_send):
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth import (
|
||||
add_authorized_key,
|
||||
canonical_payload,
|
||||
generate_keypair,
|
||||
load_authorized_keys,
|
||||
load_authorized_keys_with_names,
|
||||
load_private_key,
|
||||
new_nonce,
|
||||
pq_decrypt,
|
||||
pq_encrypt,
|
||||
pq_kex_client_encapsulate,
|
||||
pq_kex_server_decapsulate,
|
||||
pq_kex_server_keypair,
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
from browser_cli.client import _is_valid_key_spec
|
||||
|
||||
|
||||
class TestGenerateKeypair:
|
||||
def test_returns_pem_and_hex(self):
|
||||
pem, pub_hex = generate_keypair()
|
||||
assert pem.startswith(b"-----BEGIN PRIVATE KEY-----")
|
||||
assert len(pub_hex) == 64
|
||||
|
||||
def test_each_call_unique(self):
|
||||
_, pub1 = generate_keypair()
|
||||
_, pub2 = generate_keypair()
|
||||
assert pub1 != pub2
|
||||
|
||||
|
||||
class TestCanonicalPayload:
|
||||
def test_strips_auth_protocol_fields(self):
|
||||
msg = {"command": "tabs.list", "id": "x", "pubkey": "abc", "sig": "def", "pq_kex": {"alg": "ML-KEM-768"}}
|
||||
data = json.loads(canonical_payload(msg))
|
||||
assert "pubkey" not in data
|
||||
assert "sig" not in data
|
||||
assert "pq_kex" not in data
|
||||
|
||||
def test_keys_sorted(self):
|
||||
msg = {"z": 1, "a": 2, "m": 3}
|
||||
payload = canonical_payload(msg).decode()
|
||||
assert payload.index('"a"') < payload.index('"m"') < payload.index('"z"')
|
||||
|
||||
def test_deterministic(self):
|
||||
msg = {"b": 2, "a": 1}
|
||||
assert canonical_payload(msg) == canonical_payload(msg)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def keypair(tmp_path):
|
||||
pem, pub_hex = generate_keypair()
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
return priv, pub_hex
|
||||
|
||||
|
||||
class TestSignVerify:
|
||||
def test_valid_signature_verifies(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list", "id": "uuid-1", "args": {}}
|
||||
sig = sign(priv, nonce, msg).hex()
|
||||
assert verify(pub_hex, nonce, msg, sig) is True
|
||||
|
||||
def test_tampered_sig_fails(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list", "id": "x"}
|
||||
sign(priv, nonce, msg)
|
||||
assert verify(pub_hex, nonce, msg, "00" * 64) is False
|
||||
|
||||
def test_wrong_pubkey_fails(self, keypair):
|
||||
priv, _ = keypair
|
||||
_, other_pub = generate_keypair()
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list"}
|
||||
sig = sign(priv, nonce, msg).hex()
|
||||
assert verify(other_pub, nonce, msg, sig) is False
|
||||
|
||||
def test_wrong_nonce_fails(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list"}
|
||||
sig = sign(priv, nonce, msg).hex()
|
||||
other_nonce = bytes.fromhex(new_nonce())
|
||||
assert verify(pub_hex, other_nonce, msg, sig) is False
|
||||
|
||||
def test_post_quantum_shared_secret_is_bound_to_signature(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list", "pq_kex": {"alg": "ML-KEM-768", "ciphertext": "abcd"}}
|
||||
sig = sign(priv, nonce, msg, b"shared-secret").hex()
|
||||
assert verify(pub_hex, nonce, msg, sig, b"shared-secret") is True
|
||||
assert verify(pub_hex, nonce, msg, sig, b"other-secret") is False
|
||||
assert verify(pub_hex, nonce, msg, sig) is False
|
||||
|
||||
def test_garbage_pub_hex_returns_false_not_exception(self):
|
||||
assert verify("not-hex!!!!", b"nonce", {}, "00" * 64) is False
|
||||
|
||||
def test_truncated_sig_hex_returns_false_not_exception(self, keypair):
|
||||
_, pub_hex = keypair
|
||||
assert verify(pub_hex, b"nonce", {}, "aabb") is False
|
||||
|
||||
def test_wrong_length_pubkey_returns_false_not_exception(self):
|
||||
assert verify("aabbcc", b"nonce", {}, "00" * 64) is False
|
||||
|
||||
|
||||
class TestPostQuantumKex:
|
||||
def test_mlkem_roundtrip_when_backend_supports_it(self):
|
||||
keypair = pq_kex_server_keypair()
|
||||
if keypair is None:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
priv, pub = keypair
|
||||
|
||||
ciphertext_hex, client_secret = pq_kex_client_encapsulate(pub.hex())
|
||||
server_secret = pq_kex_server_decapsulate(priv, ciphertext_hex)
|
||||
|
||||
assert server_secret == client_secret
|
||||
assert len(server_secret) == 32
|
||||
|
||||
def test_pq_transport_encrypt_decrypt_roundtrip(self):
|
||||
secret = b"s" * 32
|
||||
plaintext = b'{"command":"tabs.list"}'
|
||||
|
||||
envelope = pq_encrypt(secret, "request", plaintext)
|
||||
|
||||
assert envelope["alg"] == "ML-KEM-768+ChaCha20Poly1305"
|
||||
assert plaintext.hex() not in envelope["ciphertext"]
|
||||
assert pq_decrypt(secret, "request", envelope) == plaintext
|
||||
|
||||
def test_pq_transport_direction_is_bound(self):
|
||||
secret = b"s" * 32
|
||||
envelope = pq_encrypt(secret, "request", b"payload")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
pq_decrypt(secret, "response", envelope)
|
||||
|
||||
|
||||
class TestAuthorizedKeys:
|
||||
def test_add_and_load(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
assert add_authorized_key(path, pub, "alice") is True
|
||||
assert pub in load_authorized_keys(path)
|
||||
|
||||
def test_add_duplicate_returns_false(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
add_authorized_key(path, pub)
|
||||
assert add_authorized_key(path, pub) is False
|
||||
|
||||
def test_load_with_names(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub1 = generate_keypair()
|
||||
_, pub2 = generate_keypair()
|
||||
add_authorized_key(path, pub1, "alice")
|
||||
add_authorized_key(path, pub2)
|
||||
entries = load_authorized_keys_with_names(path)
|
||||
assert (pub1, "alice") in entries
|
||||
assert (pub2, "") in entries
|
||||
|
||||
def test_ignores_comment_lines(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text("# this is a comment\n")
|
||||
assert load_authorized_keys(path) == []
|
||||
|
||||
def test_returns_empty_for_missing_file(self, tmp_path):
|
||||
assert load_authorized_keys(tmp_path / "nofile") == []
|
||||
|
||||
|
||||
class TestIsValidKeySpec:
|
||||
def test_agent_bare(self):
|
||||
assert _is_valid_key_spec("agent") is True
|
||||
|
||||
def test_agent_with_selector(self):
|
||||
assert _is_valid_key_spec("agent:cardno:000012345678") is True
|
||||
|
||||
def test_absolute_pem_path(self):
|
||||
assert _is_valid_key_spec("/home/user/.config/browser-cli/client.key.pem") is True
|
||||
|
||||
def test_dot_key_extension(self):
|
||||
assert _is_valid_key_spec("/tmp/mykey.key") is True
|
||||
|
||||
def test_angled_bracket_pem_rejected(self):
|
||||
# regression: operator precedence bug allowed "<garbage>.pem" to pass
|
||||
assert _is_valid_key_spec("<garbage>.pem") is False
|
||||
|
||||
def test_angled_bracket_key_rejected(self):
|
||||
assert _is_valid_key_spec("<garbage>.key") is False
|
||||
|
||||
def test_serialized_object_rejected(self):
|
||||
assert _is_valid_key_spec("<AgentKey(blob=b'...', comment='test')>.pem") is False
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
assert _is_valid_key_spec("") is False
|
||||
|
||||
def test_bare_filename_no_slash_no_ext_rejected(self):
|
||||
assert _is_valid_key_spec("mykey") is False
|
||||
+181
-57
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import os
|
||||
import sys
|
||||
|
||||
from click.testing import CliRunner
|
||||
@@ -79,22 +80,15 @@ def test_install_help_lists_supported_browsers():
|
||||
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
|
||||
|
||||
|
||||
def test_install_windows_registers_native_host(tmp_path, monkeypatch):
|
||||
local_app_data = tmp_path / "LocalAppData"
|
||||
extension_dir = tmp_path / "extension"
|
||||
extension_dir.mkdir()
|
||||
native_host_src = tmp_path / "native_host.py"
|
||||
native_host_src.write_text("print('ok')", encoding="utf-8")
|
||||
def test_install_windows_registers_native_host(tmp_path):
|
||||
writes = []
|
||||
|
||||
class FakeKey:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
def __exit__(self, _exc_type, _exc, _tb):
|
||||
return False
|
||||
|
||||
fake_winreg = SimpleNamespace(
|
||||
@@ -103,43 +97,135 @@ def test_install_windows_registers_native_host(tmp_path, monkeypatch):
|
||||
KEY_WOW64_32KEY=0x0200,
|
||||
KEY_WOW64_64KEY=0x0100,
|
||||
REG_SZ=1,
|
||||
CreateKeyEx=lambda _root, path, _reserved, _access: FakeKey(path),
|
||||
SetValueEx=lambda key, name, _reserved, _reg_type, value: writes.append((key.path, name, value)),
|
||||
)
|
||||
|
||||
def fake_create_key(root, path, reserved, access):
|
||||
return FakeKey(path)
|
||||
|
||||
def fake_set_value(key, name, reserved, reg_type, value):
|
||||
writes.append((key.path, name, value))
|
||||
|
||||
fake_winreg.CreateKeyEx = fake_create_key
|
||||
fake_winreg.SetValueEx = fake_set_value
|
||||
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(local_app_data))
|
||||
|
||||
host_exe = tmp_path / "browser-cli-native-host.exe"
|
||||
with patch("browser_cli.cli.is_windows", return_value=True), patch(
|
||||
"browser_cli.cli.Path.home", return_value=tmp_path
|
||||
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch(
|
||||
"browser_cli.cli.shutil.copy2"
|
||||
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict(
|
||||
sys.modules, {"winreg": fake_winreg}
|
||||
):
|
||||
copy2.side_effect = lambda src, dst: Path(dst).write_text(native_host_src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
"browser_cli.cli._native_host_exe", return_value=host_exe
|
||||
), patch("browser_cli.cli._write_native_host_exe"), patch(
|
||||
"browser_cli.cli.Path.write_text"
|
||||
), patch.dict(sys.modules, {"winreg": fake_winreg}):
|
||||
result = CliRunner().invoke(main, ["install", "edge"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes)
|
||||
assert "Registered native host" in result.output
|
||||
assert "Wrote native host manifest" in result.output
|
||||
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args]
|
||||
assert any("@echo off" in text for text in wrapper_writes)
|
||||
|
||||
def test_write_native_host_exe_unix(tmp_path):
|
||||
from browser_cli.cli import _write_native_host_exe
|
||||
|
||||
host = tmp_path / "libexec" / "browser-cli-native-host"
|
||||
with patch("browser_cli.cli.is_windows", return_value=False):
|
||||
_write_native_host_exe(host)
|
||||
|
||||
assert host.exists()
|
||||
content = host.read_text()
|
||||
assert content.startswith(f"#!{sys.executable}")
|
||||
assert "from browser_cli.native_host import main" in content
|
||||
assert host.stat().st_mode & 0o111 # executable bit set
|
||||
|
||||
|
||||
def test_write_native_host_exe_windows(tmp_path):
|
||||
from browser_cli.cli import _write_native_host_exe
|
||||
|
||||
host = tmp_path / "libexec" / "browser-cli-native-host.cmd"
|
||||
with patch("browser_cli.cli.is_windows", return_value=True):
|
||||
_write_native_host_exe(host)
|
||||
|
||||
assert host.exists()
|
||||
content = host.read_text(encoding="utf-8")
|
||||
assert "@echo off" in content
|
||||
assert "browser_cli.native_host" in content
|
||||
|
||||
|
||||
def test_clients_exits_cleanly_when_registry_is_missing():
|
||||
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
|
||||
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
|
||||
"browser_cli.cli.active_browser_targets", return_value=[]
|
||||
):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "No browser clients found" in result.output
|
||||
|
||||
|
||||
def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
|
||||
|
||||
def fake_send_command(command, args=None, profile=None):
|
||||
assert command == "clients.list"
|
||||
assert profile == "main"
|
||||
return [{"profile": "main", "name": "Chrome", "version": "1", "extensionVersion": "0.8.2"}]
|
||||
|
||||
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
|
||||
"browser_cli.cli.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "main" in result.output
|
||||
assert "0.8.2" in result.output
|
||||
|
||||
def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "clients.list"
|
||||
assert profile is None
|
||||
assert remote == "127.0.0.1:8765"
|
||||
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
|
||||
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once()
|
||||
assert "remote" in result.output
|
||||
assert "Chrome" in result.output
|
||||
assert "2.3.4" in result.output
|
||||
|
||||
|
||||
def test_clients_remote_respects_global_browser_route():
|
||||
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.cli.send_command", return_value=[]) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
|
||||
|
||||
|
||||
def test_clients_browser_alias_resolves_to_remote():
|
||||
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
|
||||
and shows only clients from that host (not local profiles)."""
|
||||
from browser_cli.client import BrowserTarget
|
||||
|
||||
resolved_target = BrowserTarget(
|
||||
profile="automatisation",
|
||||
display_name="192.168.188.104:automatisation",
|
||||
socket_path="",
|
||||
remote="192.168.188.104:8765",
|
||||
)
|
||||
all_remote_targets = [resolved_target]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "clients.list"
|
||||
assert profile == "automatisation"
|
||||
assert remote == "192.168.188.104:8765"
|
||||
return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.cli.remote_target_for_alias", return_value=resolved_target
|
||||
), patch(
|
||||
"browser_cli.cli.remote_browser_targets", return_value=all_remote_targets
|
||||
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "192.168.188.104", "clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once()
|
||||
assert "Chrome" in result.output
|
||||
assert "0.8.5" in result.output
|
||||
|
||||
|
||||
def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
default_socket = tmp_path / "550e8400-e29b-41d4-a716-446655440000.sock"
|
||||
@@ -160,7 +246,7 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
||||
|
||||
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
|
||||
"browser_cli.cli.send_command", side_effect=fake_send_command
|
||||
):
|
||||
), patch("browser_cli.cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -176,12 +262,12 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
||||
return [{"id": 1 if profile == "default" else 2, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -190,15 +276,34 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
||||
assert "work" in result.output
|
||||
|
||||
|
||||
def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||
), patch(
|
||||
"browser_cli.commands.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
|
||||
), patch(
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "remote-host:work" in result.output
|
||||
assert "Remote" in result.output
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765")
|
||||
|
||||
|
||||
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch(
|
||||
"browser_cli.commands.tabs.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Example", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"])
|
||||
@@ -217,12 +322,12 @@ def test_tabs_count_multi_browser_shows_total():
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["tabs", "count", "github"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -239,12 +344,12 @@ def test_group_count_multi_browser_shows_total():
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.groups.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.groups.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["groups", "count"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -255,7 +360,7 @@ def test_group_count_multi_browser_shows_total():
|
||||
|
||||
def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
with patch(
|
||||
"browser_cli.commands.groups.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}],
|
||||
):
|
||||
result = CliRunner().invoke(main, ["groups", "list"])
|
||||
@@ -267,7 +372,7 @@ def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
|
||||
|
||||
def test_tabs_move_accepts_right_short_alias():
|
||||
with patch("browser_cli.commands.tabs.send_command") as send_command:
|
||||
with patch("browser_cli.commands.send_command") as send_command:
|
||||
result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -279,7 +384,7 @@ def test_tabs_move_accepts_right_short_alias():
|
||||
|
||||
|
||||
def test_groups_move_accepts_left_short_alias():
|
||||
with patch("browser_cli.commands.groups.send_command") as send_command:
|
||||
with patch("browser_cli.commands.send_command") as send_command:
|
||||
result = CliRunner().invoke(main, ["groups", "move", "research", "-l"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -288,19 +393,18 @@ def test_groups_move_accepts_left_short_alias():
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_windows_list_multi_browser_shows_browser_column():
|
||||
def fake_send_command(command, args=None, profile=None):
|
||||
assert command == "windows.list"
|
||||
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.windows.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.windows.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["windows", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -316,12 +420,12 @@ def test_session_list_multi_browser_shows_browser_column():
|
||||
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.session.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.session.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["session", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -334,13 +438,13 @@ def test_session_list_multi_browser_shows_browser_column():
|
||||
|
||||
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.commands.session.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch(
|
||||
"browser_cli.commands.session.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "work", "session", "list"])
|
||||
@@ -351,7 +455,7 @@ def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||
|
||||
|
||||
def test_windows_open_passes_url():
|
||||
with patch("browser_cli.commands.windows.send_command", return_value={"id": 7}) as send_command:
|
||||
with patch("browser_cli.commands.send_command", return_value={"id": 7}) as send_command:
|
||||
result = CliRunner().invoke(main, ["windows", "open", "https://example.com"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -359,20 +463,20 @@ def test_windows_open_passes_url():
|
||||
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
|
||||
|
||||
def test_extract_markdown_command():
|
||||
with patch("browser_cli.commands.extract.send_command", return_value="# Title") as send_command:
|
||||
with patch("browser_cli.commands.send_command", return_value="# Title") as send_command:
|
||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "# Title\n"
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": None})
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": None}, profile=None)
|
||||
|
||||
def test_extract_markdown_command_with_selector():
|
||||
with patch("browser_cli.commands.extract.send_command", return_value="## Post") as send_command:
|
||||
with patch("browser_cli.commands.send_command", return_value="## Post") as send_command:
|
||||
result = CliRunner().invoke(main, ["extract", "markdown", "--selector", "article"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "## Post\n"
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": "article"})
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None)
|
||||
|
||||
|
||||
def test_clean_markdown_output_removes_escaped_underscores_and_dashes():
|
||||
@@ -457,7 +561,7 @@ def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
|
||||
"Golden Set │ ▼Promptfoo(Testausführung) │ ▼Plattformen├ Omnifact└ Le Chat\n"
|
||||
"```"
|
||||
)
|
||||
with patch("browser_cli.commands.extract.send_command", return_value=raw):
|
||||
with patch("browser_cli.commands.send_command", return_value=raw):
|
||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -524,3 +628,23 @@ def test_convert_html_to_markdown_indents_multiline_list_items():
|
||||
"- Unternehmensdaten → RAG → KI-Orchestrierung →\n"
|
||||
" Local LLMs / API Modelle / Spezialmodelle"
|
||||
) in markdown
|
||||
|
||||
|
||||
def test_tabs_list_multi_browser_queries_remote_target():
|
||||
endpoint = "browser-host.example:8765"
|
||||
remote_target = BrowserTarget(
|
||||
"work",
|
||||
"browser-host.example:work",
|
||||
"",
|
||||
remote=endpoint,
|
||||
)
|
||||
|
||||
with patch("browser_cli.commands.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint)
|
||||
assert "browser-host.example:work" in result.output
|
||||
|
||||
+317
-3
@@ -3,7 +3,19 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name
|
||||
from browser_cli.client import (
|
||||
BrowserNotConnected,
|
||||
BrowserTarget,
|
||||
_looks_like_domain,
|
||||
_normalize_endpoint,
|
||||
_resolve_connect_endpoint,
|
||||
_resolve_socket,
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
key_for_remote,
|
||||
send_command,
|
||||
remote_target_for_alias,
|
||||
)
|
||||
from browser_cli.platform import endpoint_for_alias
|
||||
|
||||
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
|
||||
@@ -63,7 +75,7 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
||||
|
||||
targets = active_browser_targets()
|
||||
targets = active_browser_targets(include_remotes=False)
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].profile == "work"
|
||||
@@ -77,7 +89,309 @@ def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr("browser_cli.client.is_windows", lambda: True)
|
||||
|
||||
targets = active_browser_targets()
|
||||
targets = active_browser_targets(include_remotes=False)
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
|
||||
|
||||
|
||||
|
||||
def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
sent.update(msg)
|
||||
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
assert send_command("tabs.list", remote="host:8765", key=None) == "ok"
|
||||
assert sent["_route"] == "work"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
sent["endpoint"] = endpoint
|
||||
sent.update(msg)
|
||||
return json.dumps({"success": True, "data": []}).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
assert send_command("tabs.list") == []
|
||||
assert sent["endpoint"] == "host:8765"
|
||||
assert sent["_route"] == "work"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
)
|
||||
|
||||
target = remote_target_for_alias("host:8765:work")
|
||||
|
||||
assert target is not None
|
||||
assert target.profile == "work"
|
||||
assert target.remote == "host:8765"
|
||||
|
||||
|
||||
def test_remote_target_for_alias_accepts_host_when_only_one_remote_target(monkeypatch):
|
||||
remote_host = "browser-host.example"
|
||||
remote_endpoint = f"{remote_host}:8765"
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
||||
)
|
||||
|
||||
target = remote_target_for_alias(remote_host)
|
||||
|
||||
assert target is not None
|
||||
assert target.profile == "work"
|
||||
assert target.remote == remote_endpoint
|
||||
|
||||
|
||||
def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
||||
remote_host = "browser-host.example"
|
||||
remote_endpoint = f"{remote_host}:8765"
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", remote_host)
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
sent["endpoint"] = endpoint
|
||||
sent.update(msg)
|
||||
return json.dumps({"success": True, "data": []}).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
assert send_command("tabs.list") == []
|
||||
assert sent["endpoint"] == remote_endpoint
|
||||
assert sent["_route"] == "work"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [
|
||||
BrowserTarget("main", "host:main", "", remote="host:8765"),
|
||||
BrowserTarget("work", "host:work", "", remote="host:8765"),
|
||||
],
|
||||
)
|
||||
|
||||
assert remote_target_for_alias("host") is None
|
||||
|
||||
|
||||
def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, key=None: [
|
||||
BrowserTarget("main", "host:main", "", remote=endpoint),
|
||||
BrowserTarget("furry", "host:furry", "", remote=endpoint),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
|
||||
send_command("tabs.list", remote="host:8765")
|
||||
|
||||
|
||||
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browser-host.example:8765"
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "browser-cli.targets"
|
||||
assert remote == endpoint
|
||||
return [{"profile": "work", "displayName": "work"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
|
||||
|
||||
targets = active_browser_targets()
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].profile == "work"
|
||||
assert targets[0].display_name == "browser-host.example:work"
|
||||
assert targets[0].remote == endpoint
|
||||
|
||||
|
||||
def test_looks_like_domain():
|
||||
assert _looks_like_domain("browsercli.yiprawr.dev") is True
|
||||
assert _looks_like_domain("browser-host.example") is True
|
||||
assert _looks_like_domain("sub.domain.org") is True
|
||||
assert _looks_like_domain("localhost") is False
|
||||
assert _looks_like_domain("127.0.0.1") is False
|
||||
assert _looks_like_domain("192.168.1.100") is False
|
||||
assert _looks_like_domain("host") is False # no dot
|
||||
|
||||
|
||||
def test_normalize_endpoint_strips_443_for_domains():
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev"
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev"
|
||||
assert _normalize_endpoint("192.168.1.1:443") == "192.168.1.1:443" # IP: keep port
|
||||
assert _normalize_endpoint("localhost:443") == "localhost:443" # localhost: keep port
|
||||
assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged
|
||||
assert _normalize_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
|
||||
|
||||
|
||||
def test_resolve_connect_endpoint_adds_443_for_domain():
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev:443"
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev:443"
|
||||
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
|
||||
assert _resolve_connect_endpoint("host:8765") == "host:8765"
|
||||
|
||||
|
||||
def test_resolve_connect_endpoint_raises_for_bare_non_domain():
|
||||
with pytest.raises(BrowserNotConnected, match="expected host:port"):
|
||||
_resolve_connect_endpoint("localhost")
|
||||
|
||||
|
||||
def test_send_command_normalizes_domain_port_443(monkeypatch):
|
||||
"""--remote domain:443 is normalized; _send_remote gets the portless domain."""
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
sent_to = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
sent_to["endpoint"] = endpoint
|
||||
return json.dumps({"success": True, "data": "ok"}).encode()
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
result = send_command("tabs.list", remote="browsercli.yiprawr.dev:443")
|
||||
assert result == "ok"
|
||||
assert sent_to["endpoint"] == "browsercli.yiprawr.dev" # stored/routed without port
|
||||
|
||||
|
||||
def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
|
||||
"""--remote domain (no port) is treated as :443."""
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
sent_to = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
sent_to["endpoint"] = endpoint
|
||||
return json.dumps({"success": True, "data": "ok"}).encode()
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
result = send_command("tabs.list", remote="browsercli.yiprawr.dev")
|
||||
assert result == "ok"
|
||||
assert sent_to["endpoint"] == "browsercli.yiprawr.dev"
|
||||
|
||||
|
||||
def test_domain_display_name_omits_port(monkeypatch, tmp_path):
|
||||
"""Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'."""
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browsercli.yiprawr.dev"
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
return [{"profile": "automatisation", "displayName": "automatisation"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
|
||||
|
||||
targets = active_browser_targets()
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
||||
assert targets[0].remote == endpoint
|
||||
|
||||
|
||||
def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path):
|
||||
"""Old remotes.json with :443 still displays cleanly without the port."""
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browsercli.yiprawr.dev:443" # old format
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
return [{"profile": "automatisation", "displayName": "automatisation"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
|
||||
|
||||
targets = active_browser_targets()
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
||||
|
||||
|
||||
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
|
||||
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it."""
|
||||
import json as _json
|
||||
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
remotes_path.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
||||
|
||||
from pathlib import Path as _Path
|
||||
used_keys = []
|
||||
|
||||
def fake_load_private_key(key_path=None):
|
||||
used_keys.append(str(key_path) if key_path is not None else None)
|
||||
return None # no actual key needed for this test
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._load_private_key", fake_load_private_key)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
return _json.dumps({"success": True, "data": "ok"}).encode()
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
# First call with explicit --key agent
|
||||
send_command("tabs.list", remote="host:8765", key=_Path("agent"))
|
||||
assert used_keys[-1] == "agent"
|
||||
|
||||
# Key must be persisted now
|
||||
assert key_for_remote("host:8765") == "agent"
|
||||
|
||||
# Second call without --key — should reuse saved "agent"
|
||||
send_command("tabs.list", remote="host:8765")
|
||||
assert used_keys[-1] == "agent"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""Tests for dom.* commands (require an http/https active tab)."""
|
||||
import pytest
|
||||
from browser_cli.client import send_command
|
||||
|
||||
|
||||
def test_dom_query_body(browser, http_tab):
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def test_extension_retries_error_page_script_injection_before_failing():
|
||||
core = (ROOT / "extension" / "src" / "core.ts").read_text()
|
||||
|
||||
assert "isBrowserErrorUrl" in core
|
||||
assert "isErrorPageScriptError" in core
|
||||
assert "chrome-error://" in core
|
||||
assert "edge-error://" in core
|
||||
assert "brave-error://" in core
|
||||
assert "about:neterror" in core
|
||||
assert "about:certerror" in core
|
||||
assert "isTransientScriptError(e)" in core
|
||||
|
||||
def test_read_only_dom_commands_have_error_page_fallbacks():
|
||||
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text()
|
||||
|
||||
assert "fallbackForErrorPageDomOp" in dom
|
||||
assert 'case "domExists":' in dom
|
||||
assert "return false;" in dom
|
||||
assert 'case "domQuery":' in dom
|
||||
assert 'case "extractText":' in dom
|
||||
assert "isBrowserErrorUrl(tabUrl)" in dom
|
||||
assert "isErrorPageScriptError(e)" in dom
|
||||
|
||||
def test_navigation_and_tabs_report_browser_error_pages():
|
||||
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
|
||||
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text()
|
||||
|
||||
assert "lastUrl" in tabs
|
||||
assert "lastStatus" in tabs
|
||||
assert "showing an error page" in tabs
|
||||
assert "last URL:" in tabs
|
||||
assert "isBrowserErrorUrl" in navigation
|
||||
assert "showing an error page while waiting for load" in navigation
|
||||
@@ -1,6 +1,4 @@
|
||||
"""Tests for extract.* commands (require an http/https active tab)."""
|
||||
import pytest
|
||||
from browser_cli.client import send_command
|
||||
|
||||
|
||||
def test_extract_links(browser, http_tab):
|
||||
@@ -58,15 +56,3 @@ def test_extract_markdown_missing_selector_errors(browser, http_tab):
|
||||
assert "No element" in str(exc)
|
||||
|
||||
|
||||
def test_dom_exists(browser, http_tab):
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
result = browser("dom.exists", {"selector": "body"})
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_dom_query(browser, http_tab):
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
elements = browser("dom.query", {"selector": "body"})
|
||||
assert isinstance(elements, list)
|
||||
assert len(elements) > 0
|
||||
assert elements[0].get("tag") == "body"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for group.* commands."""
|
||||
import pytest
|
||||
from browser_cli.client import send_command
|
||||
|
||||
|
||||
def test_group_list(browser):
|
||||
|
||||
@@ -84,3 +84,47 @@ def test_stdin_reader_routes_response_messages(monkeypatch):
|
||||
|
||||
assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
|
||||
native_host.PENDING.clear()
|
||||
|
||||
|
||||
def test_collect_paged_browser_command_accumulates_pages(monkeypatch):
|
||||
calls = []
|
||||
pages = iter([
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [1, 2], "total": 3, "nextOffset": 2}},
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [3], "total": 3, "nextOffset": None}},
|
||||
])
|
||||
|
||||
def fake_send(cmd):
|
||||
calls.append(cmd)
|
||||
return next(pages)
|
||||
|
||||
monkeypatch.setattr(native_host, "PAGE_SIZE", 2)
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", fake_send)
|
||||
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {"foo": "bar"}})
|
||||
|
||||
assert result == {"id": "orig", "success": True, "data": [1, 2, 3], "pageSize": 2, "total": 3}
|
||||
assert [call["args"]["__page"] for call in calls] == [
|
||||
{"offset": 0, "limit": 2},
|
||||
{"offset": 2, "limit": 2},
|
||||
]
|
||||
assert all(call["args"]["foo"] == "bar" for call in calls)
|
||||
assert all(call["id"] != "orig" for call in calls)
|
||||
|
||||
|
||||
def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch):
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}})
|
||||
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
|
||||
assert result == {"id": "orig", "success": True, "data": {"value": 1}}
|
||||
|
||||
|
||||
def test_handle_browser_command_pages_known_list_commands(monkeypatch):
|
||||
seen = []
|
||||
|
||||
monkeypatch.setattr(native_host, "_collect_paged_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": []})
|
||||
|
||||
result = native_host._handle_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
|
||||
assert result == {"success": True, "data": []}
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""Tests for navigate.* commands."""
|
||||
import pytest
|
||||
from browser_cli.client import send_command
|
||||
|
||||
|
||||
def test_nav_open_and_close(browser):
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import json
|
||||
|
||||
from browser_cli.registry import load_registry, save_registry, update_registry
|
||||
|
||||
|
||||
def test_load_registry_tolerates_trailing_garbage_from_old_non_atomic_writes(tmp_path):
|
||||
registry = tmp_path / "registry.json"
|
||||
registry.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
|
||||
|
||||
assert load_registry(registry) == {"main": "/tmp/.browser_cli/main.sock"}
|
||||
|
||||
|
||||
def test_update_registry_repairs_corrupted_registry_and_preserves_entries(tmp_path):
|
||||
registry = tmp_path / "registry.json"
|
||||
registry.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
|
||||
|
||||
update_registry("work", "/tmp/.browser_cli/work.sock", registry)
|
||||
|
||||
assert json.loads(registry.read_text(encoding="utf-8")) == {
|
||||
"main": "/tmp/.browser_cli/main.sock",
|
||||
"work": "/tmp/.browser_cli/work.sock",
|
||||
}
|
||||
|
||||
|
||||
def test_save_registry_writes_valid_json_atomically(tmp_path):
|
||||
registry = tmp_path / "registry.json"
|
||||
|
||||
save_registry({"main": "/tmp/main.sock"}, registry)
|
||||
|
||||
assert json.loads(registry.read_text(encoding="utf-8")) == {"main": "/tmp/main.sock"}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Remote protocol compatibility matrix for post-quantum transport.
|
||||
|
||||
These tests exercise the wire-level combinations that matter for mixed
|
||||
browser-cli versions without requiring a real browser. The native-host lookup is
|
||||
mocked so successful auth/transport reaches the proxy layer and then returns the
|
||||
expected "browser not connected" error.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth import (
|
||||
generate_keypair,
|
||||
load_private_key,
|
||||
pq_decrypt,
|
||||
pq_encrypt,
|
||||
pq_kex_client_encapsulate,
|
||||
pq_kex_server_decapsulate,
|
||||
pq_kex_server_keypair,
|
||||
sign,
|
||||
)
|
||||
from browser_cli.client import BrowserNotConnected, send_command
|
||||
from browser_cli.commands.serve import _handle_client
|
||||
|
||||
def _send_framed(sock: socket.socket, msg: dict) -> None:
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
sock.sendall(struct.pack("<I", len(payload)) + payload)
|
||||
|
||||
def _recv_framed(sock: socket.socket) -> dict:
|
||||
raw_len = b""
|
||||
while len(raw_len) < 4:
|
||||
chunk = sock.recv(4 - len(raw_len))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before response header")
|
||||
raw_len += chunk
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
data = b""
|
||||
while len(data) < msg_len:
|
||||
chunk = sock.recv(msg_len - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-response")
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
|
||||
@pytest.fixture()
|
||||
def auth_material(tmp_path):
|
||||
pem, pub = generate_keypair()
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
auth_path = tmp_path / "authorized_keys"
|
||||
auth_path.write_text(pub + "\n", encoding="utf-8")
|
||||
return key_path, auth_path, load_private_key(key_path), pub
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_browser(monkeypatch):
|
||||
def _raise_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _raise_no_browser)
|
||||
|
||||
def _connect(auth_keys_path):
|
||||
client, server = socket.socketpair()
|
||||
thread = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, auth_keys_path),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
challenge = _recv_framed(client)
|
||||
return client, thread, challenge
|
||||
|
||||
def _pq_auth_message(priv, pub: str, nonce_hex: str, command_msg: dict, challenge: dict, *, encrypted: bool) -> tuple[dict, bytes]:
|
||||
if "pq_kex" not in challenge:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
|
||||
ciphertext_hex, shared_secret = pq_kex_client_encapsulate(challenge["pq_kex"]["public_key"])
|
||||
clean_msg = {
|
||||
**command_msg,
|
||||
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": ciphertext_hex},
|
||||
}
|
||||
sig = sign(priv, bytes.fromhex(nonce_hex), clean_msg, shared_secret).hex()
|
||||
if not encrypted:
|
||||
return {**clean_msg, "pubkey": pub, "sig": sig}, shared_secret
|
||||
|
||||
envelope = {
|
||||
"id": clean_msg["id"],
|
||||
"user_agent": clean_msg["user_agent"],
|
||||
"pubkey": pub,
|
||||
"sig": sig,
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": pq_encrypt(shared_secret, "request", json.dumps(clean_msg).encode("utf-8")),
|
||||
}
|
||||
return envelope, shared_secret
|
||||
|
||||
def _assert_browser_not_connected(resp: dict) -> None:
|
||||
assert resp.get("success") is False
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
|
||||
def test_real_mlkem_primitive_roundtrip():
|
||||
keypair = pq_kex_server_keypair()
|
||||
if keypair is None:
|
||||
pytest.skip("ML-KEM backend not available")
|
||||
private_key, public_key = keypair
|
||||
|
||||
ciphertext_hex, client_secret = pq_kex_client_encapsulate(public_key.hex())
|
||||
server_secret = pq_kex_server_decapsulate(private_key, ciphertext_hex)
|
||||
|
||||
assert server_secret == client_secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("client_version", "encrypted", "expect_encrypted_response"),
|
||||
[
|
||||
("0.9.3", False, False), # legacy client stays compatible
|
||||
("0.9.5", True, True), # current client must use encrypted transport
|
||||
],
|
||||
)
|
||||
def test_remote_protocol_version_matrix(auth_material, client_version, encrypted, expect_encrypted_response):
|
||||
selected_version = os.environ.get("BROWSER_CLI_COMPAT_CLIENT_VERSION")
|
||||
if selected_version and selected_version != client_version:
|
||||
pytest.skip(f"compat matrix selected {selected_version}")
|
||||
|
||||
_key_path, auth_path, priv, pub = auth_material
|
||||
client, thread, challenge = _connect(auth_path)
|
||||
|
||||
msg = {
|
||||
"id": f"tabs-{client_version}",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": f"browser-cli/{client_version}",
|
||||
}
|
||||
wire_msg, shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=encrypted)
|
||||
_send_framed(client, wire_msg)
|
||||
resp = _recv_framed(client)
|
||||
|
||||
if expect_encrypted_response:
|
||||
assert set(resp) == {"encrypted"}
|
||||
resp = json.loads(pq_decrypt(shared_secret, "response", resp["encrypted"]))
|
||||
else:
|
||||
assert "encrypted" not in resp
|
||||
|
||||
_assert_browser_not_connected(resp)
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_current_client_plaintext_transport_is_rejected(auth_material):
|
||||
_key_path, auth_path, priv, pub = auth_material
|
||||
client, thread, challenge = _connect(auth_path)
|
||||
|
||||
msg = {
|
||||
"id": "new-plain",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
}
|
||||
wire_msg, _shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=False)
|
||||
_send_framed(client, wire_msg)
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp.get("success") is False
|
||||
assert "encrypted transport" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_send_command_uses_encrypted_remote_transport(auth_material):
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
host, port = server.getsockname()
|
||||
|
||||
def _accept_once():
|
||||
conn, addr = server.accept()
|
||||
_handle_client(conn, addr, None, auth_path)
|
||||
server.close()
|
||||
|
||||
thread = threading.Thread(target=_accept_once, daemon=True)
|
||||
thread.start()
|
||||
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
|
||||
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch):
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: None)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
host, port = server.getsockname()
|
||||
|
||||
def _accept_once():
|
||||
conn, addr = server.accept()
|
||||
_handle_client(conn, addr, None, auth_path)
|
||||
server.close()
|
||||
|
||||
thread = threading.Thread(target=_accept_once, daemon=True)
|
||||
thread.start()
|
||||
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
|
||||
|
||||
assert "not using a post-quantum key exchange" in stderr.getvalue()
|
||||
thread.join(timeout=2)
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Unit tests for the TCP serve layer (challenge-response auth, framing, rejection paths)."""
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth import generate_keypair, load_private_key, new_nonce, pq_decrypt, pq_encrypt, sign
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.commands.serve import _handle_client
|
||||
|
||||
FAKE_UA = "browser-cli/0.9.3"
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _send_framed(sock: socket.socket, data: bytes) -> None:
|
||||
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||
|
||||
|
||||
def _recv_framed(sock: socket.socket) -> dict:
|
||||
raw = b""
|
||||
while len(raw) < 4:
|
||||
chunk = sock.recv(4 - len(raw))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before response header")
|
||||
raw += chunk
|
||||
n = struct.unpack("<I", raw)[0]
|
||||
data = b""
|
||||
while len(data) < n:
|
||||
chunk = sock.recv(n - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-response")
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server_sock, ("127.0.0.1", 9999), None, auth_keys_path),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def _pair():
|
||||
return socket.socketpair()
|
||||
|
||||
|
||||
def _mock_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
|
||||
|
||||
# ── challenge frame ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestChallenge:
|
||||
def test_challenge_sent_on_connect(self):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
challenge = _recv_framed(client)
|
||||
assert challenge["type"] == "challenge"
|
||||
assert "nonce" in challenge
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_challenge_includes_version_fields(self):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
challenge = _recv_framed(client)
|
||||
assert "server_version" in challenge
|
||||
assert "min_client_version" in challenge
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_challenge_advertises_post_quantum_kex_when_available(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path)
|
||||
challenge = _recv_framed(client)
|
||||
|
||||
assert challenge["pq_kex"] == {"alg": "ML-KEM-768", "public_key": b"fake-public".hex()}
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
# ── rejection paths ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRejection:
|
||||
def _connect(self, auth_keys_path):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, auth_keys_path)
|
||||
challenge = _recv_framed(client)
|
||||
return client, t, challenge
|
||||
|
||||
def test_bad_user_agent_rejected(self):
|
||||
client, t, _ = self._connect(None)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "curl/7.88"}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "forbidden" in resp["error"].lower() or "client" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_missing_pubkey_sig_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
client, t, _ = self._connect(path)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "unauthorized" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_untrusted_pubkey_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, trusted_pub = generate_keypair()
|
||||
path.write_text(trusted_pub + "\n")
|
||||
|
||||
pem, untrusted_pub = generate_keypair()
|
||||
key_path = tmp_path / "other.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
client, t, challenge = self._connect(path)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": untrusted_pub}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "untrusted" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_bad_signature_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
client, t, _ = self._connect(path)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub, "sig": "00" * 64}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "signature" in resp["error"].lower() or "invalid" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_missing_post_quantum_kex_rejected_when_required(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
priv_path = tmp_path / "client.pem"
|
||||
priv_path.write_bytes(pem)
|
||||
priv = load_private_key(priv_path)
|
||||
|
||||
client, t, challenge = self._connect(path)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.5", "pubkey": pub}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "post-quantum" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_oversized_message_rejected(self):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
_recv_framed(client) # consume challenge
|
||||
client.sendall(struct.pack("<I", 33 * 1024 * 1024))
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "too large" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_invalid_json_rejected(self):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
_recv_framed(client) # consume challenge
|
||||
bad = b"this is not json {"
|
||||
_send_framed(client, bad)
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
# ── auth success paths ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuthSuccess:
|
||||
def test_valid_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""Correct signature → error must be 'browser not connected', not 'unauthorized'."""
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, path),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "unauthorized" not in resp["error"].lower()
|
||||
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_uppercase_pubkey_normalized_by_compat(self, tmp_path, monkeypatch):
|
||||
"""Clients < 0.9.3 may send uppercase pubkeys; compat layer normalises before auth."""
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair() # pub is lowercase hex
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path)
|
||||
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
# old client sends uppercase pubkey
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.2", "pubkey": pub.upper()}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
|
||||
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, path),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {
|
||||
"id": "x",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": FAKE_UA,
|
||||
"pubkey": pub,
|
||||
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
|
||||
}
|
||||
msg["sig"] = sign(priv, nonce, msg, b"pq-secret").hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_post_quantum_encrypted_transport_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""New clients encrypt the command payload and receive encrypted responses."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
|
||||
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, path),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
clean_msg = {
|
||||
"id": "x",
|
||||
"command": "tabs.list",
|
||||
"args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
|
||||
}
|
||||
sig = sign(priv, nonce, clean_msg, b"pq-secret").hex()
|
||||
envelope = {
|
||||
"id": "x",
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
"pubkey": pub,
|
||||
"sig": sig,
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": pq_encrypt(b"pq-secret", "request", json.dumps(clean_msg).encode()),
|
||||
}
|
||||
_send_framed(client, json.dumps(envelope).encode())
|
||||
encrypted_resp = _recv_framed(client)
|
||||
|
||||
assert "encrypted" in encrypted_resp
|
||||
resp = json.loads(pq_decrypt(b"pq-secret", "response", encrypted_resp["encrypted"]))
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_no_auth_mode_reaches_proxy(self, monkeypatch):
|
||||
"""auth_keys_path=None (--no-auth): no pubkey required, reaches proxy layer."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, None),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
_recv_framed(client) # challenge
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for session.* commands."""
|
||||
import time
|
||||
import pytest
|
||||
from browser_cli.client import send_command
|
||||
from tests.conftest import TEST_BROWSER_PROFILE
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for tabs.* commands."""
|
||||
import pytest
|
||||
from browser_cli.client import send_command
|
||||
|
||||
|
||||
def test_tabs_list(browser):
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""Tests for windows.* commands."""
|
||||
import pytest
|
||||
from browser_cli.client import send_command
|
||||
|
||||
|
||||
def test_windows_list(browser):
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["chrome"],
|
||||
"allowJs": false,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["extension/src/**/*.ts"]
|
||||
}
|
||||
@@ -4,10 +4,11 @@ requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "browser-cli"
|
||||
version = "0.5.7"
|
||||
version = "0.9.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
@@ -19,22 +20,105 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8" },
|
||||
{ name = "cryptography", specifier = ">=48" },
|
||||
{ name = "rich", specifier = ">=13" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.2"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -46,6 +130,66 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
@@ -90,11 +234,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -106,6 +250,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -135,15 +288,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user