Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8dece7800f
|
|||
|
479a0f1964
|
|||
|
371b794170
|
|||
|
0ac652beee
|
|||
|
7cb2a8b618
|
|||
|
0b43408a8d
|
|||
|
657b1b0923
|
@@ -17,8 +17,31 @@ jobs:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
- name: Build Gitea package
|
||||
run: |
|
||||
# Keep the public/PyPI distribution as real-browser-cli in the repo,
|
||||
# but publish the private Gitea package under browser-cli.
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
replacements = {
|
||||
Path("pyproject.toml"): (
|
||||
'name = "real-browser-cli"',
|
||||
'name = "browser-cli"',
|
||||
),
|
||||
Path("browser_cli/constants.py"): (
|
||||
'PYPI_PACKAGE_NAME = "real-browser-cli"',
|
||||
'PYPI_PACKAGE_NAME = "browser-cli"',
|
||||
),
|
||||
}
|
||||
|
||||
for path, (old, new) in replacements.items():
|
||||
text = path.read_text()
|
||||
if old not in text:
|
||||
raise SystemExit(f"expected text not found in {path}: {old}")
|
||||
path.write_text(text.replace(old, new, 1))
|
||||
PY
|
||||
uv build
|
||||
|
||||
- name: Publish to Gitea
|
||||
run: |
|
||||
|
||||
@@ -5,6 +5,11 @@ extension/test-dist/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Local secrets / signing keys
|
||||
secrets/
|
||||
*.pem
|
||||
*.pem.gpg
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
# browser-cli
|
||||
Control your real, running browser from the terminal or the Python SDK — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
|
||||
Control your real, running browser from the terminal, Python SDK, or a trusted remote client — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
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 browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel.
|
||||
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel, or through the optional authenticated TCP remote bridge.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
```
|
||||
terminal / python script
|
||||
terminal / python script / remote client
|
||||
│
|
||||
│ Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
|
||||
│ or TCP remote bridge (Ed25519 auth, optional compression)
|
||||
▼
|
||||
Native Messaging Host (Python process, launched by the browser)
|
||||
│
|
||||
@@ -33,7 +34,7 @@ terminal / python script
|
||||
4. CLI commands connect to that socket, send a JSON command, and wait for the result.
|
||||
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
|
||||
|
||||
No server needs to be running beforehand. The browser manages the native host's lifecycle.
|
||||
No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
|
||||
|
||||
**Message format**
|
||||
|
||||
@@ -53,7 +54,7 @@ Every response:
|
||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
|
||||
|
||||
### Install with uv
|
||||
Once published on PyPI, install the CLI as a uv tool:
|
||||
Install the CLI from PyPI as a uv tool:
|
||||
|
||||
```sh
|
||||
uv tool install real-browser-cli
|
||||
@@ -100,62 +101,42 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
|
||||
```text
|
||||
browser-cli/
|
||||
├── browser_cli/
|
||||
│ ├── __init__.py # Python SDK — BrowserCLI class and SDK entry point
|
||||
│ ├── cli.py # Click CLI entry point
|
||||
│ ├── client/ # Client-side command routing used by CLI and SDK
|
||||
│ │ ├── core.py # send_command and remote command routing
|
||||
│ │ ├── targets.py # Browser target discovery and socket resolution
|
||||
│ │ ├── auth.py # Remote auth fields and key lookup
|
||||
│ │ └── messages.py # Request/response helpers
|
||||
│ ├── models.py # Tab and Group helper models
|
||||
│ ├── native/ # Native messaging host internals
|
||||
│ │ ├── host.py # Browser-launched native host entry point
|
||||
│ │ ├── local_server.py # Local CLI IPC server
|
||||
│ │ └── protocol.py # Chrome Native Messaging framing
|
||||
│ ├── remote/ # Client-side remote browser support
|
||||
│ │ ├── transport.py # TCP/TLS remote transport
|
||||
│ │ └── registry.py # Saved remote endpoints/keys
|
||||
│ └── commands/
|
||||
│ ├── navigate.py # nav open/reload/back/forward/focus
|
||||
│ ├── search.py # search engine shortcuts
|
||||
│ ├── tabs.py # tab management
|
||||
│ ├── groups.py # tab group management
|
||||
│ ├── windows.py # window management
|
||||
│ ├── dom.py # DOM querying and interaction
|
||||
│ ├── extract.py # content extraction
|
||||
│ └── session.py # session save/load
|
||||
│ ├── __init__.py # Public sync SDK: BrowserCLI and namespace wiring
|
||||
│ ├── async_sdk.py # AsyncBrowserCLI
|
||||
│ ├── cli.py # Click root command and native-host entry point
|
||||
│ ├── client/ # send_command path, local/remote routing, message helpers
|
||||
│ ├── sdk/ # SDK namespaces: nav, tabs, groups, windows, dom, session, ...
|
||||
│ ├── commands/ # CLI presentation layer over the SDK namespaces
|
||||
│ ├── native/ # Browser-launched Native Messaging host + local IPC server
|
||||
│ ├── remote/ # TCP remote client transport and saved endpoint registry
|
||||
│ ├── serve/ # Authenticated TCP server runtime
|
||||
│ ├── transport/ # JSON/msgpack response encoding and compression helpers
|
||||
│ ├── markdown/ # HTML-to-Markdown extraction helpers
|
||||
│ ├── auth/ # Ed25519 keys, signing, SSH-agent/YubiKey helpers, PQ KEX
|
||||
│ └── models.py # Tab, Group, BrowserCounts dataclasses
|
||||
├── extension/
|
||||
│ ├── manifest.json # MV3 extension manifest
|
||||
│ ├── content.js # Content-script helpers
|
||||
│ └── src/ # TypeScript source split by command area
|
||||
│ ├── index.ts # Builds generated extension/background.js
|
||||
│ └── content/ # Builds generated extension/content-dispatch.js
|
||||
├── examples/
|
||||
│ ├── demo.py # Python SDK walkthrough
|
||||
│ └── demo.sh # Bash CLI walkthrough
|
||||
├── tests/
|
||||
│ ├── conftest.py # shared pytest fixtures
|
||||
│ ├── test_api.py
|
||||
│ ├── test_cli.py
|
||||
│ ├── test_dom.py
|
||||
│ ├── test_extract.py
|
||||
│ ├── test_groups.py
|
||||
│ ├── test_nav.py
|
||||
│ ├── test_session.py
|
||||
│ ├── test_tabs.py
|
||||
│ └── test_windows.py
|
||||
├── com.browsercli.host.json # native messaging manifest template
|
||||
├── pyproject.toml # package metadata and CLI entry point
|
||||
└── uv.lock # locked dependencies for uv
|
||||
│ ├── manifest.json # Chromium MV3 manifest
|
||||
│ └── src/ # TypeScript WebExtension source
|
||||
│ ├── index.ts # Background/service-worker bundle entry
|
||||
│ ├── content-dispatch.ts
|
||||
│ ├── commands/ # Browser-side command implementations
|
||||
│ ├── content/ # DOM/extract/Markdown logic injected into pages
|
||||
│ └── core/ # Shared extension helpers
|
||||
├── examples/ # Python and shell walkthroughs
|
||||
├── scripts/ # Packaging and release helper scripts
|
||||
├── tests/ # pytest suite
|
||||
├── package.json # Extension build/test/package scripts
|
||||
├── pyproject.toml # Python package metadata
|
||||
└── uv.lock # locked Python dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI reference
|
||||
|
||||
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
|
||||
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
|
||||
|
||||
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
|
||||
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
|
||||
|
||||
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
|
||||
|
||||
@@ -315,6 +296,27 @@ browser-cli completion zsh # print setup instructions
|
||||
browser-cli completion zsh --script # output raw completion script
|
||||
```
|
||||
|
||||
### Remote control, auth, and gateways
|
||||
|
||||
```sh
|
||||
# On the machine with the browser
|
||||
browser-cli auth keygen --output ~/.config/browser-cli/client.key
|
||||
PUBKEY=$(browser-cli auth show --key ~/.config/browser-cli/client.key | tail -n1)
|
||||
browser-cli auth trust "$PUBKEY"
|
||||
browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.config/browser-cli/authorized_keys
|
||||
|
||||
# From another machine
|
||||
browser-cli --remote browser-host.example:8765 --key ~/.config/browser-cli/client.key tabs list
|
||||
browser-cli remote trust browser-host.example:8765 ~/.config/browser-cli/client.key
|
||||
browser-cli --remote browser-host.example:8765 clients
|
||||
|
||||
# Local HTTP JSON gateway for small integrations
|
||||
browser-cli serve-http --port 8766
|
||||
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
|
||||
```
|
||||
|
||||
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Python SDK
|
||||
@@ -325,7 +327,7 @@ from browser_cli import AsyncBrowserCLI, BrowserCLI
|
||||
b = BrowserCLI()
|
||||
```
|
||||
|
||||
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
|
||||
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. Create `BrowserCLI(remote="host:8765", key="client.key")` to target a remote server. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
|
||||
|
||||
```python
|
||||
# Navigation ── b.nav
|
||||
@@ -480,6 +482,7 @@ counts = b.tabs.count()
|
||||
if isinstance(counts, BrowserCounts):
|
||||
print(counts.total)
|
||||
print(counts.by_browser)
|
||||
print(counts.browser_groups) # e.g. {"local:work": "local", "remote:work": "remote"}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -515,12 +518,22 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
|
||||
Packaging:
|
||||
|
||||
```bash
|
||||
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
|
||||
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
|
||||
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
|
||||
just publish # build to /tmp/dist-browser-cli and publish with .env credentials
|
||||
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
|
||||
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
|
||||
npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
|
||||
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
|
||||
```
|
||||
|
||||
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For Firefox, use the `*-firefox-*` zip.
|
||||
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key:
|
||||
|
||||
```bash
|
||||
scripts/setup_verified_crx_key.sh --recipient '<your GPG key id or email>'
|
||||
# Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads.
|
||||
npm run package:extension:webstore:verified
|
||||
```
|
||||
|
||||
The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip.
|
||||
|
||||
For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Commands are grouped into namespaces on the client:
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
|
||||
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async
|
||||
from browser_cli.client import active_browser_targets, remote_browser_targets, remote_targets_for_alias, send_command, send_command_async
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.models import BrowserCounts, Group, Tab
|
||||
from browser_cli.sdk import (
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import functools
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
from typing import TypeVar, cast
|
||||
|
||||
from browser_cli.models import Group, Tab
|
||||
from browser_cli.sdk import NAMESPACE_NAMES
|
||||
@@ -74,7 +74,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
||||
finally:
|
||||
if cleanup is not None:
|
||||
await self._maybe_await(cleanup(value))
|
||||
return wrapper # type: ignore[return-value]
|
||||
return cast(F, wrapper)
|
||||
return decorator(func) if func is not None else decorator
|
||||
|
||||
def new_tab(
|
||||
@@ -117,7 +117,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
||||
finally:
|
||||
if previous:
|
||||
await self._c.perf.set_profile(previous)
|
||||
return wrapper # type: ignore[return-value]
|
||||
return cast(F, wrapper)
|
||||
return decorator
|
||||
|
||||
def retry(
|
||||
@@ -142,8 +142,8 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
||||
raise
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
raise last_error # type: ignore[misc]
|
||||
return wrapper # type: ignore[return-value]
|
||||
raise cast(BaseException, last_error)
|
||||
return cast(F, wrapper)
|
||||
return decorator
|
||||
|
||||
class AsyncBrowserCLI:
|
||||
@@ -220,18 +220,40 @@ class AsyncBrowserCLI:
|
||||
async def clients(self) -> list[dict]:
|
||||
return await self._cmd("clients.list", {})
|
||||
|
||||
def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab:
|
||||
def tab_from(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_type: str | None = None,
|
||||
browser_group: str | None = None,
|
||||
) -> Tab:
|
||||
return self._sync.tab_from(
|
||||
data,
|
||||
browser_profile=browser_profile,
|
||||
browser_name=browser_name,
|
||||
browser_remote=browser_remote,
|
||||
browser_type=browser_type,
|
||||
browser_group=browser_group,
|
||||
)
|
||||
|
||||
def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group:
|
||||
def group_from(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_type: str | None = None,
|
||||
browser_group: str | None = None,
|
||||
) -> Group:
|
||||
return self._sync.group_from(
|
||||
data,
|
||||
browser_profile=browser_profile,
|
||||
browser_name=browser_name,
|
||||
browser_remote=browser_remote,
|
||||
browser_type=browser_type,
|
||||
browser_group=browser_group,
|
||||
)
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
from browser_cli.constants import (
|
||||
DEFAULT_AUTHORIZED_KEYS_PATH,
|
||||
DEFAULT_KEY_PATH,
|
||||
PQ_KEX_ALG,
|
||||
PQ_TRANSPORT_ALG,
|
||||
SSH_AGENT_IDENTITIES_ANSWER,
|
||||
SSH_AGENT_SIGN_RESPONSE,
|
||||
SSH_AGENTC_REQUEST_IDENTITIES,
|
||||
SSH_AGENTC_SIGN_REQUEST,
|
||||
)
|
||||
|
||||
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) ────────────────────────────────
|
||||
|
||||
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
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Public auth API for browser-cli.
|
||||
|
||||
Implementation lives in focused modules:
|
||||
- ``auth.agent``: SSH-agent/YubiKey helpers
|
||||
- ``auth.keys``: file keys and authorized_keys management
|
||||
- ``auth.signing``: canonical payload signing/verification
|
||||
- ``auth.pq``: ML-KEM KEX and encrypted transport helpers
|
||||
"""
|
||||
from browser_cli.auth.agent import (
|
||||
AgentKey,
|
||||
agent_find_key,
|
||||
agent_list_keys,
|
||||
agent_roundtrip as _agent_roundtrip,
|
||||
agent_sign_raw,
|
||||
pack_ssh_string as _pack_str,
|
||||
unpack_ssh_string as _unpack_str,
|
||||
)
|
||||
from browser_cli.auth.keys import (
|
||||
add_authorized_key,
|
||||
generate_keypair,
|
||||
load_authorized_keys,
|
||||
load_authorized_keys_with_names,
|
||||
load_private_key,
|
||||
public_key_hex,
|
||||
)
|
||||
from browser_cli.auth.pq import (
|
||||
new_nonce,
|
||||
pq_decrypt,
|
||||
pq_encrypt,
|
||||
pq_kex_client_encapsulate,
|
||||
pq_kex_server_decapsulate,
|
||||
pq_kex_server_keypair,
|
||||
pq_transport_key as _pq_transport_key,
|
||||
)
|
||||
from browser_cli.auth.signing import (
|
||||
auth_message as _auth_message,
|
||||
canonical_payload,
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
from browser_cli.constants import DEFAULT_AUTHORIZED_KEYS_PATH, DEFAULT_KEY_PATH, PQ_KEX_ALG, PQ_TRANSPORT_ALG
|
||||
|
||||
__all__ = [
|
||||
"AgentKey",
|
||||
"DEFAULT_AUTHORIZED_KEYS_PATH",
|
||||
"DEFAULT_KEY_PATH",
|
||||
"PQ_KEX_ALG",
|
||||
"PQ_TRANSPORT_ALG",
|
||||
"add_authorized_key",
|
||||
"agent_find_key",
|
||||
"agent_list_keys",
|
||||
"agent_sign_raw",
|
||||
"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",
|
||||
"public_key_hex",
|
||||
"sign",
|
||||
"verify",
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""SSH-agent backed Ed25519 key helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
from browser_cli.constants import (
|
||||
SSH_AGENT_IDENTITIES_ANSWER,
|
||||
SSH_AGENT_SIGN_RESPONSE,
|
||||
SSH_AGENTC_REQUEST_IDENTITIES,
|
||||
SSH_AGENTC_SIGN_REQUEST,
|
||||
)
|
||||
|
||||
def pack_ssh_string(value: bytes) -> bytes:
|
||||
return struct.pack(">I", len(value)) + value
|
||||
|
||||
def unpack_ssh_string(data: bytes, offset: int) -> tuple[bytes, int]:
|
||||
length = struct.unpack_from(">I", data, offset)[0]
|
||||
return data[offset + 4 : offset + 4 + length], offset + 4 + length
|
||||
|
||||
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
|
||||
length = struct.unpack(">I", raw_len)[0]
|
||||
response = b""
|
||||
while len(response) < length:
|
||||
chunk = sock.recv(length - len(response))
|
||||
if not chunk:
|
||||
raise RuntimeError("SSH agent closed connection mid-response")
|
||||
response += chunk
|
||||
return response
|
||||
|
||||
@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, offset = unpack_ssh_string(self.blob, 0)
|
||||
key_bytes, _ = unpack_ssh_string(self.blob, offset)
|
||||
return key_bytes
|
||||
|
||||
def agent_list_keys() -> list[AgentKey]:
|
||||
"""Return all Ed25519 keys currently held by the SSH agent."""
|
||||
response = agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
|
||||
if response[0] != SSH_AGENT_IDENTITIES_ANSWER:
|
||||
raise RuntimeError(f"Unexpected agent response: {response[0]}")
|
||||
key_count = struct.unpack_from(">I", response, 1)[0]
|
||||
keys: list[AgentKey] = []
|
||||
offset = 5
|
||||
for _ in range(key_count):
|
||||
blob, offset = unpack_ssh_string(response, offset)
|
||||
comment, offset = unpack_ssh_string(response, offset)
|
||||
algo, _ = unpack_ssh_string(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_ssh_string(key.blob)
|
||||
+ pack_ssh_string(data)
|
||||
+ struct.pack(">I", 0)
|
||||
)
|
||||
response = agent_roundtrip(msg)
|
||||
if response[0] != SSH_AGENT_SIGN_RESPONSE:
|
||||
raise RuntimeError(f"SSH agent refused to sign (response code {response[0]})")
|
||||
sig_blob, _ = unpack_ssh_string(response, 1)
|
||||
_algo, sig_offset = unpack_ssh_string(sig_blob, 0)
|
||||
raw_sig, _ = unpack_ssh_string(sig_blob, sig_offset)
|
||||
if len(raw_sig) != 64:
|
||||
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
|
||||
return raw_sig
|
||||
@@ -0,0 +1,59 @@
|
||||
"""File-based Ed25519 keys and authorized_keys helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
load_pem_private_key,
|
||||
)
|
||||
|
||||
from browser_cli.auth.agent import AgentKey
|
||||
|
||||
def generate_keypair() -> tuple[bytes, str]:
|
||||
"""Return (private_key_pem_bytes, public_key_hex)."""
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
||||
public_hex = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||
return pem, public_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()
|
||||
|
||||
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 [pubkey for pubkey, _name 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 = {pubkey for pubkey, _name 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 file:
|
||||
file.write(line)
|
||||
return True
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Post-quantum ML-KEM key exchange and app-layer transport encryption."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from browser_cli.constants import PQ_TRANSPORT_ALG
|
||||
|
||||
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
|
||||
private_key = mlkem.MLKEM768PrivateKey.generate()
|
||||
public_key = private_key.public_key().public_bytes_raw()
|
||||
return private_key, public_key
|
||||
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
|
||||
public_key = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
|
||||
shared_secret, ciphertext = public_key.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)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Canonical browser-cli auth payload signing and verification."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
from browser_cli.auth.agent import AgentKey, agent_sign_raw
|
||||
|
||||
def canonical_payload(msg: dict) -> bytes:
|
||||
"""Deterministic JSON encoding of msg without auth protocol fields."""
|
||||
return json.dumps(
|
||||
{key: value for key, value in msg.items() if key 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
|
||||
@@ -10,6 +10,7 @@ from browser_cli.client.core import (
|
||||
remote_browser_targets,
|
||||
remote_browser_targets_async,
|
||||
remote_target_for_alias,
|
||||
remote_targets_for_alias,
|
||||
send_command,
|
||||
send_command_async,
|
||||
)
|
||||
@@ -42,6 +43,7 @@ __all__ = [
|
||||
"remote_browser_targets",
|
||||
"remote_browser_targets_async",
|
||||
"remote_target_for_alias",
|
||||
"remote_targets_for_alias",
|
||||
"send_command",
|
||||
"send_command_async",
|
||||
]
|
||||
|
||||
+27
-12
@@ -25,12 +25,16 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
||||
for item in items or []:
|
||||
profile = str(item.get("profile") or "default")
|
||||
display = str(item.get("displayName") or profile)
|
||||
display_name = _remote_display_name(endpoint, profile, display)
|
||||
browser_name = item.get("browserName") or item.get("name")
|
||||
targets.append(
|
||||
BrowserTarget(
|
||||
profile=profile,
|
||||
display_name=_remote_display_name(endpoint, profile, display),
|
||||
display_name=display_name,
|
||||
socket_path="",
|
||||
remote=endpoint,
|
||||
browser_name=str(browser_name) if browser_name else None,
|
||||
display_group=display_name.rsplit(":", 1)[0],
|
||||
)
|
||||
)
|
||||
return targets
|
||||
@@ -52,15 +56,21 @@ def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> l
|
||||
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."""
|
||||
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
|
||||
"""Return remote targets matching a user-facing alias.
|
||||
|
||||
Exact browser aliases such as ``host:profile`` return one target. Endpoint
|
||||
aliases such as ``host`` or ``host:8765`` may return multiple targets, which
|
||||
lets read/list SDK commands fan out while command dispatch can still reject
|
||||
the ambiguous target.
|
||||
"""
|
||||
if not alias:
|
||||
return None
|
||||
targets = _remote_browser_targets()
|
||||
return []
|
||||
targets = _remote_browser_targets(key=key) if key is not None else _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
|
||||
return [target]
|
||||
|
||||
endpoint_matches = []
|
||||
for target in targets:
|
||||
@@ -69,16 +79,21 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
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]
|
||||
if len(endpoint_matches) > 1:
|
||||
aliases = [target.profile for target in endpoint_matches]
|
||||
endpoint = endpoint_matches[0].remote or alias
|
||||
return endpoint_matches
|
||||
|
||||
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
||||
matches = remote_targets_for_alias(alias)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
aliases = [target.profile for target in matches]
|
||||
endpoint = matches[0].remote or alias or "remote"
|
||||
examples = "\n".join(
|
||||
f" browser-cli --remote {endpoint} --browser {a} ..."
|
||||
for a in aliases
|
||||
)
|
||||
display_aliases = [target.display_name for target in endpoint_matches]
|
||||
display_aliases = [target.display_name for target in matches]
|
||||
shorthand_examples = "\n".join(
|
||||
f" browser-cli --browser {a} ..."
|
||||
for a in display_aliases
|
||||
|
||||
@@ -17,6 +17,8 @@ class BrowserTarget:
|
||||
display_name: str
|
||||
socket_path: str
|
||||
remote: str | None = None
|
||||
browser_name: str | None = None
|
||||
display_group: str | None = None
|
||||
|
||||
def is_reachable_unix_endpoint(endpoint: str) -> bool:
|
||||
"""Return True when a Unix socket path exists and accepts connections."""
|
||||
|
||||
@@ -22,60 +22,74 @@ tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab I
|
||||
|
||||
|
||||
def gentle_mode_option(help_text: str):
|
||||
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
|
||||
return click.option(
|
||||
"--gentle-mode",
|
||||
type=click.Choice(GENTLE_MODES),
|
||||
default="auto",
|
||||
show_default=True,
|
||||
help=help_text,
|
||||
)
|
||||
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
|
||||
return click.option(
|
||||
"--gentle-mode",
|
||||
type=click.Choice(GENTLE_MODES),
|
||||
default="auto",
|
||||
show_default=True,
|
||||
help=help_text,
|
||||
)
|
||||
|
||||
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
|
||||
"""Render a count result.
|
||||
"""Render a count result.
|
||||
|
||||
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
|
||||
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
|
||||
"""
|
||||
if isinstance(result, BrowserCounts):
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Browser")
|
||||
table.add_column(f"{noun.capitalize()}s", justify="right")
|
||||
for name, count in result.by_browser.items():
|
||||
table.add_row(name, str(count))
|
||||
table.add_row("Total", str(result.total))
|
||||
_console.print(table)
|
||||
else:
|
||||
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
|
||||
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
|
||||
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
|
||||
"""
|
||||
if isinstance(result, BrowserCounts):
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Browser", no_wrap=True)
|
||||
table.add_column(f"{noun.capitalize()}s", justify="right")
|
||||
rendered_groups: set[str] = set()
|
||||
for name, count in result.by_browser.items():
|
||||
group = result.browser_groups.get(name)
|
||||
if group:
|
||||
if group not in rendered_groups:
|
||||
group_total = sum(
|
||||
browser_count
|
||||
for browser_name, browser_count in result.by_browser.items()
|
||||
if result.browser_groups.get(browser_name) == group
|
||||
)
|
||||
table.add_row(f"[bold]{group}[/bold]", str(group_total))
|
||||
rendered_groups.add(group)
|
||||
display_name = name.removeprefix(f"{group}:")
|
||||
table.add_row(f" {display_name}", str(count))
|
||||
else:
|
||||
table.add_row(name, str(count))
|
||||
table.add_row("Total", str(result.total))
|
||||
_console.print(table)
|
||||
else:
|
||||
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
|
||||
|
||||
def client_from_ctx() -> BrowserCLI:
|
||||
"""Build a BrowserCLI from the root context's global options.
|
||||
"""Build a BrowserCLI from the root context's global options.
|
||||
|
||||
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
|
||||
Falls back to an unconfigured client when a command group is invoked
|
||||
standalone (e.g. in unit tests).
|
||||
"""
|
||||
obj = click.get_current_context().find_root().obj or {}
|
||||
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
|
||||
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
|
||||
Falls back to an unconfigured client when a command group is invoked
|
||||
standalone (e.g. in unit tests).
|
||||
"""
|
||||
obj = click.get_current_context().find_root().obj or {}
|
||||
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
|
||||
|
||||
def handle_errors(fn):
|
||||
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
|
||||
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
|
||||
|
||||
Apply as the innermost decorator (directly above ``def``) so Click's option
|
||||
decorators attach their params to the wrapper.
|
||||
"""
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except BrowserNotConnected as e:
|
||||
_console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except PermissionError as e:
|
||||
_console.print(f"[red]Blocked:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
_console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
Apply as the innermost decorator (directly above ``def``) so Click's option
|
||||
decorators attach their params to the wrapper.
|
||||
"""
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except BrowserNotConnected as e:
|
||||
_console.print(f"[red]Error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except PermissionError as e:
|
||||
_console.print(f"[red]Blocked:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
except RuntimeError as e:
|
||||
_console.print(f"[red]Browser error:[/red] {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
return wrapper
|
||||
return wrapper
|
||||
|
||||
@@ -36,7 +36,7 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
|
||||
if alias in profiles and alias != target_profile:
|
||||
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
||||
|
||||
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False):
|
||||
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False, profile_group=None):
|
||||
"""Query clients.list for one target and append each, tagged with *label*."""
|
||||
if quiet_remote_warning:
|
||||
result = send_command(
|
||||
@@ -50,6 +50,8 @@ def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_r
|
||||
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = label
|
||||
if profile_group:
|
||||
c["profileGroup"] = profile_group
|
||||
into.append(c)
|
||||
|
||||
@click.group("clients", invoke_without_command=True)
|
||||
@@ -89,7 +91,14 @@ def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) ->
|
||||
sys.exit(1)
|
||||
for target in targets:
|
||||
try:
|
||||
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
|
||||
_append_clients(
|
||||
all_clients,
|
||||
target.display_name,
|
||||
profile=target.profile,
|
||||
remote=resolved.remote,
|
||||
key=key,
|
||||
profile_group=target.display_group,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
@@ -109,10 +118,11 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
||||
for profile_name, sock_path in profiles.items():
|
||||
display_profile = display_browser_name(profile_name, sock_path)
|
||||
try:
|
||||
_append_clients(all_clients, display_profile, profile=profile_name)
|
||||
_append_clients(all_clients, display_profile, profile=profile_name, profile_group="local")
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
all_clients.append({
|
||||
"profile": display_profile,
|
||||
"profileGroup": "local",
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
@@ -130,6 +140,7 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
||||
profile=target.profile,
|
||||
remote=target.remote,
|
||||
quiet_remote_warning=True,
|
||||
profile_group=target.display_group,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
@@ -137,13 +148,25 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
||||
def _print_clients(all_clients: list) -> None:
|
||||
from rich.table import Table
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Profile")
|
||||
table.add_column("Profile", no_wrap=True)
|
||||
table.add_column("Browser")
|
||||
table.add_column("Version")
|
||||
table.add_column("Extension Version")
|
||||
rendered_groups: set[str] = set()
|
||||
groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
|
||||
grouped = bool(groups and groups != {"local"})
|
||||
for c in all_clients:
|
||||
group = c.get("profileGroup") if grouped else None
|
||||
if group:
|
||||
if group not in rendered_groups:
|
||||
table.add_row(f"[bold]{group}[/bold]", "", "", "")
|
||||
rendered_groups.add(group)
|
||||
profile = str(c.get("profile", "")).removeprefix(f"{group}:")
|
||||
profile = f" {profile}"
|
||||
else:
|
||||
profile = c.get("profile", "")
|
||||
table.add_row(
|
||||
c.get("profile", ""),
|
||||
profile,
|
||||
c.get("name", ""),
|
||||
c.get("version", ""),
|
||||
c.get("extensionVersion", ""),
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
import click
|
||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
|
||||
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
def _print_groups(groups, *, show_browser: bool = False) -> None:
|
||||
if not groups:
|
||||
console.print("[yellow]No groups found[/yellow]")
|
||||
return
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
if show_browser:
|
||||
table.add_column("Browser")
|
||||
table.add_column("ID", style="dim", no_wrap=True)
|
||||
table.add_column("Name")
|
||||
table.add_column("Color", width=10)
|
||||
table.add_column("Collapsed", width=10)
|
||||
table.add_column("Tabs", width=6)
|
||||
for g in groups:
|
||||
row = [
|
||||
(g.browser or "") if show_browser else None,
|
||||
str(g.id),
|
||||
g.title or "",
|
||||
g.color or "",
|
||||
"yes" if g.collapsed else "no",
|
||||
str(g.tab_count),
|
||||
]
|
||||
table.add_row(*[value for value in row if value is not None])
|
||||
console.print(table)
|
||||
columns = [
|
||||
("ID", lambda g: g.id),
|
||||
("Name", lambda g: g.title or ""),
|
||||
("Color", lambda g: g.color or ""),
|
||||
("Collapsed", lambda g: "yes" if g.collapsed else "no"),
|
||||
("Tabs", lambda g: g.tab_count),
|
||||
]
|
||||
print_browser_grouped_table_rows(groups, columns, console=console, empty_message="[yellow]No groups found[/yellow]")
|
||||
|
||||
@click.group("groups")
|
||||
def group_group():
|
||||
|
||||
@@ -8,6 +8,7 @@ from rich.table import Table
|
||||
|
||||
from browser_cli import BrowserCLI
|
||||
from browser_cli.commands import handle_errors
|
||||
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
|
||||
|
||||
console = Console()
|
||||
@@ -23,14 +24,23 @@ def remote_group():
|
||||
def remote_status(endpoint, key):
|
||||
"""Probe a remote endpoint and show server/client status."""
|
||||
client = BrowserCLI(remote=endpoint, key=key)
|
||||
clients = client.clients()
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Profile")
|
||||
table.add_column("Browser")
|
||||
table.add_column("Extension")
|
||||
for item in clients:
|
||||
table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", "")))
|
||||
console.print(table)
|
||||
clients = [
|
||||
{**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint}
|
||||
for item in client.clients()
|
||||
]
|
||||
columns = [
|
||||
("Browser", lambda item: item.get("name", "")),
|
||||
("Extension", lambda item: item.get("extensionVersion", "")),
|
||||
]
|
||||
print_browser_grouped_table_rows(
|
||||
clients,
|
||||
columns,
|
||||
console=console,
|
||||
empty_message="[yellow]No browser clients found[/yellow]",
|
||||
browser_getter=lambda item: item.get("profileLabel", ""),
|
||||
group_getter=lambda item: item.get("profileGroup", ""),
|
||||
browser_header="Profile",
|
||||
)
|
||||
|
||||
@remote_group.command("trust")
|
||||
@click.argument("endpoint")
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Reusable rendering helpers for CLI command modules."""
|
||||
from browser_cli.commands.rendering.common import (
|
||||
Column,
|
||||
item_value,
|
||||
print_browser_grouped_table_rows,
|
||||
print_table_rows,
|
||||
print_tree,
|
||||
shorten,
|
||||
terminal_width,
|
||||
tree_title_limit,
|
||||
tree_url_limit,
|
||||
)
|
||||
from browser_cli.commands.rendering.labels import (
|
||||
BROWSER_FAMILY_STYLES,
|
||||
DEFAULT_BROWSER_STYLE,
|
||||
DEFAULT_SCOPE,
|
||||
browser_label_style,
|
||||
group_tree_label,
|
||||
no_wrap_text,
|
||||
scoped_browser_label,
|
||||
tab_tree_label,
|
||||
)
|
||||
from browser_cli.commands.rendering.tabs_tree import (
|
||||
TabsTreeBuilder,
|
||||
browser_label_key,
|
||||
browser_scope,
|
||||
build_tabs_tree,
|
||||
tab_group_id,
|
||||
tab_sort_key,
|
||||
tab_window_id,
|
||||
)
|
||||
from browser_cli.commands.rendering.windows_tree import build_windows_tree
|
||||
|
||||
__all__ = [
|
||||
"BROWSER_FAMILY_STYLES",
|
||||
"Column",
|
||||
"DEFAULT_BROWSER_STYLE",
|
||||
"DEFAULT_SCOPE",
|
||||
"TabsTreeBuilder",
|
||||
"browser_label_key",
|
||||
"browser_label_style",
|
||||
"browser_scope",
|
||||
"build_tabs_tree",
|
||||
"build_windows_tree",
|
||||
"group_tree_label",
|
||||
"item_value",
|
||||
"no_wrap_text",
|
||||
"print_browser_grouped_table_rows",
|
||||
"print_table_rows",
|
||||
"print_tree",
|
||||
"scoped_browser_label",
|
||||
"shorten",
|
||||
"tab_group_id",
|
||||
"tab_sort_key",
|
||||
"tab_tree_label",
|
||||
"tab_window_id",
|
||||
"terminal_width",
|
||||
"tree_title_limit",
|
||||
"tree_url_limit",
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Common Rich rendering helpers for CLI command modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import TypeVar, cast
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
Row = object
|
||||
CellValue = object
|
||||
Column = tuple[str, Callable[[Row], CellValue]]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def item_value(item: Row, name: str, default: T | None = None) -> CellValue | T | None:
|
||||
"""Read *name* from a dict-like or attribute object."""
|
||||
if isinstance(item, Mapping):
|
||||
return cast(Mapping[str, CellValue], item).get(name, default)
|
||||
return getattr(item, name, default)
|
||||
|
||||
def text_value(value: CellValue | None, default: str = "") -> str:
|
||||
"""Coerce a nullable cell value to display text."""
|
||||
return default if value is None else str(value)
|
||||
|
||||
def int_value(value: CellValue | None, default: int = 0) -> int:
|
||||
"""Coerce a cell value to int, falling back when conversion is not possible."""
|
||||
try:
|
||||
return int(cast(int | str | float | bool, value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def shorten(value: str | None, limit: int) -> str:
|
||||
"""Return *value* shortened to *limit* cells-ish, using an ellipsis."""
|
||||
value = value or ""
|
||||
return value if len(value) <= limit else value[:max(0, limit - 1)] + "…"
|
||||
|
||||
def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int:
|
||||
"""Best-effort terminal width for interactive and redirected output.
|
||||
|
||||
Rich falls back to 80 columns when stdout is redirected. browser-cli output is
|
||||
often piped into files for inspection, so also consult ``shutil``/``COLUMNS``
|
||||
and prefer the wider value.
|
||||
"""
|
||||
rich_width = (console.width if console is not None else 0) or 0
|
||||
shell_width = shutil.get_terminal_size((fallback, 20)).columns
|
||||
return max(rich_width, shell_width)
|
||||
|
||||
def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int:
|
||||
"""Title width for tree labels, reserving space for branches/IDs/metadata."""
|
||||
reserve = 48 if show_urls else 32
|
||||
if show_browser:
|
||||
reserve += 4
|
||||
return max(50, terminal_width(console) - reserve)
|
||||
|
||||
def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int:
|
||||
"""URL width for tree labels when URLs are displayed."""
|
||||
return max(35, terminal_width(console) - title_limit - 40)
|
||||
|
||||
def print_tree(tree: Tree, *, console: Console | None = None) -> None:
|
||||
"""Render a Rich tree using the detected full terminal width."""
|
||||
Console(width=terminal_width(console)).print(tree)
|
||||
|
||||
def print_table_rows(
|
||||
rows: Sequence[Row],
|
||||
columns: Sequence[Column],
|
||||
*,
|
||||
console: Console,
|
||||
empty_message: str,
|
||||
show_header: bool = True,
|
||||
header_style: str = "bold cyan",
|
||||
) -> None:
|
||||
"""Render a small Rich table from arbitrary row objects."""
|
||||
if not rows:
|
||||
console.print(empty_message)
|
||||
return
|
||||
table = Table(show_header=show_header, header_style=header_style)
|
||||
for header, _getter in columns:
|
||||
table.add_column(header)
|
||||
for row in rows:
|
||||
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
|
||||
Console(width=terminal_width(console)).print(table)
|
||||
|
||||
def print_browser_grouped_table_rows(
|
||||
rows: Sequence[Row],
|
||||
columns: Sequence[Column],
|
||||
*,
|
||||
console: Console,
|
||||
empty_message: str,
|
||||
browser_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser"),
|
||||
group_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser_group", item_value(row, "browserGroup")),
|
||||
browser_header: str = "Browser",
|
||||
show_header: bool = True,
|
||||
header_style: str = "bold cyan",
|
||||
) -> None:
|
||||
"""Render rows with optional local/remote browser grouping.
|
||||
|
||||
Rows without a browser label are rendered as a normal table. Rows with
|
||||
``browser_group``/``browserGroup`` get a group header (for example ``local``
|
||||
or a remote host) and a short indented profile label below it.
|
||||
"""
|
||||
if not rows:
|
||||
console.print(empty_message)
|
||||
return
|
||||
|
||||
show_browser = any(bool(browser_getter(row)) for row in rows)
|
||||
if not show_browser:
|
||||
print_table_rows(
|
||||
rows,
|
||||
columns,
|
||||
console=console,
|
||||
empty_message=empty_message,
|
||||
show_header=show_header,
|
||||
header_style=header_style,
|
||||
)
|
||||
return
|
||||
|
||||
table = Table(show_header=show_header, header_style=header_style)
|
||||
table.add_column(browser_header, no_wrap=True)
|
||||
for header, _getter in columns:
|
||||
table.add_column(header)
|
||||
|
||||
rendered_groups: set[str] = set()
|
||||
for row in rows:
|
||||
browser = text_value(browser_getter(row))
|
||||
group = text_value(group_getter(row))
|
||||
if group:
|
||||
if group not in rendered_groups:
|
||||
table.add_row(f"[bold]{group}[/bold]", *["" for _header, _getter in columns])
|
||||
rendered_groups.add(group)
|
||||
browser = browser.removeprefix(f"{group}:")
|
||||
browser = f" {browser}"
|
||||
table.add_row(browser, *[text_value(getter(row)) for _header, getter in columns])
|
||||
|
||||
Console(width=terminal_width(console)).print(table)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Rich label helpers for tab/window tree renderers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, shorten, text_value
|
||||
|
||||
BROWSER_FAMILY_STYLES = {
|
||||
"firefox": "orange1",
|
||||
"chrome": "cyan",
|
||||
"chromium": "cyan",
|
||||
"brave": "cyan",
|
||||
"edge": "cyan",
|
||||
"vivaldi": "cyan",
|
||||
}
|
||||
DEFAULT_SCOPE = "local"
|
||||
DEFAULT_BROWSER_STYLE = "bold cyan"
|
||||
|
||||
def no_wrap_text() -> Text:
|
||||
"""Text configured for one-line tree labels with edge ellipsis."""
|
||||
return Text(no_wrap=True, overflow="ellipsis")
|
||||
|
||||
def tab_tree_label(tab: Row, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
|
||||
"""Reusable one-line label for a browser tab in tree views."""
|
||||
label = no_wrap_text()
|
||||
label.append(f"[{text_value(item_value(tab, 'id'))}] ", style="dim")
|
||||
label.append(shorten(text_value(item_value(tab, 'title'), "(untitled)") or "(untitled)", title_limit))
|
||||
if bool(item_value(tab, "active", False)):
|
||||
label.append(" *", style="green")
|
||||
url = text_value(item_value(tab, "url"))
|
||||
if show_urls and url:
|
||||
label.append(" — ", style="dim")
|
||||
label.append(shorten(url, url_limit), style="dim")
|
||||
return label
|
||||
|
||||
def group_tree_label(group_id: object, group: Row | None, *, title_limit: int) -> Text:
|
||||
"""Reusable one-line label for a browser tab group in tree views."""
|
||||
title = text_value(item_value(group, "title", "") if group is not None else "") or f"Group {group_id}"
|
||||
color = text_value(item_value(group, "color", "") if group is not None else "") or "group"
|
||||
count = int_value(item_value(group, "tab_count", item_value(group, "tabCount", 0)) if group is not None else 0)
|
||||
collapsed = bool(item_value(group, "collapsed", False)) if group is not None else False
|
||||
label = no_wrap_text()
|
||||
label.append(shorten(title, title_limit), style="bold")
|
||||
meta = [color]
|
||||
if count:
|
||||
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
|
||||
if collapsed:
|
||||
meta.append("collapsed")
|
||||
label.append(" (" + ", ".join(meta) + ")", style="dim")
|
||||
return label
|
||||
|
||||
def browser_label_style(browser_name: str | None) -> str:
|
||||
"""Return a Rich style for a browser family label."""
|
||||
name = (browser_name or "").lower()
|
||||
for family, style in BROWSER_FAMILY_STYLES.items():
|
||||
if family in name:
|
||||
return style
|
||||
return DEFAULT_BROWSER_STYLE
|
||||
|
||||
def scoped_browser_label(browser: str, scope: str, *, grouped: bool) -> str:
|
||||
"""Shorten browser labels under a remote/local group node."""
|
||||
prefix = f"{scope}:"
|
||||
return browser[len(prefix):] if grouped and browser.startswith(prefix) else browser
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tabs tree renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
|
||||
from browser_cli.commands.rendering.labels import (
|
||||
DEFAULT_BROWSER_STYLE,
|
||||
DEFAULT_SCOPE,
|
||||
browser_label_style,
|
||||
group_tree_label,
|
||||
scoped_browser_label,
|
||||
tab_tree_label,
|
||||
)
|
||||
|
||||
GroupId = object
|
||||
GroupKey = tuple[str, str, int | None, GroupId]
|
||||
TreeNodeKey = tuple[str, str]
|
||||
WindowNodeKey = tuple[str, str, int]
|
||||
BrowserGroupNodeKey = tuple[str, str, int, GroupId]
|
||||
|
||||
def browser_scope(item: Row) -> str:
|
||||
"""Return the remote/local scope key used by tree renderers."""
|
||||
return text_value(item_value(item, "browser_group")) or DEFAULT_SCOPE
|
||||
|
||||
def browser_label_key(item: Row) -> str:
|
||||
"""Return the browser/profile key used by tree renderers."""
|
||||
return text_value(item_value(item, "browser")) or DEFAULT_SCOPE
|
||||
|
||||
def tab_window_id(tab: Row) -> int:
|
||||
"""Return a stable window id from object or dict-shaped tab responses."""
|
||||
return int_value(item_value(tab, "window_id", item_value(tab, "windowId", 0)))
|
||||
|
||||
def tab_group_id(tab: Row) -> GroupId | None:
|
||||
"""Return a tab group id from object or dict-shaped tab responses."""
|
||||
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
|
||||
return None if group_id is None else group_id
|
||||
|
||||
def tab_sort_key(tab: Row) -> tuple[str, str, int, int, int, int]:
|
||||
"""Stable tab ordering across multi-browser responses."""
|
||||
group_id = tab_group_id(tab)
|
||||
return (
|
||||
browser_scope(tab),
|
||||
browser_label_key(tab),
|
||||
tab_window_id(tab),
|
||||
int_value(item_value(tab, "index", 0)),
|
||||
int_value(group_id, -1) if group_id is not None else -1,
|
||||
int_value(item_value(tab, "id", 0)),
|
||||
)
|
||||
|
||||
class TabsTreeBuilder:
|
||||
"""Stateful builder for the browser tabs tree.
|
||||
|
||||
The tree has optional scope nodes (remote host/local), then browser/profile,
|
||||
then window, then browser tab-groups/tabs. Keeping this state in a helper
|
||||
keeps ``build_tabs_tree`` small while preserving stable node reuse.
|
||||
"""
|
||||
|
||||
tabs: list[Row]
|
||||
groups: list[Row]
|
||||
show_urls: bool
|
||||
show_browser: bool
|
||||
group_by_scope: bool
|
||||
title_limit: int
|
||||
url_limit: int
|
||||
root: Tree
|
||||
group_info: dict[GroupKey, Row]
|
||||
browser_styles: dict[str, str]
|
||||
scope_nodes: dict[str, Tree]
|
||||
browser_nodes: dict[TreeNodeKey, Tree]
|
||||
window_nodes: dict[WindowNodeKey, Tree]
|
||||
group_nodes: dict[BrowserGroupNodeKey, Tree]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tabs: Iterable[Row],
|
||||
groups: Iterable[Row],
|
||||
*,
|
||||
console: Console,
|
||||
show_urls: bool = False,
|
||||
):
|
||||
self.tabs = sorted(tabs, key=tab_sort_key)
|
||||
self.groups = list(groups)
|
||||
self.show_urls = show_urls
|
||||
self.show_browser = any(bool(item_value(tab, "browser")) for tab in self.tabs)
|
||||
self.group_by_scope = any(bool(item_value(item, "browser_group")) for item in self.tabs + self.groups)
|
||||
self.title_limit = tree_title_limit(console=console, show_browser=self.show_browser, show_urls=show_urls)
|
||||
self.url_limit = tree_url_limit(self.title_limit, console=console)
|
||||
self.root = Tree("[bold]Tabs[/bold]")
|
||||
self.group_info = self._group_info()
|
||||
self.browser_styles = self._browser_styles()
|
||||
self.scope_nodes = {}
|
||||
self.browser_nodes = {}
|
||||
self.window_nodes = {}
|
||||
self.group_nodes = {}
|
||||
|
||||
def build(self) -> Tree:
|
||||
for tab in self.tabs:
|
||||
self._add_tab(tab)
|
||||
return self.root
|
||||
|
||||
def _group_info(self) -> dict[GroupKey, Row]:
|
||||
return {
|
||||
(
|
||||
browser_scope(group),
|
||||
browser_label_key(group),
|
||||
int_value(item_value(group, "window_id", item_value(group, "windowId")), 0),
|
||||
item_value(group, "id"),
|
||||
): group
|
||||
for group in self.groups
|
||||
}
|
||||
|
||||
def _browser_styles(self) -> dict[str, str]:
|
||||
styles: dict[str, str] = {}
|
||||
for item in self.tabs + self.groups:
|
||||
key = browser_label_key(item)
|
||||
styles.setdefault(key, browser_label_style(text_value(item_value(item, "browser_name")) or None))
|
||||
return styles
|
||||
|
||||
def _scope_node(self, scope: str) -> Tree:
|
||||
if not self.group_by_scope:
|
||||
return self.root
|
||||
node = self.scope_nodes.get(scope)
|
||||
if node is None:
|
||||
node = self.root.add(Text(scope, style="bold"))
|
||||
self.scope_nodes[scope] = node
|
||||
return node
|
||||
|
||||
def _browser_node(self, scope: str, browser: str) -> Tree:
|
||||
key = (scope, browser)
|
||||
node = self.browser_nodes.get(key)
|
||||
if node is None:
|
||||
parent = self._scope_node(scope)
|
||||
if self.show_browser:
|
||||
label = scoped_browser_label(browser, scope, grouped=self.group_by_scope)
|
||||
node = parent.add(Text(label, style=self.browser_styles.get(browser, DEFAULT_BROWSER_STYLE)))
|
||||
else:
|
||||
node = parent
|
||||
self.browser_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _window_node(self, scope: str, browser: str, window_id: int) -> Tree:
|
||||
key = (scope, browser, window_id)
|
||||
node = self.window_nodes.get(key)
|
||||
if node is None:
|
||||
node = self._browser_node(scope, browser).add(f"Window {window_id}")
|
||||
self.window_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _group_node(self, scope: str, browser: str, window_id: int, group_id: GroupId, parent: Tree) -> Tree:
|
||||
key = (scope, browser, window_id, group_id)
|
||||
node = self.group_nodes.get(key)
|
||||
if node is None:
|
||||
group = self.group_info.get(key) or self.group_info.get((scope, browser, None, group_id))
|
||||
node = parent.add(group_tree_label(group_id, group, title_limit=self.title_limit))
|
||||
self.group_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _add_tab(self, tab: Row) -> None:
|
||||
scope = browser_scope(tab)
|
||||
browser = browser_label_key(tab)
|
||||
window_id = tab_window_id(tab)
|
||||
window_node = self._window_node(scope, browser, window_id)
|
||||
group_id = tab_group_id(tab)
|
||||
parent = window_node if group_id is None else self._group_node(scope, browser, window_id, group_id, window_node)
|
||||
parent.add(tab_tree_label(
|
||||
tab,
|
||||
title_limit=self.title_limit,
|
||||
show_urls=self.show_urls,
|
||||
url_limit=self.url_limit,
|
||||
))
|
||||
|
||||
def build_tabs_tree(
|
||||
tabs: Iterable[Row],
|
||||
groups: Iterable[Row],
|
||||
*,
|
||||
console: Console,
|
||||
show_urls: bool = False,
|
||||
) -> Tree:
|
||||
"""Build a remote/local → browser → window → group/tab tree from tab responses."""
|
||||
return TabsTreeBuilder(tabs, groups, console=console, show_urls=show_urls).build()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Windows tree renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
|
||||
from browser_cli.commands.rendering.labels import tab_tree_label
|
||||
|
||||
WindowRow = Mapping[str, object]
|
||||
|
||||
def build_windows_tree(windows: Iterable[WindowRow], tabs: Iterable[Row], *, console: Console) -> Tree:
|
||||
"""Build a window → tab tree from window and tab responses."""
|
||||
windows = list(windows)
|
||||
tabs = list(tabs)
|
||||
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
|
||||
url_limit = tree_url_limit(title_limit, console=console)
|
||||
root = Tree("[bold]Windows[/bold]")
|
||||
for window in sorted(windows, key=lambda item: (text_value(item.get("browser")), int_value(item.get("id")))):
|
||||
window_id = int_value(window.get("id"))
|
||||
label = f"Window {window_id}"
|
||||
alias = text_value(window.get("alias"))
|
||||
browser = text_value(window.get("browser"))
|
||||
if alias:
|
||||
label += f" ({alias})"
|
||||
if browser:
|
||||
label = f"{browser}: " + label
|
||||
node = root.add(label)
|
||||
window_tabs = [
|
||||
tab for tab in tabs
|
||||
if int_value(item_value(tab, "window_id", item_value(tab, "windowId"))) == window_id
|
||||
and (not browser or text_value(item_value(tab, "browser")) == browser)
|
||||
]
|
||||
for tab in sorted(window_tabs, key=lambda item: int_value(item_value(item, "index", 0))):
|
||||
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
|
||||
return root
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -12,25 +14,25 @@ from browser_cli.commands import client_from_ctx, handle_errors
|
||||
console = Console()
|
||||
|
||||
def _load_steps(path: Path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if path.suffix.lower() in {".yaml", ".yml"}:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except Exception as exc:
|
||||
raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
|
||||
return yaml.safe_load(text)
|
||||
return json.loads(text)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if path.suffix.lower() in {".yaml", ".yml"}:
|
||||
try:
|
||||
yaml = cast(Any, importlib.import_module("yaml"))
|
||||
except Exception as exc:
|
||||
raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
|
||||
return yaml.safe_load(text)
|
||||
return json.loads(text)
|
||||
|
||||
def _parse_step(step):
|
||||
if isinstance(step, str):
|
||||
return step, {}
|
||||
if isinstance(step, dict):
|
||||
if "command" in step:
|
||||
return step["command"], step.get("args") or {}
|
||||
if len(step) == 1:
|
||||
command, args = next(iter(step.items()))
|
||||
return command, args or {}
|
||||
raise click.ClickException(f"Invalid script step: {step!r}")
|
||||
if isinstance(step, str):
|
||||
return step, {}
|
||||
if isinstance(step, dict):
|
||||
if "command" in step:
|
||||
return step["command"], step.get("args") or {}
|
||||
if len(step) == 1:
|
||||
command, args = next(iter(step.items()))
|
||||
return command, args or {}
|
||||
raise click.ClickException(f"Invalid script step: {step!r}")
|
||||
|
||||
@click.command("script")
|
||||
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@@ -41,28 +43,28 @@ def _parse_step(step):
|
||||
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
|
||||
@handle_errors
|
||||
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool):
|
||||
"""Run a JSON/YAML batch script of browser-cli wire commands."""
|
||||
steps = _load_steps(file)
|
||||
if not isinstance(steps, list):
|
||||
raise click.ClickException("Script root must be a list")
|
||||
client = client_from_ctx()
|
||||
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||
results = []
|
||||
for index, step in enumerate(steps, start=1):
|
||||
command, args = _parse_step(step)
|
||||
try:
|
||||
assert_command_allowed(command, policy)
|
||||
result = client.command(command, args)
|
||||
results.append({"index": index, "command": command, "ok": True, "result": result})
|
||||
if not json_output:
|
||||
console.print(f"[green]✓[/green] {index}: {command}")
|
||||
except Exception as exc:
|
||||
results.append({"index": index, "command": command, "ok": False, "error": str(exc)})
|
||||
if not continue_on_error:
|
||||
if json_output:
|
||||
click.echo(json.dumps(results, indent=2, default=str))
|
||||
raise
|
||||
if not json_output:
|
||||
console.print(f"[red]✗[/red] {index}: {command}: {exc}")
|
||||
if json_output:
|
||||
click.echo(json.dumps(results, indent=2, default=str))
|
||||
"""Run a JSON/YAML batch script of browser-cli wire commands."""
|
||||
steps = _load_steps(file)
|
||||
if not isinstance(steps, list):
|
||||
raise click.ClickException("Script root must be a list")
|
||||
client = client_from_ctx()
|
||||
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||
results = []
|
||||
for index, step in enumerate(steps, start=1):
|
||||
command, args = _parse_step(step)
|
||||
try:
|
||||
assert_command_allowed(command, policy)
|
||||
result = client.command(command, args)
|
||||
results.append({"index": index, "command": command, "ok": True, "result": result})
|
||||
if not json_output:
|
||||
console.print(f"[green]✓[/green] {index}: {command}")
|
||||
except Exception as exc:
|
||||
results.append({"index": index, "command": command, "ok": False, "error": str(exc)})
|
||||
if not continue_on_error:
|
||||
if json_output:
|
||||
click.echo(json.dumps(results, indent=2, default=str))
|
||||
raise
|
||||
if not json_output:
|
||||
console.print(f"[red]✗[/red] {index}: {command}: {exc}")
|
||||
if json_output:
|
||||
click.echo(json.dumps(results, indent=2, default=str))
|
||||
|
||||
@@ -105,24 +105,20 @@ def session_diff(name_a, name_b):
|
||||
def session_list():
|
||||
"""List all saved sessions."""
|
||||
from datetime import datetime
|
||||
from rich.table import Table
|
||||
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||
sessions = client_from_ctx().session.list()
|
||||
if not sessions:
|
||||
console.print("[yellow]No saved sessions[/yellow]")
|
||||
return
|
||||
show_browser = any("browser" in s for s in sessions)
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
if show_browser:
|
||||
table.add_column("Browser")
|
||||
table.add_column("Name")
|
||||
table.add_column("Tabs", width=6)
|
||||
table.add_column("Saved at")
|
||||
for s in sessions:
|
||||
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
|
||||
row = [s.get("browser", "")] if show_browser else []
|
||||
row.extend([s["name"], str(s["tabs"]), saved])
|
||||
table.add_row(*row)
|
||||
console.print(table)
|
||||
def saved_at(session):
|
||||
return datetime.fromtimestamp(session["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if session.get("savedAt") else ""
|
||||
|
||||
columns = [
|
||||
("Name", lambda session: session["name"]),
|
||||
("Tabs", lambda session: session["tabs"]),
|
||||
("Saved at", saved_at),
|
||||
]
|
||||
print_browser_grouped_table_rows(sessions, columns, console=console, empty_message="[yellow]No saved sessions[/yellow]")
|
||||
|
||||
@session_group.command("remove")
|
||||
@click.argument("name")
|
||||
|
||||
+94
-130
@@ -2,78 +2,42 @@ import base64
|
||||
import binascii
|
||||
import click
|
||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
|
||||
from browser_cli.commands.rendering import build_tabs_tree, print_browser_grouped_table_rows, print_tree
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
console = Console()
|
||||
|
||||
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
|
||||
if not tabs:
|
||||
console.print("[yellow]No tabs found[/yellow]")
|
||||
return
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
if show_browser:
|
||||
table.add_column("Browser", no_wrap=True)
|
||||
table.add_column("ID", style="dim", no_wrap=True)
|
||||
table.add_column("Window", no_wrap=True)
|
||||
table.add_column("Active", width=7)
|
||||
table.add_column("Muted", width=7)
|
||||
table.add_column("Title")
|
||||
table.add_column("URL")
|
||||
for t in tabs:
|
||||
active = "[green]✓[/green]" if t.active else ""
|
||||
muted = "[yellow]✓[/yellow]" if t.muted else ""
|
||||
row = [
|
||||
(t.browser or "") if show_browser else None,
|
||||
str(t.id),
|
||||
str(t.window_id),
|
||||
active,
|
||||
muted,
|
||||
(t.title or "")[:60],
|
||||
(t.url or "")[:80],
|
||||
]
|
||||
table.add_row(*[value for value in row if value is not None])
|
||||
console.print(table)
|
||||
columns = [
|
||||
("ID", lambda tab: tab.id),
|
||||
("Window", lambda tab: tab.window_id),
|
||||
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
|
||||
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
|
||||
("Title", lambda tab: (tab.title or "")[:60]),
|
||||
("URL", lambda tab: (tab.url or "")[:80]),
|
||||
]
|
||||
print_browser_grouped_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
|
||||
|
||||
@click.group("tabs")
|
||||
def tabs_group():
|
||||
"""Manage browser tabs."""
|
||||
"""Manage browser tabs."""
|
||||
|
||||
@tabs_group.command("list")
|
||||
@handle_errors
|
||||
def tabs_list():
|
||||
"""List all open tabs across all windows."""
|
||||
tabs = client_from_ctx().tabs.list()
|
||||
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
|
||||
"""List all open tabs across all windows."""
|
||||
tabs = client_from_ctx().tabs.list()
|
||||
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
|
||||
|
||||
@tabs_group.command("tree")
|
||||
@click.option("--urls", "show_urls", is_flag=True, help="Show shortened URLs next to tab titles")
|
||||
@handle_errors
|
||||
def tabs_tree():
|
||||
"""Show tabs grouped as a window/group tree."""
|
||||
tabs = sorted(client_from_ctx().tabs.list(), key=lambda t: ((t.browser or ""), t.window_id, t.group_id if t.group_id is not None else -1, t.index))
|
||||
root = Tree("[bold]Tabs[/bold]")
|
||||
browsers = {}
|
||||
windows = {}
|
||||
groups = {}
|
||||
show_browser = any(t.browser for t in tabs)
|
||||
for tab in tabs:
|
||||
browser_key = tab.browser or "local"
|
||||
browser_node = browsers.setdefault(browser_key, root.add(f"[bold cyan]{browser_key}[/bold cyan]") if show_browser else root)
|
||||
win_key = (browser_key, tab.window_id)
|
||||
win_node = windows.get(win_key)
|
||||
if win_node is None:
|
||||
win_node = browser_node.add(f"Window {tab.window_id}")
|
||||
windows[win_key] = win_node
|
||||
group_label = f"Group {tab.group_id}" if tab.group_id is not None else "Ungrouped"
|
||||
group_key = (browser_key, tab.window_id, group_label)
|
||||
group_node = groups.get(group_key)
|
||||
if group_node is None:
|
||||
group_node = win_node.add(group_label)
|
||||
groups[group_key] = group_node
|
||||
active = " [green]*[/green]" if tab.active else ""
|
||||
group_node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]")
|
||||
console.print(root)
|
||||
def tabs_tree(show_urls):
|
||||
"""Show tabs grouped as a window/group tree."""
|
||||
client = client_from_ctx()
|
||||
root = build_tabs_tree(client.tabs.list(), client.groups.list(), console=console, show_urls=show_urls)
|
||||
print_tree(root, console=console)
|
||||
|
||||
@tabs_group.command("close")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@@ -82,9 +46,9 @@ def tabs_tree():
|
||||
@gentle_mode_option("Throttle mode for large close operations.")
|
||||
@handle_errors
|
||||
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
|
||||
"""Close a tab, all inactive tabs, or all duplicate tabs."""
|
||||
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Closed {count} tab(s)[/green]")
|
||||
"""Close a tab, all inactive tabs, or all duplicate tabs."""
|
||||
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Closed {count} tab(s)[/green]")
|
||||
|
||||
@tabs_group.command("move")
|
||||
@click.argument("tab_id", type=int)
|
||||
@@ -97,123 +61,123 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode):
|
||||
@click.option("--index", type=int, default=None, help="Absolute position index in target")
|
||||
@handle_errors
|
||||
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
|
||||
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
|
||||
client_from_ctx().tabs.move(
|
||||
tab_id, forward=forward, backward=backward,
|
||||
group_id=group_id, window_id=window_id, index=index,
|
||||
)
|
||||
console.print("[green]Tab moved[/green]")
|
||||
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
|
||||
client_from_ctx().tabs.move(
|
||||
tab_id, forward=forward, backward=backward,
|
||||
group_id=group_id, window_id=window_id, index=index,
|
||||
)
|
||||
console.print("[green]Tab moved[/green]")
|
||||
|
||||
@tabs_group.command("active")
|
||||
@click.argument("tab_id", type=int)
|
||||
@handle_errors
|
||||
def tabs_active(tab_id):
|
||||
"""Switch browser focus to a tab."""
|
||||
client_from_ctx().tabs.activate(tab_id)
|
||||
console.print(f"[green]Switched to tab {tab_id}[/green]")
|
||||
"""Switch browser focus to a tab."""
|
||||
client_from_ctx().tabs.activate(tab_id)
|
||||
console.print(f"[green]Switched to tab {tab_id}[/green]")
|
||||
|
||||
@tabs_group.command("status")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_status(tab_id):
|
||||
"""Show status for the active tab or a specific tab."""
|
||||
tab = client_from_ctx().tabs.status(tab_id)
|
||||
table = Table(show_header=False)
|
||||
table.add_column("Field", style="bold cyan")
|
||||
table.add_column("Value")
|
||||
table.add_row("ID", str(tab.id))
|
||||
table.add_row("Window", str(tab.window_id))
|
||||
table.add_row("Active", "yes" if tab.active else "no")
|
||||
table.add_row("Muted", "yes" if tab.muted else "no")
|
||||
table.add_row("Title", tab.title or "")
|
||||
table.add_row("URL", tab.url or "")
|
||||
console.print(table)
|
||||
"""Show status for the active tab or a specific tab."""
|
||||
tab = client_from_ctx().tabs.status(tab_id)
|
||||
table = Table(show_header=False)
|
||||
table.add_column("Field", style="bold cyan")
|
||||
table.add_column("Value")
|
||||
table.add_row("ID", str(tab.id))
|
||||
table.add_row("Window", str(tab.window_id))
|
||||
table.add_row("Active", "yes" if tab.active else "no")
|
||||
table.add_row("Muted", "yes" if tab.muted else "no")
|
||||
table.add_row("Title", tab.title or "")
|
||||
table.add_row("URL", tab.url or "")
|
||||
console.print(table)
|
||||
|
||||
@tabs_group.command("filter")
|
||||
@click.argument("pattern")
|
||||
@handle_errors
|
||||
def tabs_filter(pattern):
|
||||
"""List tabs whose URL contains PATTERN."""
|
||||
_print_tabs(client_from_ctx().tabs.filter(pattern))
|
||||
"""List tabs whose URL contains PATTERN."""
|
||||
_print_tabs(client_from_ctx().tabs.filter(pattern))
|
||||
|
||||
@tabs_group.command("count")
|
||||
@click.argument("pattern", required=False)
|
||||
@handle_errors
|
||||
def tabs_count(pattern):
|
||||
"""Count open tabs, optionally filtered by URL PATTERN."""
|
||||
label = f" matching '{pattern}'" if pattern else ""
|
||||
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
|
||||
"""Count open tabs, optionally filtered by URL PATTERN."""
|
||||
label = f" matching '{pattern}'" if pattern else ""
|
||||
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
|
||||
|
||||
@tabs_group.command("query")
|
||||
@click.argument("search")
|
||||
@handle_errors
|
||||
def tabs_query(search):
|
||||
"""Search tabs by URL or title."""
|
||||
_print_tabs(client_from_ctx().tabs.query(search))
|
||||
"""Search tabs by URL or title."""
|
||||
_print_tabs(client_from_ctx().tabs.query(search))
|
||||
|
||||
@tabs_group.command("html")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_html(tab_id):
|
||||
"""Print the full HTML of a tab."""
|
||||
console.print(client_from_ctx().tabs.html(tab_id))
|
||||
"""Print the full HTML of a tab."""
|
||||
console.print(client_from_ctx().tabs.html(tab_id))
|
||||
|
||||
@tabs_group.command("dedupe")
|
||||
@gentle_mode_option("Throttle mode for large dedupe operations.")
|
||||
@handle_errors
|
||||
def tabs_dedupe(gentle_mode):
|
||||
"""Close duplicate tabs (keep the first occurrence of each URL)."""
|
||||
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
|
||||
"""Close duplicate tabs (keep the first occurrence of each URL)."""
|
||||
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
|
||||
|
||||
@tabs_group.command("sort")
|
||||
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
|
||||
@gentle_mode_option("Throttle mode for large sort operations.")
|
||||
@handle_errors
|
||||
def tabs_sort(by, gentle_mode):
|
||||
"""Sort tabs within each window."""
|
||||
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Tabs sorted by {by}[/green]")
|
||||
"""Sort tabs within each window."""
|
||||
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Tabs sorted by {by}[/green]")
|
||||
|
||||
@tabs_group.command("merge-windows")
|
||||
@gentle_mode_option("Throttle mode for large merge operations.")
|
||||
@handle_errors
|
||||
def tabs_merge_windows(gentle_mode):
|
||||
"""Move all tabs into the focused window."""
|
||||
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
|
||||
"""Move all tabs into the focused window."""
|
||||
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
|
||||
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
|
||||
|
||||
@tabs_group.command("mute")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_mute(tab_id):
|
||||
"""Mute the active tab or a specific tab."""
|
||||
target = client_from_ctx().tabs.mute(tab_id)
|
||||
console.print(f"[green]Muted tab {target}[/green]")
|
||||
"""Mute the active tab or a specific tab."""
|
||||
target = client_from_ctx().tabs.mute(tab_id)
|
||||
console.print(f"[green]Muted tab {target}[/green]")
|
||||
|
||||
@tabs_group.command("unmute")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_unmute(tab_id):
|
||||
"""Unmute the active tab or a specific tab."""
|
||||
target = client_from_ctx().tabs.unmute(tab_id)
|
||||
console.print(f"[green]Unmuted tab {target}[/green]")
|
||||
"""Unmute the active tab or a specific tab."""
|
||||
target = client_from_ctx().tabs.unmute(tab_id)
|
||||
console.print(f"[green]Unmuted tab {target}[/green]")
|
||||
|
||||
@tabs_group.command("pin")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_pin(tab_id):
|
||||
"""Pin the active tab or a specific tab."""
|
||||
target = client_from_ctx().tabs.pin(tab_id)
|
||||
console.print(f"[green]Pinned tab {target}[/green]")
|
||||
"""Pin the active tab or a specific tab."""
|
||||
target = client_from_ctx().tabs.pin(tab_id)
|
||||
console.print(f"[green]Pinned tab {target}[/green]")
|
||||
|
||||
@tabs_group.command("unpin")
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@handle_errors
|
||||
def tabs_unpin(tab_id):
|
||||
"""Unpin the active tab or a specific tab."""
|
||||
target = client_from_ctx().tabs.unpin(tab_id)
|
||||
console.print(f"[green]Unpinned tab {target}[/green]")
|
||||
"""Unpin the active tab or a specific tab."""
|
||||
target = client_from_ctx().tabs.unpin(tab_id)
|
||||
console.print(f"[green]Unpinned tab {target}[/green]")
|
||||
|
||||
@tabs_group.command("watch-url")
|
||||
@click.argument("pattern")
|
||||
@@ -221,9 +185,9 @@ def tabs_unpin(tab_id):
|
||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
||||
@handle_errors
|
||||
def tabs_watch_url(pattern, tab_id, timeout):
|
||||
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
|
||||
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
|
||||
console.print(f"[green]URL matched:[/green] {tab.url}")
|
||||
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
|
||||
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
|
||||
console.print(f"[green]URL matched:[/green] {tab.url}")
|
||||
|
||||
@tabs_group.command("screenshot")
|
||||
@click.argument("output", required=False, metavar="FILE")
|
||||
@@ -232,21 +196,21 @@ def tabs_watch_url(pattern, tab_id, timeout):
|
||||
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
|
||||
@handle_errors
|
||||
def tabs_screenshot(output, tab_id, fmt, quality):
|
||||
"""Capture a screenshot of the active (or specified) tab.
|
||||
"""Capture a screenshot of the active (or specified) tab.
|
||||
|
||||
Saves to FILE if given, otherwise prints the base64 data URL.
|
||||
"""
|
||||
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
|
||||
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)
|
||||
Saves to FILE if given, otherwise prints the base64 data URL.
|
||||
"""
|
||||
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
|
||||
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,87 +1,60 @@
|
||||
import click
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from browser_cli.commands.rendering import build_windows_tree, print_browser_grouped_table_rows, print_tree
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
console = Console()
|
||||
|
||||
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
||||
if not windows:
|
||||
console.print("[yellow]No windows found[/yellow]")
|
||||
return
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
if show_browser:
|
||||
table.add_column("Browser")
|
||||
table.add_column("ID", style="dim", no_wrap=True)
|
||||
table.add_column("Alias", width=20)
|
||||
table.add_column("Tabs", width=6)
|
||||
table.add_column("State", width=12)
|
||||
for w in windows:
|
||||
row = [
|
||||
w.get("browser", "") if show_browser else None,
|
||||
str(w.get("id", "")),
|
||||
w.get("alias") or "",
|
||||
str(w.get("tabCount", "")),
|
||||
w.get("state") or "",
|
||||
]
|
||||
table.add_row(*[value for value in row if value is not None])
|
||||
console.print(table)
|
||||
columns = [
|
||||
("ID", lambda window: window.get("id", "")),
|
||||
("Alias", lambda window: window.get("alias") or ""),
|
||||
("Tabs", lambda window: window.get("tabCount", "")),
|
||||
("State", lambda window: window.get("state") or ""),
|
||||
]
|
||||
print_browser_grouped_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
|
||||
|
||||
@click.group("windows")
|
||||
def windows_group():
|
||||
"""Manage browser windows."""
|
||||
"""Manage browser windows."""
|
||||
|
||||
@windows_group.command("list")
|
||||
@handle_errors
|
||||
def windows_list():
|
||||
"""List all browser windows."""
|
||||
windows = client_from_ctx().windows.list()
|
||||
_print_windows(windows, show_browser=any("browser" in w for w in windows))
|
||||
"""List all browser windows."""
|
||||
windows = client_from_ctx().windows.list()
|
||||
_print_windows(windows, show_browser=any("browser" in w for w in windows))
|
||||
|
||||
@windows_group.command("tree")
|
||||
@handle_errors
|
||||
def windows_tree():
|
||||
"""Show windows and their tabs as a tree."""
|
||||
client = client_from_ctx()
|
||||
windows = client.windows.list()
|
||||
tabs = client.tabs.list()
|
||||
root = Tree("[bold]Windows[/bold]")
|
||||
for w in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))):
|
||||
wid = w.get("id")
|
||||
label = f"Window {wid}"
|
||||
if w.get("alias"):
|
||||
label += f" ({w['alias']})"
|
||||
if w.get("browser"):
|
||||
label = f"{w['browser']}: " + label
|
||||
node = root.add(label)
|
||||
for tab in sorted([t for t in tabs if t.window_id == wid and (not w.get("browser") or t.browser == w.get("browser"))], key=lambda t: t.index):
|
||||
active = " [green]*[/green]" if tab.active else ""
|
||||
node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]")
|
||||
console.print(root)
|
||||
"""Show windows and their tabs as a tree."""
|
||||
client = client_from_ctx()
|
||||
root = build_windows_tree(client.windows.list(), client.tabs.list(), console=console)
|
||||
print_tree(root, console=console)
|
||||
|
||||
@windows_group.command("rename")
|
||||
@click.argument("window_id", type=int)
|
||||
@click.argument("name")
|
||||
@handle_errors
|
||||
def windows_rename(window_id, name):
|
||||
"""Give a window a local alias NAME (stored in native host)."""
|
||||
client_from_ctx().windows.rename(window_id, name)
|
||||
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
|
||||
"""Give a window a local alias NAME (stored in native host)."""
|
||||
client_from_ctx().windows.rename(window_id, name)
|
||||
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
|
||||
|
||||
@windows_group.command("close")
|
||||
@click.argument("window_id", type=int)
|
||||
@handle_errors
|
||||
def windows_close(window_id):
|
||||
"""Close a browser window."""
|
||||
client_from_ctx().windows.close(window_id)
|
||||
console.print(f"[green]Window {window_id} closed[/green]")
|
||||
"""Close a browser window."""
|
||||
client_from_ctx().windows.close(window_id)
|
||||
console.print(f"[green]Window {window_id} closed[/green]")
|
||||
|
||||
@windows_group.command("open")
|
||||
@click.argument("url", required=False)
|
||||
@handle_errors
|
||||
def windows_open(url):
|
||||
"""Open a new browser window."""
|
||||
result = client_from_ctx().windows.open(url)
|
||||
wid = result.get("id") if isinstance(result, dict) else result
|
||||
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
|
||||
"""Open a new browser window."""
|
||||
result = client_from_ctx().windows.open(url)
|
||||
wid = result.get("id") if isinstance(result, dict) else result
|
||||
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
|
||||
|
||||
@@ -4,26 +4,6 @@ from __future__ import annotations
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
def _normalize_text(value):
|
||||
return re.sub(r"\s+", " ", value or "").strip()
|
||||
|
||||
def _normalize_inline(value):
|
||||
value = value.replace("\xa0", " ")
|
||||
value = re.sub(r"[ \t\r\f\v]+", " ", value)
|
||||
value = re.sub(r" *\n *", "\n", value)
|
||||
return value.strip()
|
||||
|
||||
def _collapse_blank_lines(value):
|
||||
value = re.sub(r"[ \t]+\n", "\n", value)
|
||||
value = re.sub(r"\n{3,}", "\n\n", value)
|
||||
return value.strip()
|
||||
|
||||
def _escape_markdown(text):
|
||||
return re.sub(r"([\\`[\]])", r"\\\1", text)
|
||||
|
||||
def _escape_table_cell(text):
|
||||
return text.replace("|", r"\|").replace("\n", " ").strip()
|
||||
|
||||
class _HtmlNode:
|
||||
def __init__(self, tag=None, attrs=None, text=None):
|
||||
self.tag = tag
|
||||
|
||||
@@ -31,6 +31,7 @@ class BrowserCounts:
|
||||
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
|
||||
total: int
|
||||
by_browser: dict[str, int]
|
||||
browser_groups: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# ── Tab ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,7 +45,10 @@ class Tab:
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
group_id: int | None = None
|
||||
index: int = 0
|
||||
browser: str | None = None
|
||||
browser_name: str | None = None
|
||||
browser_group: str | None = None
|
||||
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
|
||||
|
||||
def _b(self) -> BoundBrowser:
|
||||
@@ -149,7 +153,10 @@ class Group:
|
||||
color: str
|
||||
collapsed: bool
|
||||
tab_count: int
|
||||
window_id: int | None = None
|
||||
browser: str | None = None
|
||||
browser_name: str | None = None
|
||||
browser_group: str | None = None
|
||||
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
|
||||
|
||||
def _b(self) -> BoundBrowser:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Challenge/response auth helpers for remote TCP transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.version_manager import USER_AGENT
|
||||
|
||||
T = TypeVar("T")
|
||||
AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
|
||||
PQ_WARNING = (
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
)
|
||||
|
||||
def parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
|
||||
try:
|
||||
challenge = json.loads(raw)
|
||||
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
||||
return challenge, nonce_hex
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return None, None
|
||||
|
||||
def check_min_client_version(challenge: dict | None) -> None:
|
||||
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
||||
if not min_ver:
|
||||
return
|
||||
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
|
||||
|
||||
def clean_message(msg: dict) -> dict:
|
||||
return {key: value for key, value in msg.items() if key not in AUTH_FIELDS}
|
||||
|
||||
def get_pq_public_key(challenge: dict | None) -> str | None:
|
||||
if not isinstance(challenge, dict):
|
||||
return None
|
||||
from browser_cli.auth import PQ_KEX_ALG
|
||||
kex = challenge.get("pq_kex")
|
||||
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
||||
return str(kex["public_key"])
|
||||
return None
|
||||
|
||||
def signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
|
||||
from browser_cli.auth import pq_encrypt, public_key_hex, sign
|
||||
|
||||
nonce = bytes.fromhex(nonce_hex)
|
||||
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
||||
pubkey = public_key_hex(private_key)
|
||||
if pq_shared_secret is None:
|
||||
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
|
||||
|
||||
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
||||
return {
|
||||
"id": clean_msg.get("id"),
|
||||
"user_agent": clean_msg.get("user_agent"),
|
||||
"pubkey": pubkey,
|
||||
"sig": sig.hex(),
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": encrypted,
|
||||
}
|
||||
|
||||
def emit_no_pq_warning(enabled: bool) -> None:
|
||||
if enabled:
|
||||
sys.stderr.write(PQ_WARNING)
|
||||
|
||||
def build_auth_message(
|
||||
msg: dict,
|
||||
challenge: dict | None,
|
||||
nonce_hex: str | None,
|
||||
private_key,
|
||||
encapsulate: Callable[[str], tuple[str, bytes]],
|
||||
*,
|
||||
warn_no_pq: bool = True,
|
||||
) -> tuple[dict, bytes | None]:
|
||||
if not nonce_hex or private_key is None:
|
||||
emit_no_pq_warning(warn_no_pq)
|
||||
return msg, None
|
||||
|
||||
clean_msg = clean_message(msg)
|
||||
pq_shared_secret = None
|
||||
pq_public_key = get_pq_public_key(challenge)
|
||||
if pq_public_key:
|
||||
from browser_cli.auth import PQ_KEX_ALG
|
||||
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
|
||||
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
||||
else:
|
||||
emit_no_pq_warning(warn_no_pq)
|
||||
|
||||
return signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
|
||||
|
||||
async def build_auth_message_async(
|
||||
msg: dict,
|
||||
challenge: dict | None,
|
||||
nonce_hex: str | None,
|
||||
private_key,
|
||||
*,
|
||||
warn_no_pq: bool = True,
|
||||
) -> tuple[dict, bytes | None]:
|
||||
def encapsulate(public_key: str) -> tuple[str, bytes]:
|
||||
from browser_cli.auth import pq_kex_client_encapsulate
|
||||
return pq_kex_client_encapsulate(public_key)
|
||||
|
||||
return await asyncio.to_thread(
|
||||
build_auth_message,
|
||||
msg,
|
||||
challenge,
|
||||
nonce_hex,
|
||||
private_key,
|
||||
encapsulate,
|
||||
warn_no_pq=warn_no_pq,
|
||||
)
|
||||
|
||||
def decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
|
||||
if response is None or pq_shared_secret is None:
|
||||
return response
|
||||
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 exc:
|
||||
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {exc}") from exc
|
||||
return response
|
||||
|
||||
def with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
|
||||
if challenge_raw is None:
|
||||
raise BrowserNotConnected("No challenge received from remote endpoint")
|
||||
challenge, nonce_hex = parse_challenge(challenge_raw)
|
||||
check_min_client_version(challenge)
|
||||
return build_auth(msg, challenge, nonce_hex, private_key)
|
||||
|
||||
def should_warn_no_pq(msg: dict) -> bool:
|
||||
return not bool(msg.pop("_suppress_pq_warning", False))
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Socket helpers for remote TCP/TLS transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from contextlib import contextmanager
|
||||
|
||||
from browser_cli.endpoints import _resolve_connect_endpoint
|
||||
from browser_cli.framing import async_recv_exact, async_recv_frame, recv_exact, recv_frame
|
||||
|
||||
def recv_exact_bytes(sock: socket.socket, n: int) -> bytes:
|
||||
return recv_exact(sock, n) or b""
|
||||
|
||||
def recv_all(sock: socket.socket) -> bytes:
|
||||
return recv_frame(sock, label="Response") or b""
|
||||
|
||||
async def async_recv_exact_bytes(reader: asyncio.StreamReader, n: int) -> bytes:
|
||||
return await async_recv_exact(reader, n) or b""
|
||||
|
||||
async def async_recv_all(reader: asyncio.StreamReader) -> bytes:
|
||||
return await async_recv_frame(reader, label="Response") or b""
|
||||
|
||||
def split_endpoint(endpoint: str) -> tuple[str, int]:
|
||||
connect_ep = _resolve_connect_endpoint(endpoint)
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
return host, int(port_str)
|
||||
|
||||
@contextmanager
|
||||
def open_socket(endpoint: str):
|
||||
host, port = split_endpoint(endpoint)
|
||||
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
|
||||
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
|
||||
else:
|
||||
sock = raw_sock
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
with sock:
|
||||
yield sock
|
||||
|
||||
async def open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||
host, port = split_endpoint(endpoint)
|
||||
ssl_ctx = None
|
||||
if port == 443:
|
||||
import ssl
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
|
||||
+28
-199
@@ -1,202 +1,43 @@
|
||||
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
|
||||
|
||||
Owns the wire mechanics of the remote leg: open a socket (TLS on :443),
|
||||
complete the signed challenge/response handshake with an optional post-quantum
|
||||
key exchange, frame the request, and read the framed (possibly encrypted)
|
||||
response. The higher-level "which endpoint / which profile / which key"
|
||||
decisions stay in :mod:`browser_cli.client.core`.
|
||||
This module keeps the public/private compatibility surface used by older tests
|
||||
and callers, while delegating socket mechanics and auth-handshake details to
|
||||
focused helper modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from typing import TypeVar
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.endpoints import _resolve_connect_endpoint
|
||||
from browser_cli.framing import async_recv_exact, async_recv_frame, async_send_frame, frame, recv_exact, recv_frame
|
||||
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
|
||||
|
||||
T = TypeVar("T")
|
||||
_AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
|
||||
_PQ_WARNING = (
|
||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||
from browser_cli.framing import async_send_frame, frame
|
||||
from browser_cli.remote.auth import (
|
||||
build_auth_message as _build_auth_message,
|
||||
build_auth_message_async as _build_auth_message_async,
|
||||
decode_pq_response as _decode_pq_response,
|
||||
parse_challenge as _parse_challenge,
|
||||
should_warn_no_pq as _should_warn_no_pq,
|
||||
with_challenge as _with_challenge,
|
||||
)
|
||||
from browser_cli.remote.socket import (
|
||||
async_recv_all as _async_recv_all,
|
||||
async_recv_exact_bytes as _async_recv_exact,
|
||||
open_async_connection as _open_async_connection,
|
||||
open_socket as _open_socket,
|
||||
recv_all as _recv_all,
|
||||
recv_exact_bytes as _recv_exact,
|
||||
split_endpoint as _split_endpoint,
|
||||
)
|
||||
|
||||
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
||||
return recv_exact(sock, n) or b""
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
||||
|
||||
def _recv_all(sock: socket.socket) -> bytes:
|
||||
return recv_frame(sock, label="Response") or b""
|
||||
|
||||
async def _async_recv_exact(reader: asyncio.StreamReader, n: int) -> bytes:
|
||||
return await async_recv_exact(reader, n) or b""
|
||||
|
||||
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
|
||||
return await async_recv_frame(reader, label="Response") or b""
|
||||
|
||||
def _split_endpoint(endpoint: str) -> tuple[str, int]:
|
||||
connect_ep = _resolve_connect_endpoint(endpoint)
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
return host, int(port_str)
|
||||
|
||||
@contextmanager
|
||||
def _open_socket(endpoint: str):
|
||||
host, port = _split_endpoint(endpoint)
|
||||
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
|
||||
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
|
||||
else:
|
||||
sock = raw_sock
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
with sock:
|
||||
yield sock
|
||||
|
||||
async def _open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||
host, port = _split_endpoint(endpoint)
|
||||
ssl_ctx = None
|
||||
if port == 443:
|
||||
import ssl
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
|
||||
|
||||
def _parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
|
||||
try:
|
||||
challenge = json.loads(raw)
|
||||
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
||||
return challenge, nonce_hex
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return None, None
|
||||
|
||||
def _check_min_client_version(challenge: dict | None) -> None:
|
||||
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
||||
if not min_ver:
|
||||
return
|
||||
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
|
||||
|
||||
def _clean_message(msg: dict) -> dict:
|
||||
return {k: v for k, v in msg.items() if k not in _AUTH_FIELDS}
|
||||
|
||||
def _get_pq_public_key(challenge: dict | None) -> str | None:
|
||||
if not isinstance(challenge, dict):
|
||||
return None
|
||||
from browser_cli.auth import PQ_KEX_ALG
|
||||
kex = challenge.get("pq_kex")
|
||||
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
||||
return str(kex["public_key"])
|
||||
return None
|
||||
|
||||
def _signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
|
||||
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, public_key_hex, sign
|
||||
|
||||
nonce = bytes.fromhex(nonce_hex)
|
||||
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
||||
pubkey = public_key_hex(private_key)
|
||||
if pq_shared_secret is None:
|
||||
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
|
||||
|
||||
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
||||
return {
|
||||
"id": clean_msg.get("id"),
|
||||
"user_agent": clean_msg.get("user_agent"),
|
||||
"pubkey": pubkey,
|
||||
"sig": sig.hex(),
|
||||
"pq_kex": clean_msg["pq_kex"],
|
||||
"encrypted": encrypted,
|
||||
}
|
||||
|
||||
def _warn_no_pq(enabled: bool) -> None:
|
||||
if enabled:
|
||||
sys.stderr.write(_PQ_WARNING)
|
||||
|
||||
def _build_auth_message(
|
||||
msg: dict,
|
||||
challenge: dict | None,
|
||||
nonce_hex: str | None,
|
||||
private_key,
|
||||
encapsulate: Callable[[str], tuple[str, bytes]],
|
||||
*,
|
||||
warn_no_pq: bool = True,
|
||||
) -> tuple[dict, bytes | None]:
|
||||
if not nonce_hex or private_key is None:
|
||||
_warn_no_pq(warn_no_pq)
|
||||
return msg, None
|
||||
|
||||
clean_msg = _clean_message(msg)
|
||||
pq_shared_secret = None
|
||||
pq_public_key = _get_pq_public_key(challenge)
|
||||
if pq_public_key:
|
||||
from browser_cli.auth import PQ_KEX_ALG
|
||||
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
|
||||
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
||||
else:
|
||||
_warn_no_pq(warn_no_pq)
|
||||
|
||||
return _signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
|
||||
|
||||
async def _build_auth_message_async(
|
||||
msg: dict,
|
||||
challenge: dict | None,
|
||||
nonce_hex: str | None,
|
||||
private_key,
|
||||
*,
|
||||
warn_no_pq: bool = True,
|
||||
) -> tuple[dict, bytes | None]:
|
||||
def encapsulate(public_key: str) -> tuple[str, bytes]:
|
||||
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
||||
from browser_cli.auth import pq_kex_client_encapsulate
|
||||
return pq_kex_client_encapsulate(public_key)
|
||||
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
|
||||
|
||||
return await asyncio.to_thread(
|
||||
_build_auth_message,
|
||||
msg,
|
||||
challenge,
|
||||
nonce_hex,
|
||||
private_key,
|
||||
encapsulate,
|
||||
warn_no_pq=warn_no_pq,
|
||||
)
|
||||
|
||||
def _decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
|
||||
if response is None or pq_shared_secret is None:
|
||||
return response
|
||||
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 _with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
|
||||
if challenge_raw is None:
|
||||
raise BrowserNotConnected("No challenge received from remote endpoint")
|
||||
challenge, nonce_hex = _parse_challenge(challenge_raw)
|
||||
_check_min_client_version(challenge)
|
||||
return build_auth(msg, challenge, nonce_hex, private_key)
|
||||
|
||||
def _should_warn_no_pq(msg: dict) -> bool:
|
||||
return not bool(msg.pop("_suppress_pq_warning", False))
|
||||
with _open_socket(endpoint) as sock:
|
||||
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
||||
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
||||
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
|
||||
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
reader, writer = await _open_async_connection(endpoint)
|
||||
@@ -216,15 +57,3 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
||||
|
||||
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
||||
from browser_cli.auth import pq_kex_client_encapsulate
|
||||
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
|
||||
|
||||
with _open_socket(endpoint) as sock:
|
||||
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
||||
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
||||
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, TypeVar
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
F = TypeVar("F", bound=Callable)
|
||||
_MISSING = object()
|
||||
@@ -54,8 +54,8 @@ def sdk_command(
|
||||
return _clone_default(default)
|
||||
return result
|
||||
|
||||
wrapper._browser_cli_command = name # type: ignore[attr-defined]
|
||||
return wrapper # type: ignore[return-value]
|
||||
setattr(wrapper, "_browser_cli_command", name)
|
||||
return cast(F, wrapper)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
from typing import TypeVar, cast
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
|
||||
@@ -53,7 +53,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
|
||||
finally:
|
||||
if cleanup is not None:
|
||||
await asyncio.to_thread(cleanup, value)
|
||||
return async_wrapper # type: ignore[return-value]
|
||||
return cast(F, async_wrapper)
|
||||
return WorkflowDecoratorsMixin._value_decorator(
|
||||
self, fn, get_value, keyword=keyword, cleanup=cleanup
|
||||
)
|
||||
@@ -74,7 +74,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
|
||||
finally:
|
||||
if previous:
|
||||
await asyncio.to_thread(self._c.perf.set_profile, previous)
|
||||
return async_wrapper # type: ignore[return-value]
|
||||
return cast(F, async_wrapper)
|
||||
return WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn)
|
||||
return decorator
|
||||
|
||||
@@ -101,7 +101,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
|
||||
raise
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
raise last_error # type: ignore[misc]
|
||||
return async_wrapper # type: ignore[return-value]
|
||||
raise cast(BaseException, last_error)
|
||||
return cast(F, async_wrapper)
|
||||
return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn)
|
||||
return decorator
|
||||
|
||||
+101
-80
@@ -11,93 +11,114 @@ from typing import Any, Protocol, cast
|
||||
|
||||
from browser_cli.models import Group, Tab
|
||||
|
||||
def _target_group(target) -> str | None:
|
||||
if target is None:
|
||||
return None
|
||||
return getattr(target, "display_group", None) or ("local" if target.remote is None else None)
|
||||
|
||||
class _FactoryClient(Protocol):
|
||||
_key: str | None
|
||||
_key: str | None
|
||||
|
||||
class FactoryMixin:
|
||||
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
|
||||
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
|
||||
|
||||
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
|
||||
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
|
||||
"""
|
||||
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
|
||||
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
|
||||
"""
|
||||
|
||||
def tab_from(
|
||||
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),
|
||||
active=data.get("active", False),
|
||||
muted=data.get("muted", False),
|
||||
title=data.get("title") or "",
|
||||
url=data.get("url") or "",
|
||||
group_id=data.get("groupId") or None,
|
||||
browser=browser_name,
|
||||
)
|
||||
client = cast(_FactoryClient, self)
|
||||
tab._browser = self if browser_profile is None else cast(Any, type(self))(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=client._key,
|
||||
_command_sender=getattr(self, "_command_sender", None),
|
||||
)
|
||||
return tab
|
||||
def tab_from(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_type: str | None = None,
|
||||
browser_group: str | None = None,
|
||||
) -> Tab:
|
||||
tab = Tab(
|
||||
id=data["id"],
|
||||
window_id=data.get("windowId", 0),
|
||||
active=data.get("active", False),
|
||||
muted=data.get("muted", False),
|
||||
title=data.get("title") or "",
|
||||
url=data.get("url") or "",
|
||||
group_id=data.get("groupId") or None,
|
||||
index=data.get("index", 0) or 0,
|
||||
browser=browser_name,
|
||||
browser_name=browser_type,
|
||||
browser_group=browser_group,
|
||||
)
|
||||
client = cast(_FactoryClient, self)
|
||||
tab._browser = self if browser_profile is None else cast(Any, type(self))(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=client._key,
|
||||
_command_sender=getattr(self, "_command_sender", None),
|
||||
)
|
||||
return tab
|
||||
|
||||
def require_tab_response(self, data, error: str) -> Tab:
|
||||
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError(error)
|
||||
return self.tab_from(data)
|
||||
def require_tab_response(self, data, error: str) -> Tab:
|
||||
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError(error)
|
||||
return self.tab_from(data)
|
||||
|
||||
def group_from(
|
||||
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 "",
|
||||
color=data.get("color") or "",
|
||||
collapsed=data.get("collapsed", False),
|
||||
tab_count=data.get("tabCount", 0),
|
||||
browser=browser_name,
|
||||
)
|
||||
client = cast(_FactoryClient, self)
|
||||
group._browser = self if browser_profile is None else cast(Any, type(self))(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=client._key,
|
||||
_command_sender=getattr(self, "_command_sender", None),
|
||||
)
|
||||
return group
|
||||
def group_from(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_type: str | None = None,
|
||||
browser_group: str | None = None,
|
||||
) -> Group:
|
||||
group = Group(
|
||||
id=data["id"],
|
||||
title=data.get("title") or "",
|
||||
color=data.get("color") or "",
|
||||
collapsed=data.get("collapsed", False),
|
||||
tab_count=data.get("tabCount", 0),
|
||||
window_id=data.get("windowId"),
|
||||
browser=browser_name,
|
||||
browser_name=browser_type,
|
||||
browser_group=browser_group,
|
||||
)
|
||||
client = cast(_FactoryClient, self)
|
||||
group._browser = self if browser_profile is None else cast(Any, type(self))(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=client._key,
|
||||
_command_sender=getattr(self, "_command_sender", None),
|
||||
)
|
||||
return group
|
||||
|
||||
def tab_from_target(self, data: dict, target) -> Tab:
|
||||
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
|
||||
return self.tab_from(
|
||||
data,
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
)
|
||||
def tab_from_target(self, data: dict, target) -> Tab:
|
||||
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
|
||||
return self.tab_from(
|
||||
data,
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
browser_type=getattr(target, "browser_name", None) if target else None,
|
||||
browser_group=_target_group(target),
|
||||
)
|
||||
|
||||
def group_from_target(self, data: dict, target) -> Group:
|
||||
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
|
||||
return self.group_from(
|
||||
data,
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
)
|
||||
def group_from_target(self, data: dict, target) -> Group:
|
||||
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
|
||||
return self.group_from(
|
||||
data,
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
browser_type=getattr(target, "browser_name", None) if target else None,
|
||||
browser_group=_target_group(target),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def tag_browser(item: dict, target) -> dict:
|
||||
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
|
||||
return item if target is None else {**item, "browser": target.display_name}
|
||||
@staticmethod
|
||||
def tag_browser(item: dict, target) -> dict:
|
||||
"""Return *item* as-is locally, or with browser metadata in multi-browser mode."""
|
||||
if target is None:
|
||||
return item
|
||||
return {**item, "browser": target.display_name, "browserGroup": _target_group(target)}
|
||||
|
||||
@@ -37,6 +37,20 @@ _UNSET = object()
|
||||
def _browser_cli_package():
|
||||
return sys.modules.get("browser_cli") or importlib.import_module("browser_cli")
|
||||
|
||||
def _with_profile_display(targets: list[BrowserTarget]) -> list[BrowserTarget]:
|
||||
"""Use profile-only labels when a command is already scoped to one remote."""
|
||||
return [
|
||||
BrowserTarget(
|
||||
profile=target.profile,
|
||||
display_name=target.profile if target.remote else target.display_name,
|
||||
socket_path=target.socket_path,
|
||||
remote=target.remote,
|
||||
browser_name=target.browser_name,
|
||||
display_group=None,
|
||||
)
|
||||
for target in targets
|
||||
]
|
||||
|
||||
class RoutingMixin:
|
||||
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
|
||||
|
||||
@@ -51,10 +65,15 @@ class RoutingMixin:
|
||||
def _multi_browser_targets(self) -> list[BrowserTarget]:
|
||||
client = self._client
|
||||
package = _browser_cli_package()
|
||||
if client._browser is not None:
|
||||
if client._browser is not None and not client._remote:
|
||||
targets = package.remote_targets_for_alias(client._browser, key=client._key)
|
||||
if len(targets) <= 1:
|
||||
return []
|
||||
targets = _with_profile_display(targets)
|
||||
elif client._browser is not None:
|
||||
return []
|
||||
if client._remote:
|
||||
targets = package.remote_browser_targets(client._remote, key=client._key)
|
||||
elif client._remote:
|
||||
targets = _with_profile_display(package.remote_browser_targets(client._remote, key=client._key))
|
||||
else:
|
||||
targets = package.active_browser_targets()
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
@@ -107,7 +126,12 @@ class RoutingMixin:
|
||||
if not multi_results:
|
||||
return self._client.dispatch(command, args or {})
|
||||
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
|
||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
||||
browser_groups = {
|
||||
target.display_name: target.display_group or "local"
|
||||
for target, _count in multi_results
|
||||
if target.display_group or target.remote is None
|
||||
}
|
||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser, browser_groups=browser_groups)
|
||||
|
||||
def multi_list(self, command: str, args: dict | None, mapper):
|
||||
"""List command, flattening per-browser results in multi-browser mode.
|
||||
|
||||
@@ -6,6 +6,11 @@ from collections.abc import Callable, Iterable
|
||||
from browser_cli.models import BrowserCounts, Tab
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
# Keep SDK-driven bulk closes comfortably below the native-host response
|
||||
# timeout. The extension can close larger batches, but real browsers may take
|
||||
# much longer when hundreds of visible tabs are involved.
|
||||
BULK_CLOSE_CHUNK_SIZE = 50
|
||||
|
||||
class TabsNS(Namespace):
|
||||
"""List, open, close, move, and inspect browser tabs."""
|
||||
|
||||
@@ -75,6 +80,20 @@ class TabsNS(Namespace):
|
||||
ids = None
|
||||
if tab_ids is not None:
|
||||
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
|
||||
if ids is not None and len(ids) > BULK_CLOSE_CHUNK_SIZE and not inactive and not duplicates and tab_id is None:
|
||||
closed = 0
|
||||
for start in range(0, len(ids), BULK_CLOSE_CHUNK_SIZE):
|
||||
chunk = ids[start:start + BULK_CLOSE_CHUNK_SIZE]
|
||||
result = self.command("tabs.close", {
|
||||
"tabId": None,
|
||||
"tabIds": chunk,
|
||||
"inactive": False,
|
||||
"duplicates": False,
|
||||
"gentleMode": gentle_mode,
|
||||
})
|
||||
closed += self.field(result, "closed", len(chunk))
|
||||
return closed
|
||||
|
||||
result = self.command("tabs.close", {
|
||||
"tabId": tab_id,
|
||||
"tabIds": ids,
|
||||
|
||||
@@ -4,11 +4,32 @@ from __future__ import annotations
|
||||
import functools
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
from typing import Protocol, TypeVar, cast
|
||||
|
||||
F = TypeVar("F", bound=Callable)
|
||||
_NO_INJECT = object()
|
||||
|
||||
class _WorkflowTabs(Protocol):
|
||||
def active(self): ...
|
||||
def open(self, *args, **kwargs): ...
|
||||
def watch_url(self, *args, **kwargs): ...
|
||||
|
||||
class _WorkflowDom(Protocol):
|
||||
def wait_for(self, *args, **kwargs): ...
|
||||
|
||||
class _WorkflowPerf(Protocol):
|
||||
def status(self): ...
|
||||
def set_profile(self, profile: str): ...
|
||||
|
||||
class _WorkflowSession(Protocol):
|
||||
def save(self, name: str): ...
|
||||
|
||||
class _WorkflowClient(Protocol):
|
||||
tabs: _WorkflowTabs
|
||||
dom: _WorkflowDom
|
||||
perf: _WorkflowPerf
|
||||
session: _WorkflowSession
|
||||
|
||||
class WorkflowDecoratorsMixin:
|
||||
"""Shared implementation for sync and async workflow decorators.
|
||||
|
||||
@@ -17,7 +38,7 @@ class WorkflowDecoratorsMixin:
|
||||
in lockstep.
|
||||
"""
|
||||
|
||||
_c: object
|
||||
_c: _WorkflowClient
|
||||
|
||||
@staticmethod
|
||||
def _inject(kwargs: dict, keyword: str | None, value):
|
||||
@@ -62,7 +83,7 @@ class WorkflowDecoratorsMixin:
|
||||
finally:
|
||||
if cleanup is not None:
|
||||
self._run(cleanup, value)
|
||||
return wrapper # type: ignore[return-value]
|
||||
return cast(F, wrapper)
|
||||
|
||||
return decorator(func) if func is not None else decorator
|
||||
|
||||
@@ -72,7 +93,7 @@ class WorkflowDecoratorsMixin:
|
||||
By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to
|
||||
pass it as the first positional argument instead.
|
||||
"""
|
||||
return self._value_decorator(func, self._c.tabs.active, keyword=keyword) # type: ignore[attr-defined]
|
||||
return self._value_decorator(func, self._c.tabs.active, keyword=keyword)
|
||||
|
||||
def new_tab(
|
||||
self,
|
||||
@@ -93,7 +114,7 @@ class WorkflowDecoratorsMixin:
|
||||
wrapped function returns or raises.
|
||||
"""
|
||||
def open_tab():
|
||||
return self._c.tabs.open( # type: ignore[attr-defined]
|
||||
return self._c.tabs.open(
|
||||
url,
|
||||
wait=wait,
|
||||
timeout=timeout,
|
||||
@@ -124,7 +145,7 @@ class WorkflowDecoratorsMixin:
|
||||
the wrapped function. By default the result is not injected.
|
||||
"""
|
||||
def wait():
|
||||
return self._c.dom.wait_for( # type: ignore[attr-defined]
|
||||
return self._c.dom.wait_for(
|
||||
selector,
|
||||
timeout=timeout,
|
||||
visible=visible,
|
||||
@@ -145,7 +166,7 @@ class WorkflowDecoratorsMixin:
|
||||
):
|
||||
"""Wait until a tab URL matches *pattern* before calling the function."""
|
||||
def wait():
|
||||
return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) # type: ignore[attr-defined]
|
||||
return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
|
||||
|
||||
inject = keyword if keyword is not None else _NO_INJECT
|
||||
return self._value_decorator(None, wait, keyword=inject)
|
||||
@@ -157,19 +178,19 @@ class WorkflowDecoratorsMixin:
|
||||
def wrapper(*args, **kwargs):
|
||||
previous = None
|
||||
if restore:
|
||||
previous = self._run(self._c.perf.status).get("performanceProfile") # type: ignore[attr-defined]
|
||||
self._run(self._c.perf.set_profile, profile) # type: ignore[attr-defined]
|
||||
previous = self._run(self._c.perf.status).get("performanceProfile")
|
||||
self._run(self._c.perf.set_profile, profile)
|
||||
try:
|
||||
return self._call_wrapped(fn, *args, **kwargs)
|
||||
finally:
|
||||
if previous:
|
||||
self._run(self._c.perf.set_profile, previous) # type: ignore[attr-defined]
|
||||
return wrapper # type: ignore[return-value]
|
||||
self._run(self._c.perf.set_profile, previous)
|
||||
return cast(F, wrapper)
|
||||
return decorator
|
||||
|
||||
def save_session_before(self, name: str):
|
||||
"""Save the current browser session before running the function."""
|
||||
return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT) # type: ignore[attr-defined]
|
||||
return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT)
|
||||
|
||||
def retry(
|
||||
self,
|
||||
@@ -194,7 +215,7 @@ class WorkflowDecoratorsMixin:
|
||||
raise
|
||||
if delay > 0:
|
||||
self._sleep(delay)
|
||||
raise last_error # type: ignore[misc]
|
||||
return wrapper # type: ignore[return-value]
|
||||
raise cast(BaseException, last_error)
|
||||
return cast(F, wrapper)
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Challenge-frame helpers for ``browser-cli serve``."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
|
||||
|
||||
async def load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
|
||||
if auth_keys_path is None:
|
||||
return None
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
|
||||
|
||||
async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
|
||||
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 = await asyncio.to_thread(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()}
|
||||
return nonce, pq_private_key, challenge_msg
|
||||
@@ -16,11 +16,19 @@ class ServeControlMixin:
|
||||
|
||||
async def handle_control_command(self, msg: dict) -> bool:
|
||||
if self.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)
|
||||
]
|
||||
from browser_cli.client import active_browser_targets, send_command
|
||||
targets = []
|
||||
for target in active_browser_targets(include_remotes=False):
|
||||
item = {"profile": target.profile, "displayName": target.display_name}
|
||||
try:
|
||||
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
|
||||
if clients:
|
||||
browser_name = clients[0].get("name")
|
||||
if browser_name:
|
||||
item["browserName"] = browser_name
|
||||
except Exception:
|
||||
pass
|
||||
targets.append(item)
|
||||
await self.send_ok(targets, self.command)
|
||||
log_request(self.addr, self.command, None, "OK")
|
||||
return True
|
||||
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -17,10 +16,10 @@ from browser_cli import transport
|
||||
from browser_cli.compat import adapt_auth
|
||||
from browser_cli.framing import async_recv_frame, async_send_frame
|
||||
from browser_cli.serve.auth import ServeAuthMixin
|
||||
from browser_cli.serve.challenge import build_challenge as _build_challenge, load_auth_keys as _load_auth_keys
|
||||
from browser_cli.serve.control import ServeControlMixin
|
||||
from browser_cli.serve.logging import console, log_request
|
||||
from browser_cli.serve.proxy import ServeProxyMixin
|
||||
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
|
||||
|
||||
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
|
||||
await async_send_frame(writer, data)
|
||||
@@ -140,29 +139,6 @@ async def _async_handle_client(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
|
||||
if auth_keys_path is None:
|
||||
return None
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
|
||||
|
||||
async def _build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
|
||||
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 = await asyncio.to_thread(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()}
|
||||
return nonce, pq_private_key, challenge_msg
|
||||
|
||||
def _handle_client(
|
||||
client_sock: socket.socket,
|
||||
addr: tuple,
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"""Response payload encoding for the TCP serve <-> client leg.
|
||||
|
||||
The wire frame stays ``4-byte LE length + payload``. The payload is made
|
||||
self-describing so old peers keep working unchanged:
|
||||
|
||||
* A payload that starts with ``{`` or ``[`` is plain JSON (the historical
|
||||
format). Old clients and old servers only ever produce/consume this.
|
||||
* Any other leading byte is a 1-byte codec tag followed by the encoded body.
|
||||
The tag's high nibble selects serialization, the low nibble compression::
|
||||
|
||||
tag = (serialization << 4) | compression
|
||||
|
||||
This is only ever emitted toward a peer that advertised support for it, so it
|
||||
is fully backward compatible: clients announce what they can decode via the
|
||||
``accept_encoding`` field in their request, and the server encodes the
|
||||
response accordingly. Requests themselves stay plain JSON (they are tiny).
|
||||
|
||||
Compression is the big win — response payloads (``extract.html``,
|
||||
``dom.query``, ``tabs.list`` over hundreds of tabs, base64 screenshots) are
|
||||
heavy and text-like. msgpack additionally lets ``tabs.screenshot`` ship the
|
||||
image as raw bytes instead of a base64 data URL (~33% smaller before
|
||||
compression); the client transparently rebuilds the data URL so the SDK/CLI
|
||||
API is unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import zlib
|
||||
|
||||
from browser_cli.constants import (
|
||||
COMP_GZIP,
|
||||
COMP_NONE,
|
||||
COMP_ZLIB,
|
||||
COMP_ZSTD,
|
||||
DEFAULT_TRANSPORT_THRESHOLD,
|
||||
SER_JSON,
|
||||
SER_MSGPACK,
|
||||
)
|
||||
|
||||
try: # optional: better ratio + speed than zlib/gzip
|
||||
import zstandard as _zstd
|
||||
except Exception: # pragma: no cover - depends on optional extra
|
||||
_zstd = None
|
||||
|
||||
try: # optional: alternate serialization + raw binary for screenshots
|
||||
import msgpack as _msgpack
|
||||
except Exception: # pragma: no cover - depends on optional extra
|
||||
_msgpack = None
|
||||
|
||||
# ── codec ids ────────────────────────────────────────────────────────────────
|
||||
_SER_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
|
||||
_SER_ID = {v: k for k, v in _SER_NAME.items()}
|
||||
_COMP_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
|
||||
_COMP_ID = {v: k for k, v in _COMP_NAME.items()}
|
||||
|
||||
# Don't compress payloads smaller than this — the header/CPU cost is not worth it.
|
||||
|
||||
# JSON top-level values always start with one of these bytes; a tag byte never does.
|
||||
_JSON_FIRST_BYTES = frozenset(b"{[")
|
||||
|
||||
def msgpack_available() -> bool:
|
||||
return _msgpack is not None
|
||||
|
||||
def zstd_available() -> bool:
|
||||
return _zstd is not None
|
||||
|
||||
def supported_serialization() -> list[str]:
|
||||
"""Serializations this build can produce/consume, best first."""
|
||||
return (["msgpack"] if _msgpack is not None else []) + ["json"]
|
||||
|
||||
def supported_compression() -> list[str]:
|
||||
"""Compression codecs this build can produce/consume, best first."""
|
||||
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
|
||||
|
||||
def client_accept_encoding() -> dict:
|
||||
"""What the local client advertises it can decode (sent with each request)."""
|
||||
return {"ser": supported_serialization(), "comp": supported_compression()}
|
||||
|
||||
# ── compression primitives ────────────────────────────────────────────────────
|
||||
|
||||
def _compress(comp_id: int, data: bytes) -> bytes:
|
||||
if comp_id == COMP_NONE:
|
||||
return data
|
||||
if comp_id == COMP_ZLIB:
|
||||
return zlib.compress(data, 6)
|
||||
if comp_id == COMP_GZIP:
|
||||
return gzip.compress(data, compresslevel=6)
|
||||
if comp_id == COMP_ZSTD:
|
||||
if _zstd is None:
|
||||
raise ValueError("zstd compression requested but zstandard is not installed")
|
||||
return _zstd.ZstdCompressor(level=10).compress(data)
|
||||
raise ValueError(f"unknown compression id {comp_id}")
|
||||
|
||||
def _decompress(comp_id: int, data: bytes) -> bytes:
|
||||
if comp_id == COMP_NONE:
|
||||
return data
|
||||
if comp_id == COMP_ZLIB:
|
||||
return zlib.decompress(data)
|
||||
if comp_id == COMP_GZIP:
|
||||
return gzip.decompress(data)
|
||||
if comp_id == COMP_ZSTD:
|
||||
if _zstd is None:
|
||||
raise ValueError("zstd payload received but zstandard is not installed")
|
||||
return _zstd.ZstdDecompressor().decompress(data)
|
||||
raise ValueError(f"unknown compression id {comp_id}")
|
||||
|
||||
# ── codec negotiation ──────────────────────────────────────────────────────────
|
||||
|
||||
def _choose(accept: dict | None) -> tuple[int, int]:
|
||||
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
|
||||
accept = accept if isinstance(accept, dict) else {}
|
||||
accept_ser = accept.get("ser") or ["json"]
|
||||
accept_comp = accept.get("comp") or []
|
||||
|
||||
ser = SER_JSON
|
||||
if _msgpack is not None and "msgpack" in accept_ser:
|
||||
ser = SER_MSGPACK
|
||||
|
||||
comp = COMP_NONE
|
||||
for name in supported_compression(): # server preference: zstd > gzip > zlib
|
||||
if name in accept_comp:
|
||||
comp = _COMP_ID[name]
|
||||
break
|
||||
return ser, comp
|
||||
|
||||
# ── raw-binary hoisting (screenshots) ──────────────────────────────────────────
|
||||
|
||||
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
|
||||
_B64_MARKER = "__b64__"
|
||||
|
||||
def _hoist_screenshot(obj, command: str | None):
|
||||
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
|
||||
|
||||
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
|
||||
"""
|
||||
if command != "tabs.screenshot" or not isinstance(obj, dict):
|
||||
return obj
|
||||
data = obj.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return obj
|
||||
url = data.get("dataUrl")
|
||||
if not isinstance(url, str):
|
||||
return obj
|
||||
m = _DATA_URL_RE.match(url)
|
||||
if not m:
|
||||
return obj
|
||||
try:
|
||||
raw = base64.b64decode(m.group(2))
|
||||
except Exception:
|
||||
return obj
|
||||
new_data = dict(data)
|
||||
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
|
||||
return {**obj, "data": new_data}
|
||||
|
||||
def _unhoist_binary(obj):
|
||||
"""Rebuild any hoisted data URL so callers see the original string again."""
|
||||
if isinstance(obj, dict):
|
||||
raw = obj.get("raw")
|
||||
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
|
||||
mime = obj.get("mime") or "application/octet-stream"
|
||||
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
|
||||
return {k: _unhoist_binary(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_unhoist_binary(v) for v in obj]
|
||||
return obj
|
||||
|
||||
# ── encode / decode ─────────────────────────────────────────────────────────────
|
||||
|
||||
def encode_response(obj, accept: dict | None = None, command: str | None = None,
|
||||
threshold: int = DEFAULT_TRANSPORT_THRESHOLD) -> bytes:
|
||||
"""Encode a response object for the chosen/accepted codec.
|
||||
|
||||
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
|
||||
what an old server would have sent.
|
||||
"""
|
||||
ser, comp = _choose(accept)
|
||||
|
||||
if ser == SER_MSGPACK:
|
||||
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
|
||||
else:
|
||||
body = json.dumps(obj).encode("utf-8")
|
||||
|
||||
if comp != COMP_NONE and len(body) >= threshold:
|
||||
body = _compress(comp, body)
|
||||
else:
|
||||
comp = COMP_NONE
|
||||
|
||||
if ser == SER_JSON and comp == COMP_NONE:
|
||||
return body # plain JSON — historical wire format, no tag byte
|
||||
|
||||
return bytes([(ser << 4) | comp]) + body
|
||||
|
||||
def decode_response(raw: bytes | None):
|
||||
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not raw:
|
||||
raise ValueError("empty response payload")
|
||||
if raw[0] in _JSON_FIRST_BYTES:
|
||||
return json.loads(raw)
|
||||
|
||||
tag = raw[0]
|
||||
ser, comp = tag >> 4, tag & 0x0F
|
||||
body = _decompress(comp, raw[1:])
|
||||
if ser == SER_MSGPACK:
|
||||
if _msgpack is None:
|
||||
raise ValueError("msgpack payload received but msgpack is not installed")
|
||||
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
|
||||
if ser == SER_JSON:
|
||||
return json.loads(body)
|
||||
raise ValueError(f"unknown serialization id {ser}")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Response payload encoding for the TCP serve <-> client leg.
|
||||
|
||||
The wire frame stays ``4-byte LE length + payload``. Payloads are plain JSON
|
||||
for legacy peers, or a 1-byte codec tag followed by serialized/compressed data
|
||||
when the peer advertised support for it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from browser_cli.constants import COMP_GZIP, COMP_NONE, COMP_ZLIB, COMP_ZSTD, DEFAULT_TRANSPORT_THRESHOLD, SER_JSON, SER_MSGPACK
|
||||
from browser_cli.transport.binary import hoist_screenshot as _hoist_screenshot, unhoist_binary as _unhoist_binary
|
||||
from browser_cli.transport.codecs import (
|
||||
JSON_FIRST_BYTES as _JSON_FIRST_BYTES,
|
||||
_msgpack,
|
||||
choose_codec as _choose,
|
||||
client_accept_encoding,
|
||||
compress_payload as _compress,
|
||||
decompress_payload as _decompress,
|
||||
msgpack_available,
|
||||
supported_compression,
|
||||
supported_serialization,
|
||||
zstd_available,
|
||||
)
|
||||
|
||||
def encode_response(
|
||||
obj,
|
||||
accept: dict | None = None,
|
||||
command: str | None = None,
|
||||
threshold: int = DEFAULT_TRANSPORT_THRESHOLD,
|
||||
) -> bytes:
|
||||
"""Encode a response object for the chosen/accepted codec.
|
||||
|
||||
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
|
||||
what an old server would have sent.
|
||||
"""
|
||||
ser, comp = _choose(accept)
|
||||
|
||||
if ser == SER_MSGPACK:
|
||||
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
|
||||
else:
|
||||
body = json.dumps(obj).encode("utf-8")
|
||||
|
||||
if comp != COMP_NONE and len(body) >= threshold:
|
||||
body = _compress(comp, body)
|
||||
else:
|
||||
comp = COMP_NONE
|
||||
|
||||
if ser == SER_JSON and comp == COMP_NONE:
|
||||
return body # plain JSON — historical wire format, no tag byte
|
||||
|
||||
return bytes([(ser << 4) | comp]) + body
|
||||
|
||||
def decode_response(raw: bytes | None):
|
||||
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not raw:
|
||||
raise ValueError("empty response payload")
|
||||
if raw[0] in _JSON_FIRST_BYTES:
|
||||
return json.loads(raw)
|
||||
|
||||
tag = raw[0]
|
||||
ser, comp = tag >> 4, tag & 0x0F
|
||||
body = _decompress(comp, raw[1:])
|
||||
if ser == SER_MSGPACK:
|
||||
if _msgpack is None:
|
||||
raise ValueError("msgpack payload received but msgpack is not installed")
|
||||
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
|
||||
if ser == SER_JSON:
|
||||
return json.loads(body)
|
||||
raise ValueError(f"unknown serialization id {ser}")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Raw-binary hoisting helpers for encoded response payloads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
|
||||
DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
|
||||
B64_MARKER = "__b64__"
|
||||
|
||||
def hoist_screenshot(obj, command: str | None):
|
||||
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
|
||||
|
||||
Gated to ``tabs.screenshot`` so arbitrary page-derived data is never touched.
|
||||
"""
|
||||
if command != "tabs.screenshot" or not isinstance(obj, dict):
|
||||
return obj
|
||||
data = obj.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return obj
|
||||
url = data.get("dataUrl")
|
||||
if not isinstance(url, str):
|
||||
return obj
|
||||
match = DATA_URL_RE.match(url)
|
||||
if not match:
|
||||
return obj
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2))
|
||||
except Exception:
|
||||
return obj
|
||||
new_data = dict(data)
|
||||
new_data["dataUrl"] = {B64_MARKER: True, "mime": match.group(1), "raw": raw}
|
||||
return {**obj, "data": new_data}
|
||||
|
||||
def unhoist_binary(obj):
|
||||
"""Rebuild any hoisted data URL so callers see the original string again."""
|
||||
if isinstance(obj, dict):
|
||||
raw = obj.get("raw")
|
||||
if obj.get(B64_MARKER) and isinstance(raw, (bytes, bytearray)):
|
||||
mime = obj.get("mime") or "application/octet-stream"
|
||||
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
|
||||
return {key: unhoist_binary(value) for key, value in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [unhoist_binary(value) for value in obj]
|
||||
return obj
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Serialization/compression primitives for TCP response payloads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import zlib
|
||||
|
||||
from browser_cli.constants import COMP_GZIP, COMP_NONE, COMP_ZLIB, COMP_ZSTD, SER_JSON, SER_MSGPACK
|
||||
|
||||
try: # optional: better ratio + speed than zlib/gzip
|
||||
import zstandard as _zstd
|
||||
except Exception: # pragma: no cover - depends on optional extra
|
||||
_zstd = None
|
||||
|
||||
try: # optional: alternate serialization + raw binary for screenshots
|
||||
import msgpack as _msgpack
|
||||
except Exception: # pragma: no cover - depends on optional extra
|
||||
_msgpack = None
|
||||
|
||||
SERIALIZATION_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
|
||||
SERIALIZATION_ID = {value: key for key, value in SERIALIZATION_NAME.items()}
|
||||
COMPRESSION_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
|
||||
COMPRESSION_ID = {value: key for key, value in COMPRESSION_NAME.items()}
|
||||
JSON_FIRST_BYTES = frozenset(b"{[")
|
||||
|
||||
def msgpack_available() -> bool:
|
||||
return _msgpack is not None
|
||||
|
||||
def zstd_available() -> bool:
|
||||
return _zstd is not None
|
||||
|
||||
def supported_serialization() -> list[str]:
|
||||
"""Serializations this build can produce/consume, best first."""
|
||||
return (["msgpack"] if _msgpack is not None else []) + ["json"]
|
||||
|
||||
def supported_compression() -> list[str]:
|
||||
"""Compression codecs this build can produce/consume, best first."""
|
||||
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
|
||||
|
||||
def client_accept_encoding() -> dict:
|
||||
"""What the local client advertises it can decode (sent with each request)."""
|
||||
return {"ser": supported_serialization(), "comp": supported_compression()}
|
||||
|
||||
def compress_payload(comp_id: int, data: bytes) -> bytes:
|
||||
if comp_id == COMP_NONE:
|
||||
return data
|
||||
if comp_id == COMP_ZLIB:
|
||||
return zlib.compress(data, 6)
|
||||
if comp_id == COMP_GZIP:
|
||||
return gzip.compress(data, compresslevel=6)
|
||||
if comp_id == COMP_ZSTD:
|
||||
if _zstd is None:
|
||||
raise ValueError("zstd compression requested but zstandard is not installed")
|
||||
return _zstd.ZstdCompressor(level=10).compress(data)
|
||||
raise ValueError(f"unknown compression id {comp_id}")
|
||||
|
||||
def decompress_payload(comp_id: int, data: bytes) -> bytes:
|
||||
if comp_id == COMP_NONE:
|
||||
return data
|
||||
if comp_id == COMP_ZLIB:
|
||||
return zlib.decompress(data)
|
||||
if comp_id == COMP_GZIP:
|
||||
return gzip.decompress(data)
|
||||
if comp_id == COMP_ZSTD:
|
||||
if _zstd is None:
|
||||
raise ValueError("zstd payload received but zstandard is not installed")
|
||||
return _zstd.ZstdDecompressor().decompress(data)
|
||||
raise ValueError(f"unknown compression id {comp_id}")
|
||||
|
||||
def choose_codec(accept: dict | None) -> tuple[int, int]:
|
||||
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
|
||||
accept = accept if isinstance(accept, dict) else {}
|
||||
accept_ser = accept.get("ser") or ["json"]
|
||||
accept_comp = accept.get("comp") or []
|
||||
|
||||
serialization = SER_JSON
|
||||
if _msgpack is not None and "msgpack" in accept_ser:
|
||||
serialization = SER_MSGPACK
|
||||
|
||||
compression = COMP_NONE
|
||||
for name in supported_compression(): # server preference: zstd > gzip > zlib
|
||||
if name in accept_comp:
|
||||
compression = COMPRESSION_ID[name]
|
||||
break
|
||||
return serialization, compression
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.6",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
import type { ContentArgs } from '../types';
|
||||
|
||||
export function extractMarkdown({ selector }: ContentArgs) {
|
||||
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: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeInline(value: string) {
|
||||
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: string) {
|
||||
return value
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeMarkdown(text: string) {
|
||||
return text.replace(/([\\`[\]])/g, "\\$1");
|
||||
}
|
||||
|
||||
function escapeTableCell(text: string) {
|
||||
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
||||
}
|
||||
|
||||
function absoluteUrl(attr: string | null | undefined, fallback?: string) {
|
||||
return attr || fallback || "";
|
||||
}
|
||||
|
||||
function isNoiseElement(node: Node | null): boolean {
|
||||
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||
const el = node as Element;
|
||||
const tag = el.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 (el.hasAttribute("hidden")) return true;
|
||||
if ((el.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
|
||||
if (el.matches(".sr-only, [class*='sr-only']")) return true;
|
||||
if (el.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
|
||||
if (el.matches("[data-testid*='action-button']")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripNoise(root: Element): Element {
|
||||
const clone = root.cloneNode(true) as Element;
|
||||
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
|
||||
return clone;
|
||||
}
|
||||
|
||||
function candidateScore(node: Element) {
|
||||
const text = normalizeText((node as HTMLElement).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 as HTMLElement).innerText || "").length > 0);
|
||||
if (!candidates.length) return document.body;
|
||||
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function inlineText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeMarkdown(node.textContent || "");
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
if (isNoiseElement(node)) return "";
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "br") return "\n";
|
||||
if (tag === "img") {
|
||||
const img = el as HTMLImageElement;
|
||||
const src = absoluteUrl(img.getAttribute("src"), img.src);
|
||||
if (!src) return "";
|
||||
const alt = normalizeText(img.getAttribute("alt") || "");
|
||||
return alt ? `` : ``;
|
||||
}
|
||||
if (tag === "a") {
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
const href = absoluteUrl(el.getAttribute("href"), (el as HTMLAnchorElement).href);
|
||||
if (!href) return text;
|
||||
return `[${text || href}](${href})`;
|
||||
}
|
||||
if (tag === "code") {
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
|
||||
}
|
||||
if (tag === "strong" || tag === "b") {
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `**${text}**` : "";
|
||||
}
|
||||
if (tag === "em" || tag === "i") {
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `*${text}*` : "";
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (const child of el.childNodes) {
|
||||
const rendered = inlineText(child);
|
||||
if (!rendered) continue;
|
||||
chunks.push(rendered);
|
||||
if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has((child as Element).tagName.toLowerCase())) {
|
||||
chunks.push("\n");
|
||||
}
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function textBlock(node: Node): string {
|
||||
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
|
||||
}
|
||||
|
||||
function preserveNodeText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || "";
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "br") return "\n";
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const child of el.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: string): string {
|
||||
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: string[]): string[] {
|
||||
const converted: string[] = [];
|
||||
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: string): string {
|
||||
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: Element) {
|
||||
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: Element, depth = 0): string {
|
||||
const ordered = list.tagName.toLowerCase() === "ol";
|
||||
const items: string[] = [];
|
||||
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: string[] = [];
|
||||
const content: string[] = [];
|
||||
|
||||
for (const child of item.childNodes) {
|
||||
const childEl = child as Element;
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (childEl.tagName === "UL" || childEl.tagName === "OL")) {
|
||||
nested.push(listToMarkdown(childEl, 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: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeText(node.textContent || "");
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
if (isNoiseElement(node)) return "";
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "table") return tableToMarkdown(el);
|
||||
if (tag === "ul" || tag === "ol") return listToMarkdown(el);
|
||||
if (el.matches(".cm-editor[data-is-code-block-view='true']")) {
|
||||
const lines = Array.from(el.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(el));
|
||||
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
||||
}
|
||||
if (tag === "blockquote") {
|
||||
const content = collapseBlankLines(Array.from(el.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(el);
|
||||
return text ? `${"#".repeat(level)} ${text}` : "";
|
||||
}
|
||||
if (tag === "p" || tag === "figcaption") {
|
||||
return textBlock(el);
|
||||
}
|
||||
if (tag === "hr") {
|
||||
return "---";
|
||||
}
|
||||
if (tag === "img") {
|
||||
return inlineText(el);
|
||||
}
|
||||
|
||||
const childBlocks = Array.from(el.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);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
function repairFlattenedDiagram(text: string): string {
|
||||
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: string[]): string[] {
|
||||
const converted: string[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
export function normalizeCodeBlock(text: string): string {
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ContentArgs } from '../../types';
|
||||
import { pickMarkdownRoot } from './root';
|
||||
import { renderMarkdown } from './renderer';
|
||||
import { stripNoise } from './utils';
|
||||
|
||||
export function extractMarkdown({ selector }: ContentArgs) {
|
||||
const root = stripNoise(pickMarkdownRoot(selector));
|
||||
return renderMarkdown(root);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { normalizeCodeBlock } from './code';
|
||||
import {
|
||||
absoluteUrl,
|
||||
BLOCK_TAGS,
|
||||
collapseBlankLines,
|
||||
escapeMarkdown,
|
||||
escapeTableCell,
|
||||
isNoiseElement,
|
||||
normalizeInline,
|
||||
normalizeText,
|
||||
} from './utils';
|
||||
|
||||
function inlineText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeMarkdown(node.textContent || "");
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
if (isNoiseElement(node)) return "";
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "br") return "\n";
|
||||
if (tag === "img") {
|
||||
const img = el as HTMLImageElement;
|
||||
const src = absoluteUrl(img.getAttribute("src"), img.src);
|
||||
if (!src) return "";
|
||||
const alt = normalizeText(img.getAttribute("alt") || "");
|
||||
return alt ? `` : ``;
|
||||
}
|
||||
if (tag === "a") {
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
const href = absoluteUrl(el.getAttribute("href"), (el as HTMLAnchorElement).href);
|
||||
if (!href) return text;
|
||||
return `[${text || href}](${href})`;
|
||||
}
|
||||
if (tag === "code") {
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
|
||||
}
|
||||
if (tag === "strong" || tag === "b") {
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `**${text}**` : "";
|
||||
}
|
||||
if (tag === "em" || tag === "i") {
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `*${text}*` : "";
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (const child of el.childNodes) {
|
||||
const rendered = inlineText(child);
|
||||
if (!rendered) continue;
|
||||
chunks.push(rendered);
|
||||
if (child.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has((child as Element).tagName.toLowerCase())) {
|
||||
chunks.push("\n");
|
||||
}
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function textBlock(node: Node): string {
|
||||
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
|
||||
}
|
||||
|
||||
function preserveNodeText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || "";
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "br") return "\n";
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const child of el.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 tableToMarkdown(table: Element) {
|
||||
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: Element, depth = 0): string {
|
||||
const ordered = list.tagName.toLowerCase() === "ol";
|
||||
const items: string[] = [];
|
||||
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: string[] = [];
|
||||
const content: string[] = [];
|
||||
|
||||
for (const child of item.childNodes) {
|
||||
const childEl = child as Element;
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (childEl.tagName === "UL" || childEl.tagName === "OL")) {
|
||||
nested.push(listToMarkdown(childEl, 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: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeText(node.textContent || "");
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
if (isNoiseElement(node)) return "";
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "table") return tableToMarkdown(el);
|
||||
if (tag === "ul" || tag === "ol") return listToMarkdown(el);
|
||||
if (el.matches(".cm-editor[data-is-code-block-view='true']")) {
|
||||
const lines = Array.from(el.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(el));
|
||||
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
||||
}
|
||||
if (tag === "blockquote") {
|
||||
const content = collapseBlankLines(Array.from(el.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(el);
|
||||
return text ? `${"#".repeat(level)} ${text}` : "";
|
||||
}
|
||||
if (tag === "p" || tag === "figcaption") {
|
||||
return textBlock(el);
|
||||
}
|
||||
if (tag === "hr") {
|
||||
return "---";
|
||||
}
|
||||
if (tag === "img") {
|
||||
return inlineText(el);
|
||||
}
|
||||
|
||||
const childBlocks = Array.from(el.childNodes)
|
||||
.map(child => blockToMarkdown(child))
|
||||
.filter(Boolean);
|
||||
if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n"));
|
||||
|
||||
return textBlock(node);
|
||||
}
|
||||
|
||||
export function renderMarkdown(root: Element): string {
|
||||
return collapseBlankLines(blockToMarkdown(root));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { normalizeText } from './utils';
|
||||
|
||||
function candidateScore(node: Element) {
|
||||
const text = normalizeText((node as HTMLElement).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);
|
||||
}
|
||||
|
||||
export function pickMarkdownRoot(selector?: string) {
|
||||
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 as HTMLElement).innerText || "").length > 0);
|
||||
if (!candidates.length) return document.body;
|
||||
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
|
||||
return candidates[0];
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export const BLOCK_TAGS = 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"
|
||||
]);
|
||||
|
||||
export 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(", ");
|
||||
|
||||
export function normalizeText(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function normalizeInline(value: string) {
|
||||
return value
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n[ \t]+/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function collapseBlankLines(value: string) {
|
||||
return value
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function escapeMarkdown(text: string) {
|
||||
return text.replace(/([\\`[\]])/g, "\\$1");
|
||||
}
|
||||
|
||||
export function escapeTableCell(text: string) {
|
||||
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function absoluteUrl(attr: string | null | undefined, fallback?: string) {
|
||||
return attr || fallback || "";
|
||||
}
|
||||
|
||||
export function isNoiseElement(node: Node | null): boolean {
|
||||
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||
const el = node as Element;
|
||||
const tag = el.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 (el.hasAttribute("hidden")) return true;
|
||||
if ((el.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
|
||||
if (el.matches(".sr-only, [class*='sr-only']")) return true;
|
||||
if (el.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
|
||||
if (el.matches("[data-testid*='action-button']")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stripNoise(root: Element): Element {
|
||||
const clone = root.cloneNode(true) as Element;
|
||||
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
|
||||
return clone;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function tabInfo(t: Tab) {
|
||||
windowId: t.windowId,
|
||||
active: t.active,
|
||||
muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
|
||||
index: t.index,
|
||||
groupId: t.groupId >= 0 ? t.groupId : null,
|
||||
title: t.title,
|
||||
url: t.url || t.pendingUrl || "",
|
||||
|
||||
@@ -74,6 +74,18 @@ version-check:
|
||||
ext=$(grep -m1 '"version"' extension/manifest.json | cut -d'"' -f4); \
|
||||
if [ "$py" = "$ext" ]; then echo "ok: $py"; else echo "MISMATCH pyproject=$py manifest=$ext"; exit 1; fi
|
||||
|
||||
# Build into /tmp/dist-browser-cli and publish using credentials from .env
|
||||
publish:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set -a
|
||||
[ ! -f .env ] || source .env
|
||||
set +a
|
||||
rm -rf /tmp/dist-browser-cli
|
||||
mkdir -p /tmp/dist-browser-cli
|
||||
uv build --out-dir /tmp/dist-browser-cli
|
||||
uv publish /tmp/dist-browser-cli/*
|
||||
|
||||
# ── Demos ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Run the Python SDK demo
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension",
|
||||
"package:extension": "npm run build:extension && python scripts/package_extension.py",
|
||||
"package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore",
|
||||
"package:extension:webstore:verified": "scripts/package_verified_crx.sh",
|
||||
"package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "real-browser-cli"
|
||||
version = "0.15.2"
|
||||
version = "0.15.6"
|
||||
description = "Control your real running browser from the terminal or Python SDK"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/package_verified_crx.sh [--key FILE.gpg] [--browser COMMAND] [--out FILE.crx]
|
||||
|
||||
Builds the Chrome Web Store package and creates a CRX signed with the dedicated
|
||||
verified-upload RSA key. The RSA private key is expected to be GPG-encrypted.
|
||||
|
||||
Environment alternatives:
|
||||
VERIFIED_CRX_KEY_GPG Path to encrypted RSA private key
|
||||
CHROME_FOR_PACKING Browser command with --pack-extension support
|
||||
EOF
|
||||
}
|
||||
|
||||
key_gpg="${VERIFIED_CRX_KEY_GPG:-secrets/verified-crx/chrome-webstore-verified-crx-private-key.pem.gpg}"
|
||||
browser_cmd="${CHROME_FOR_PACKING:-}"
|
||||
out=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--key)
|
||||
key_gpg="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--browser)
|
||||
browser_cmd="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--out)
|
||||
out="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$key_gpg" ]]; then
|
||||
echo "Encrypted verified CRX key not found: $key_gpg" >&2
|
||||
echo "Create it with: scripts/setup_verified_crx_key.sh --recipient '<your GPG key>'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$browser_cmd" ]]; then
|
||||
for candidate in google-chrome chrome chromium chromium-browser brave-browser brave; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
browser_cmd="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "$browser_cmd" ]]; then
|
||||
echo "No Chromium-based browser with --pack-extension found. Pass --browser or set CHROME_FOR_PACKING." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="$(python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
print(json.loads(Path('extension/manifest.json').read_text())['version'])
|
||||
PY
|
||||
)"
|
||||
out="${out:-dist/browser-cli-extension-webstore-verified-v${version}.crx}"
|
||||
|
||||
npm run build:extension
|
||||
python scripts/package_extension.py --webstore --out "dist/browser-cli-extension-webstore-v${version}.zip" >/dev/null
|
||||
|
||||
staging="$PWD/dist/extension-package-webstore"
|
||||
if [[ ! -d "$staging" ]]; then
|
||||
echo "Missing webstore staging directory: $staging" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
private_key="$tmp_dir/verified-crx-private-key.pem"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
gpg --decrypt --output "$private_key" "$key_gpg"
|
||||
chmod 600 "$private_key"
|
||||
|
||||
rm -f "$staging.crx"
|
||||
"$browser_cmd" \
|
||||
--pack-extension="$staging" \
|
||||
--pack-extension-key="$private_key" \
|
||||
--no-message-box \
|
||||
--disable-gpu \
|
||||
--no-sandbox >/dev/null
|
||||
|
||||
mkdir -p "$(dirname "$out")"
|
||||
mv "$staging.crx" "$out"
|
||||
|
||||
echo "$out"
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/setup_verified_crx_key.sh [--recipient GPG_RECIPIENT] [--out-dir DIR]
|
||||
|
||||
Generates a dedicated RSA private key for Chrome Web Store verified CRX uploads,
|
||||
encrypts it to your GPG key, and writes the public key material for the Chrome
|
||||
Developer Dashboard.
|
||||
|
||||
Chrome Web Store verified uploads require an RSA CRX signing key. A GPG/OpenPGP
|
||||
key cannot be used directly for CRX signing, but it can protect the RSA private
|
||||
key at rest.
|
||||
EOF
|
||||
}
|
||||
|
||||
recipient=""
|
||||
out_dir="secrets/verified-crx"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--recipient)
|
||||
recipient="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--out-dir)
|
||||
out_dir="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$recipient" ]]; then
|
||||
recipient="$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1 == "uid" { print $10; exit }')"
|
||||
fi
|
||||
|
||||
if [[ -z "$recipient" ]]; then
|
||||
echo "No GPG recipient found. Pass --recipient '<key id or email>'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out_dir"
|
||||
chmod 700 "$out_dir"
|
||||
|
||||
private_key="$(mktemp)"
|
||||
public_pem="$out_dir/chrome-webstore-verified-crx-public-key.pem"
|
||||
public_der_b64="$out_dir/chrome-webstore-verified-crx-public-key.der.base64.txt"
|
||||
encrypted_private="$out_dir/chrome-webstore-verified-crx-private-key.pem.gpg"
|
||||
trap 'rm -f "$private_key"' EXIT
|
||||
|
||||
if [[ -e "$encrypted_private" ]]; then
|
||||
echo "Refusing to overwrite existing encrypted private key: $encrypted_private" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
openssl genrsa -out "$private_key" 2048 >/dev/null 2>&1
|
||||
chmod 600 "$private_key"
|
||||
openssl rsa -in "$private_key" -pubout -out "$public_pem" >/dev/null 2>&1
|
||||
openssl rsa -in "$private_key" -pubout -outform DER 2>/dev/null | base64 -w0 > "$public_der_b64"
|
||||
printf '\n' >> "$public_der_b64"
|
||||
|
||||
gpg --encrypt --recipient "$recipient" --output "$encrypted_private" "$private_key"
|
||||
chmod 600 "$encrypted_private"
|
||||
|
||||
cat <<EOF
|
||||
Created verified CRX upload key material:
|
||||
encrypted private key: $encrypted_private
|
||||
public key PEM: $public_pem
|
||||
public key DER/base64: $public_der_b64
|
||||
|
||||
Use the public key in the Chrome Developer Dashboard -> Package -> Verified uploads.
|
||||
Keep the encrypted private key. Do not commit or upload the decrypted PEM.
|
||||
EOF
|
||||
+90
-8
@@ -18,6 +18,7 @@ TAB_DATA = {
|
||||
"id": 10,
|
||||
"windowId": 1,
|
||||
"active": True,
|
||||
"index": 3,
|
||||
"title": "Example",
|
||||
"url": "https://example.com",
|
||||
"groupId": None,
|
||||
@@ -73,6 +74,15 @@ class TestBrowserCLIInit:
|
||||
assert b.remote == "browser-host.example:443"
|
||||
assert b.key == "agent"
|
||||
|
||||
def test_tab_factory_preserves_index(self):
|
||||
tab = BrowserCLI().tab_from(TAB_DATA)
|
||||
assert tab.index == 3
|
||||
|
||||
def test_tab_factory_defaults_missing_index_to_zero(self):
|
||||
data = {key: value for key, value in TAB_DATA.items() if key != "index"}
|
||||
tab = BrowserCLI().tab_from(data)
|
||||
assert tab.index == 0
|
||||
|
||||
def test_namespaces_present_and_bound(self):
|
||||
b = BrowserCLI()
|
||||
for name in ("nav", "tabs", "groups", "windows", "dom", "extract",
|
||||
@@ -354,6 +364,27 @@ class TestTabs:
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_close_by_ids_chunks_large_batches(self, b, mock_send):
|
||||
mock_send.side_effect = [{"closed": 50}, {"closed": 50}, {"closed": 20}]
|
||||
assert b.tabs.close(tab_ids=range(120), gentle_mode="normal") == 120
|
||||
assert mock_send.call_args_list == [
|
||||
call(
|
||||
"tabs.close",
|
||||
{"tabId": None, "tabIds": list(range(0, 50)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
|
||||
profile=None, remote=None, key=None,
|
||||
),
|
||||
call(
|
||||
"tabs.close",
|
||||
{"tabId": None, "tabIds": list(range(50, 100)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
|
||||
profile=None, remote=None, key=None,
|
||||
),
|
||||
call(
|
||||
"tabs.close",
|
||||
{"tabId": None, "tabIds": list(range(100, 120)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
|
||||
profile=None, remote=None, key=None,
|
||||
),
|
||||
]
|
||||
|
||||
def test_tabs_move(self, b, mock_send):
|
||||
b.tabs.move(10, forward=True)
|
||||
mock_send.assert_called_once_with(
|
||||
@@ -439,7 +470,7 @@ class TestTabs:
|
||||
tabs = b.tabs.list()
|
||||
tabs[0].close()
|
||||
|
||||
assert [tab.browser for tab in tabs] == ["host:work"]
|
||||
assert [tab.browser for tab in tabs] == ["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),
|
||||
@@ -460,6 +491,28 @@ class TestTabs:
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_list_browser_host_alias_fans_out_to_remote_targets(self, mock_send):
|
||||
b = BrowserCLI(browser="browser-host.example", key="agent")
|
||||
with patch(
|
||||
"browser_cli.remote_targets_for_alias",
|
||||
return_value=[
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"),
|
||||
],
|
||||
):
|
||||
mock_send.side_effect = [[TAB_DATA], [{**TAB_DATA, "id": 11}], None]
|
||||
tabs = b.tabs.list()
|
||||
tabs[1].close()
|
||||
|
||||
assert [tab.browser for tab in tabs] == ["main", "work"]
|
||||
assert [tab.browser_name for tab in tabs] == ["Chrome", "Firefox"]
|
||||
assert [tab.browser_group for tab in tabs] == [None, None]
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_active_returns_active_tab(self, b, mock_send):
|
||||
mock_send.side_effect = [[TAB_DATA], TAB_DATA]
|
||||
|
||||
@@ -506,12 +559,37 @@ class TestTabs:
|
||||
mock_send.side_effect = [3, 4]
|
||||
result = b.tabs.count("github")
|
||||
|
||||
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 3, "work": 4})
|
||||
assert result == BrowserCounts(
|
||||
total=7,
|
||||
by_browser={"uuid-1": 3, "work": 4},
|
||||
browser_groups={"uuid-1": "local", "work": "local"},
|
||||
)
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.count", {"pattern": "github"}, profile="default"),
|
||||
call("tabs.count", {"pattern": "github"}, profile="work"),
|
||||
]
|
||||
|
||||
def test_tabs_count_multi_browser_keeps_remote_display_groups(self, b, mock_send):
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
],
|
||||
):
|
||||
mock_send.side_effect = [1, 2]
|
||||
result = b.tabs.count()
|
||||
|
||||
assert result == BrowserCounts(
|
||||
total=3,
|
||||
by_browser={"browser-host.example:main": 1, "browser-host.example:work": 2},
|
||||
browser_groups={"browser-host.example:main": "browser-host.example", "browser-host.example:work": "browser-host.example"},
|
||||
)
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.count", {"pattern": None}, profile="main", remote="browser-host.example:8765", key=None),
|
||||
call("tabs.count", {"pattern": None}, profile="work", remote="browser-host.example:8765", key=None),
|
||||
]
|
||||
|
||||
def test_tabs_query(self, b, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
result = b.tabs.query("example")
|
||||
@@ -628,7 +706,7 @@ class TestGroups:
|
||||
groups = b.groups.list()
|
||||
groups[0].close()
|
||||
|
||||
assert [group.browser for group in groups] == ["host:work"]
|
||||
assert [group.browser for group in groups] == ["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),
|
||||
@@ -660,7 +738,11 @@ class TestGroups:
|
||||
mock_send.side_effect = [2, 5]
|
||||
result = b.groups.count()
|
||||
|
||||
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 2, "work": 5})
|
||||
assert result == BrowserCounts(
|
||||
total=7,
|
||||
by_browser={"uuid-1": 2, "work": 5},
|
||||
browser_groups={"uuid-1": "local", "work": "local"},
|
||||
)
|
||||
|
||||
def test_group_query(self, b, mock_send):
|
||||
mock_send.return_value = [GROUP_DATA]
|
||||
@@ -719,8 +801,8 @@ class TestWindows:
|
||||
result = b.windows.list()
|
||||
|
||||
assert result == [
|
||||
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1"},
|
||||
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work"},
|
||||
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1", "browserGroup": "local"},
|
||||
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work", "browserGroup": "local"},
|
||||
]
|
||||
|
||||
def test_windows_open_without_url(self, b, mock_send):
|
||||
@@ -854,8 +936,8 @@ class TestSession:
|
||||
result = b.session.list()
|
||||
|
||||
assert result == [
|
||||
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1"},
|
||||
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work"},
|
||||
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1", "browserGroup": "local"},
|
||||
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work", "browserGroup": "local"},
|
||||
]
|
||||
assert mock_send.call_args_list == [
|
||||
call("session.list", {}, profile="default"),
|
||||
|
||||
+216
-6
@@ -4,7 +4,7 @@ import os
|
||||
import sys
|
||||
|
||||
from click.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
from browser_cli.cli import main, _project_version
|
||||
from browser_cli.client import BrowserTarget
|
||||
@@ -141,9 +141,9 @@ def test_install_writes_firefox_allowed_extensions(tmp_path):
|
||||
]
|
||||
assert "about:debugging#/runtime/this-firefox" in result.output
|
||||
assert "npm run package:extension:firefox" in result.output
|
||||
assert "dist/extension-package-firefo" in result.output
|
||||
assert "x/manifest.json" in result.output
|
||||
assert "Do not select extension/manifest.json" in result.output
|
||||
output_unwrapped = result.output.replace("\n", "")
|
||||
assert "dist/extension-package-firefox/manifest.json" in output_unwrapped
|
||||
assert "Do not select extension/manifest.json" in output_unwrapped
|
||||
assert "Firefox extension ID" in result.output
|
||||
|
||||
def test_install_windows_registers_native_host(tmp_path):
|
||||
@@ -217,7 +217,13 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
|
||||
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}', encoding="utf-8")
|
||||
remote_target = BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765")
|
||||
remote_target = BrowserTarget(
|
||||
"work",
|
||||
"browser-host.example:work",
|
||||
"",
|
||||
remote="browser-host.example:8765",
|
||||
display_group="browser-host.example",
|
||||
)
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None, suppress_pq_warning=False):
|
||||
assert command == "clients.list"
|
||||
@@ -239,7 +245,11 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
|
||||
|
||||
assert result.exit_code == 0
|
||||
active_targets.assert_called_once_with(suppress_pq_warning=True)
|
||||
assert "local" in result.output
|
||||
assert "Chrome" in result.output
|
||||
assert "browser-host.example" in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "work" in result.output
|
||||
assert "Remote Chrome" in result.output
|
||||
assert "post-quantum" not in result.output
|
||||
|
||||
@@ -362,9 +372,33 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Browser" in result.output
|
||||
assert "local" in result.output
|
||||
assert "550e8400-e29b-41d4-a716-446655440000" in result.output
|
||||
assert "work" in result.output
|
||||
|
||||
def test_tabs_list_unscoped_groups_remote_targets_by_host():
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "tabs.list"
|
||||
assert remote == "browser-host.example:8765"
|
||||
return [{"id": 1, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
|
||||
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||
"browser_cli.send_command", side_effect=fake_send_command
|
||||
):
|
||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "browser-host.example" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
|
||||
def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
@@ -379,7 +413,8 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "remote-host:work" in result.output
|
||||
assert "work" in result.output
|
||||
assert "remote-host:work" not in result.output
|
||||
assert "Remote" in result.output
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", key=None)
|
||||
|
||||
@@ -400,6 +435,81 @@ def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
||||
assert "Browser" not in result.output
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote=None, key=None)
|
||||
|
||||
def test_tabs_tree_with_browser_host_alias_fans_out_to_remote_targets():
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"),
|
||||
]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert remote == "browser-host.example:8765"
|
||||
if command == "tabs.list":
|
||||
return [{
|
||||
"id": 1 if profile == "main" else 2,
|
||||
"windowId": 1,
|
||||
"active": True,
|
||||
"index": 0,
|
||||
"title": f"{profile} tab",
|
||||
"url": "https://example.com",
|
||||
}]
|
||||
if command == "group.list":
|
||||
return []
|
||||
raise AssertionError(command)
|
||||
|
||||
with patch("browser_cli.remote_targets_for_alias", return_value=targets), patch(
|
||||
"browser_cli.send_command", side_effect=fake_send_command
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "browser-host.example", "tabs", "tree"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main tab" in result.output
|
||||
assert "work tab" in result.output
|
||||
assert send_command.call_args_list == [
|
||||
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key=None),
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key=None),
|
||||
call("group.list", {}, profile="main", remote="browser-host.example:8765", key=None),
|
||||
call("group.list", {}, profile="work", remote="browser-host.example:8765", key=None),
|
||||
]
|
||||
|
||||
def test_tabs_tree_unscoped_groups_remote_targets_by_host():
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert remote == "browser-host.example:8765"
|
||||
if command == "tabs.list":
|
||||
return [{
|
||||
"id": 1 if profile == "main" else 2,
|
||||
"windowId": 1,
|
||||
"active": True,
|
||||
"index": 0,
|
||||
"title": f"{profile} tab",
|
||||
"url": "https://example.com",
|
||||
}]
|
||||
if command == "group.list":
|
||||
return []
|
||||
raise AssertionError(command)
|
||||
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||
"browser_cli.send_command", side_effect=fake_send_command
|
||||
):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "browser-host.example" in result.output
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main tab" in result.output
|
||||
assert "work tab" in result.output
|
||||
|
||||
def test_tabs_count_multi_browser_shows_total():
|
||||
counts = {"default": 3, "work": 4}
|
||||
|
||||
@@ -419,9 +529,36 @@ def test_tabs_count_multi_browser_shows_total():
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Browser" in result.output
|
||||
assert "local" in result.output
|
||||
assert "Total" in result.output
|
||||
assert "7" in result.output
|
||||
|
||||
def test_tabs_count_unscoped_groups_remote_targets_by_host():
|
||||
counts = {"main": 1, "work": 2}
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "tabs.count"
|
||||
assert remote == "browser-host.example:8765"
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
],
|
||||
), patch("browser_cli.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["tabs", "count"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "browser-host.example" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
assert "Total" in result.output
|
||||
assert "3" in result.output
|
||||
|
||||
def test_group_count_multi_browser_shows_total():
|
||||
counts = {"default": 1, "work": 2}
|
||||
|
||||
@@ -443,6 +580,29 @@ def test_group_count_multi_browser_shows_total():
|
||||
assert "Total" in result.output
|
||||
assert "3" in result.output
|
||||
|
||||
def test_groups_list_unscoped_groups_remote_targets_by_host():
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "group.list"
|
||||
assert remote == "browser-host.example:8765"
|
||||
return [{"id": 1, "title": profile, "color": "blue", "collapsed": False, "tabCount": 2}]
|
||||
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||
"browser_cli.send_command", side_effect=fake_send_command
|
||||
):
|
||||
result = CliRunner().invoke(main, ["groups", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "browser-host.example" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
|
||||
def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
with patch(
|
||||
"browser_cli.send_command",
|
||||
@@ -491,10 +651,34 @@ def test_windows_list_multi_browser_shows_browser_column():
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Browser" in result.output
|
||||
assert "local" in result.output
|
||||
assert "Focused" not in result.output
|
||||
assert "uuid-1" in result.output
|
||||
assert "work" in result.output
|
||||
|
||||
def test_windows_list_unscoped_groups_remote_targets_by_host():
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "windows.list"
|
||||
assert remote == "browser-host.example:8765"
|
||||
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
|
||||
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||
"browser_cli.send_command", side_effect=fake_send_command
|
||||
):
|
||||
result = CliRunner().invoke(main, ["windows", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "browser-host.example" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
|
||||
def test_session_list_multi_browser_shows_browser_column():
|
||||
def fake_send_command(command, args=None, profile=None):
|
||||
assert command == "session.list"
|
||||
@@ -511,11 +695,37 @@ def test_session_list_multi_browser_shows_browser_column():
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Browser" in result.output
|
||||
assert "local" in result.output
|
||||
assert "uuid-1" in result.output
|
||||
assert "work" in result.output
|
||||
assert "default-session" in result.output
|
||||
assert "work-session" in result.output
|
||||
|
||||
def test_session_list_unscoped_groups_remote_targets_by_host():
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "session.list"
|
||||
assert remote == "browser-host.example:8765"
|
||||
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
|
||||
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||
"browser_cli.send_command", side_effect=fake_send_command
|
||||
):
|
||||
result = CliRunner().invoke(main, ["session", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "browser-host.example" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
assert "main-session" in result.output
|
||||
assert "work-session" in result.output
|
||||
|
||||
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
|
||||
@@ -342,7 +342,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_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"}]
|
||||
return [{"profile": "work", "displayName": "work", "browserName": "Firefox"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
||||
|
||||
@@ -352,6 +352,8 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
assert targets[0].profile == "work"
|
||||
assert targets[0].display_name == "browser-host.example:work"
|
||||
assert targets[0].remote == endpoint
|
||||
assert targets[0].browser_name == "Firefox"
|
||||
assert targets[0].display_group == "browser-host.example"
|
||||
|
||||
def test_looks_like_domain():
|
||||
assert _looks_like_domain("browsercli.yiprawr.dev") is True
|
||||
|
||||
@@ -7,6 +7,7 @@ from click.testing import CliRunner
|
||||
import pytest
|
||||
|
||||
from browser_cli import BrowserCLI
|
||||
from browser_cli.client import BrowserTarget
|
||||
from browser_cli.cli import main
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category
|
||||
|
||||
@@ -47,12 +48,94 @@ def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
|
||||
("navigate.to", {"tabId": 7, "url": "https://example.com"}),
|
||||
]
|
||||
|
||||
def _tree_sender(tabs, groups):
|
||||
def sender(command, args=None, **kwargs):
|
||||
if command == "tabs.list":
|
||||
return tabs
|
||||
if command == "group.list":
|
||||
return groups
|
||||
return []
|
||||
return sender
|
||||
|
||||
def test_tabs_tree_command_available():
|
||||
with patch("browser_cli.send_command", return_value=[]):
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender([], [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Tabs" in result.output
|
||||
|
||||
def test_tabs_tree_handles_tabs_without_index_from_older_extension():
|
||||
tabs = [{
|
||||
"id": 7,
|
||||
"windowId": 1,
|
||||
"active": True,
|
||||
"muted": False,
|
||||
"title": "Example",
|
||||
"url": "https://example.com",
|
||||
"groupId": None,
|
||||
}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Example" in result.output
|
||||
|
||||
def test_tabs_tree_preserves_window_tab_order_and_truncates_long_lines():
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "[Gold] Grouped", "url": "https://example.com/grouped", "groupId": 20},
|
||||
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/" + "x" * 200, "groupId": None},
|
||||
]
|
||||
groups = [{"id": 20, "title": "Group Name", "color": "blue", "collapsed": False, "tabCount": 1, "windowId": 1}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
output = result.output
|
||||
assert output.index("Before") < output.index("Group Name") < output.index("[Gold] Grouped") < output.index("After")
|
||||
assert "https://example.com/before" not in output
|
||||
assert "https://example.com/grouped" not in output
|
||||
assert "https://example.com/" + "x" * 200 not in output
|
||||
|
||||
def test_tabs_tree_adds_each_browser_node_only_once():
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "One", "url": "https://example.com/one", "groupId": None, "browser": "work"},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Two", "url": "https://example.com/two", "groupId": None, "browser": "work"},
|
||||
]
|
||||
targets = [
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
BrowserTarget("personal", "personal", "/tmp/personal.sock"),
|
||||
]
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), \
|
||||
patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.count("work") == 1
|
||||
assert result.output.count("personal") == 1
|
||||
assert "One" in result.output
|
||||
assert "Two" in result.output
|
||||
|
||||
def test_tabs_tree_shows_tabs_inside_collapsed_browser_groups():
|
||||
tabs = [
|
||||
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
|
||||
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Hidden", "url": "https://example.com/hidden", "groupId": 20},
|
||||
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/after", "groupId": None},
|
||||
]
|
||||
groups = [{"id": 20, "title": "Collapsed Group", "color": "orange", "collapsed": True, "tabCount": 1, "windowId": 1}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Collapsed Group" in result.output
|
||||
assert "1 tab" in result.output
|
||||
assert "collapsed" in result.output
|
||||
assert "Hidden" in result.output
|
||||
|
||||
def test_tabs_tree_can_show_shortened_urls_on_request():
|
||||
tabs = [{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Long URL", "url": "https://example.com/" + "x" * 200, "groupId": None}]
|
||||
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree", "--urls"])
|
||||
assert result.exit_code == 0
|
||||
assert "https://example.com/" in result.output
|
||||
assert "https://example.com/" + "x" * 200 not in result.output
|
||||
assert "…" in result.output
|
||||
|
||||
def test_doctor_command_reports_connection_failure_cleanly():
|
||||
with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \
|
||||
patch("browser_cli.send_command", side_effect=RuntimeError("no browser")):
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from os import terminal_size
|
||||
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from browser_cli.models import Tab
|
||||
from browser_cli.commands import rendering
|
||||
from browser_cli.commands.rendering import common
|
||||
|
||||
def test_shorten_uses_ellipsis():
|
||||
assert rendering.shorten("abcdef", 4) == "abc…"
|
||||
assert rendering.shorten("abc", 4) == "abc"
|
||||
|
||||
def test_terminal_width_prefers_shell_width_when_rich_is_redirected(monkeypatch):
|
||||
monkeypatch.setattr(common.shutil, "get_terminal_size", lambda fallback: terminal_size((140, 20)))
|
||||
assert rendering.terminal_width(Console(width=80)) == 140
|
||||
|
||||
def test_browser_label_style_distinguishes_browser_families():
|
||||
assert rendering.browser_label_style("Firefox") == "orange1"
|
||||
assert rendering.browser_label_style("Chrome") == "cyan"
|
||||
assert rendering.browser_label_style(None) == "bold cyan"
|
||||
|
||||
def test_scoped_browser_label_strips_repeated_remote_prefix():
|
||||
assert rendering.scoped_browser_label("browser-host.example:work", "browser-host.example", grouped=True) == "work"
|
||||
assert rendering.scoped_browser_label("work", "browser-host.example", grouped=True) == "work"
|
||||
assert rendering.scoped_browser_label("browser-host.example:work", "browser-host.example", grouped=False) == "browser-host.example:work"
|
||||
|
||||
def test_tab_tree_label_is_reusable_no_wrap_text():
|
||||
tab = type("Tab", (), {"id": 1, "title": "abcdef", "active": True, "url": "https://example.com"})()
|
||||
label = rendering.tab_tree_label(tab, title_limit=4, show_urls=True, url_limit=12)
|
||||
assert label.no_wrap is True
|
||||
assert label.overflow == "ellipsis"
|
||||
assert "abc…" in label.plain
|
||||
assert "https://exa…" in label.plain
|
||||
|
||||
def test_print_tree_uses_detected_width(monkeypatch):
|
||||
widths = []
|
||||
class CapturingConsole(Console):
|
||||
def __init__(self, *args, **kwargs):
|
||||
widths.append(kwargs.get("width"))
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(common, "Console", CapturingConsole)
|
||||
monkeypatch.setattr(common, "terminal_width", lambda console=None: 132)
|
||||
rendering.print_tree(Tree("Root"))
|
||||
assert widths == [132]
|
||||
|
||||
def test_build_tabs_tree_groups_by_browser_window_and_group():
|
||||
tabs = [
|
||||
Tab(id=1, window_id=5, active=False, muted=False, title="Before", url="https://example.com/before", group_id=None, index=0, browser="work"),
|
||||
Tab(id=2, window_id=5, active=False, muted=False, title="Inside", url="https://example.com/inside", group_id=9, index=1, browser="work"),
|
||||
]
|
||||
groups = [{"id": 9, "windowId": 5, "browser": "work", "title": "Research", "color": "blue", "tabCount": 1, "collapsed": True}]
|
||||
tree = rendering.build_tabs_tree(tabs, groups, console=Console(width=120), show_urls=True)
|
||||
text = "\n".join(str(line) for line in tree.__rich_console__(Console(width=120), Console(width=120).options))
|
||||
assert "work" in text
|
||||
assert "Window 5" in text
|
||||
assert "Research" in text
|
||||
assert "collapsed" in text
|
||||
assert "Inside" in text
|
||||
|
||||
def test_build_tabs_tree_groups_remote_browsers_by_scope():
|
||||
tabs = [
|
||||
Tab(id=1, window_id=5, active=False, muted=False, title="Remote A", url="https://example.com/a", index=0, browser="main", browser_group="browser-host.example"),
|
||||
Tab(id=2, window_id=6, active=False, muted=False, title="Remote B", url="https://example.com/b", index=0, browser="work", browser_group="browser-host.example"),
|
||||
Tab(id=3, window_id=7, active=False, muted=False, title="Local", url="https://example.com/local", index=0, browser="local"),
|
||||
]
|
||||
tree = rendering.build_tabs_tree(tabs, [], console=Console(width=120))
|
||||
text = "\n".join(str(line) for line in tree.__rich_console__(Console(width=120), Console(width=120).options))
|
||||
assert "browser-host.example" in text
|
||||
assert "main" in text
|
||||
assert "work" in text
|
||||
assert "browser-host.example:main" not in text
|
||||
assert "browser-host.example:work" not in text
|
||||
assert "Local" in text
|
||||
|
||||
def test_build_windows_tree_keeps_multi_browser_windows_separate():
|
||||
tabs = [
|
||||
Tab(id=1, window_id=5, active=False, muted=False, title="Work Tab", url="https://example.com/work", index=0, browser="work"),
|
||||
Tab(id=2, window_id=5, active=False, muted=False, title="Personal Tab", url="https://example.com/personal", index=0, browser="personal"),
|
||||
]
|
||||
windows = [
|
||||
{"id": 5, "alias": "main", "browser": "work", "tabCount": 1, "state": "normal"},
|
||||
{"id": 5, "alias": "main", "browser": "personal", "tabCount": 1, "state": "normal"},
|
||||
]
|
||||
tree = rendering.build_windows_tree(windows, tabs, console=Console(width=120))
|
||||
text = "\n".join(str(line) for line in tree.__rich_console__(Console(width=120), Console(width=120).options))
|
||||
assert "work: Window 5" in text
|
||||
assert "personal: Window 5" in text
|
||||
assert "Work Tab" in text
|
||||
assert "Personal Tab" in text
|
||||
Reference in New Issue
Block a user