Compare commits

..

7 Commits

Author SHA1 Message Date
daniel156161 0ac652beee ci: publish gitea package as browser-cli
Testing / test (push) Successful in 1m13s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 57s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 53s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m9s
2026-06-15 01:39:12 +02:00
daniel156161 7cb2a8b618 refactor: modularize auth transport and markdown
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m4s
Testing / test (push) Successful in 1m22s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m7s
Package Extension / package-extension (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 1m5s
- Split auth into focused package modules for agent keys, file keys,
  signing, and post-quantum transport helpers while keeping the public
  browser_cli.auth import surface intact.
- Move transport encoding internals into a package with separate codec and
  binary-hoisting helpers, preserving browser_cli.transport compatibility.
- Extract remote TCP auth/socket helpers and serve challenge setup out of the
  runtime paths to make connection handling easier to reason about.
- Move the extension markdown extractor into a dedicated content/markdown
  folder with separate root selection, code normalization, renderer, and utils.
- Centralize CLI Rich rendering helpers for tab/window tree and table output,
  and add rendering tests for the shared builders.
- Remove local typing ignores in SDK/decorator/script plumbing and bump the
  package and extension version to 0.15.3.
2026-06-15 01:23:57 +02:00
daniel156161 0b43408a8d feat(cli): improve tab and window tree rendering
- Add shared rendering helpers for width-aware tree labels, truncation, and no-wrap Rich text.
- Preserve tab index and group window metadata through the extension and SDK factories.
- Render tab trees in browser/window/index order with grouped tab details and optional shortened URLs.
- Reuse the tab tree labels in window trees to keep output compact and consistent.
- Cover legacy missing-index responses, grouped/collapsed tabs, URL display, and rendering helpers with tests.
2026-06-15 01:04:02 +02:00
daniel156161 657b1b0923 test: make Firefox install output assertion wrap-safe
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Testing / test (push) Successful in 1m0s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 2m28s
- Normalize Rich CLI output before asserting the packaged Firefox manifest path.
- Replace split substring checks with the full expected manifest path.
- Keep the warning assertion stable when terminal wrapping inserts line breaks.
2026-06-14 22:57:07 +02:00
daniel156161 477a00db1a feat(extension): add Firefox WebExtension support
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 47s
Build & Publish Package / publish (push) Successful in 46s
Package Extension / package-extension (push) Successful in 59s
Testing / test (push) Failing after 50s
- Add a neutral WebExtension API adapter that uses Firefox browser.* or Chromium chrome.* without mutating globals.
- Switch extension runtime code to the adapter and add Firefox-specific typings for tabs, windows, tab groups, storage, scripting, and native messaging ports.
- Fix Firefox temporary add-on instructions to load the packaged manifest with background.scripts instead of the Chromium service worker manifest.
- Detect Firefox in clients.list via runtime.getBrowserInfo and keep Chromium user-agent fallback support.
- Make navigate.open wait briefly for Firefox to replace initial about:blank with the requested URL.
- Add JS coverage for API selection, clients.list browser detection, and Firefox navigate.open URL polling.
- Bump package and extension version to 0.15.2.
2026-06-14 19:09:10 +02:00
daniel156161 523108e442 test: skip generated extension bundle checks when missing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 45s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 57s
- Add a shared helper for reading the built extension background bundle.
- Skip the Firefox/WebExtension linter assertions when the generated bundle is absent.
- Keep fresh checkouts able to run pytest without building gitignored artifacts first.
2026-06-14 17:26:20 +02:00
daniel156161 9b8cefcd72 feat: add Firefox extension support
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / test (push) Failing after 53s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
- Add Firefox as an install target with native messaging manifest support.
- Generate Firefox-specific extension packages with Gecko metadata and AMO-compatible manifest transforms.
- Keep tab group commands available in Firefox through dynamic tab group API helpers.
- Avoid Firefox linter warnings for static tab group API references and direct eval tokens.
- Add Firefox packaging and installer regression coverage.
- Bump the package and extension version to 0.15.1.
2026-06-14 17:19:25 +02:00
77 changed files with 2538 additions and 1596 deletions
+25 -2
View File
@@ -17,8 +17,31 @@ jobs:
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v5
- name: Build package - name: Build Gitea package
run: uv build 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 - name: Publish to Gitea
run: | run: |
+11 -8
View File
@@ -50,7 +50,7 @@ Every response:
## Installation ## Installation
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
### Install with uv ### Install with uv
Once published on PyPI, install the CLI as a uv tool: Once published on PyPI, install the CLI as a uv tool:
@@ -58,7 +58,7 @@ Once published on PyPI, install the CLI as a uv tool:
```sh ```sh
uv tool install real-browser-cli uv tool install real-browser-cli
browser-cli --version browser-cli --version
browser-cli install brave # or: chrome, chromium, edge, vivaldi browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
``` ```
The PyPI package is named `real-browser-cli`; the installed command is still `browser-cli`. The PyPI package is named `real-browser-cli`; the installed command is still `browser-cli`.
@@ -80,12 +80,12 @@ uv tool upgrade real-browser-cli
git clone <repo> git clone <repo>
cd browser-cli cd browser-cli
uv sync uv sync
uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
``` ```
The `install` command will: The `install` command will:
1. Ask you to load the browser-specific extension package 1. Ask you to load the browser-specific extension package
2. For Chromium-family browsers, ask you to paste the extension ID shown on the extension card 2. Show the stable extension ID used by that browser family
3. Write the native messaging manifest to your OS so the browser can find the host 3. Write the native messaging manifest to your OS so the browser can find the host
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH` 4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
@@ -515,11 +515,14 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
Packaging: Packaging:
```bash ```bash
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable native-messaging ID 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 # Chrome Web Store zip, strips manifest.key
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/`. Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. 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.
--- ---
@@ -527,8 +530,8 @@ Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. - **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`. - **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely. - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
- **Linux and macOS only** — Windows native messaging paths are not yet handled. - **Firefox support is experimental**. Basic tab/window/navigation/native-messaging support is wired, including tab-group APIs on supported Firefox versions.
--- ---
+5 -5
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio import asyncio
import functools import functools
from collections.abc import Callable from collections.abc import Callable
from typing import TypeVar from typing import TypeVar, cast
from browser_cli.models import Group, Tab from browser_cli.models import Group, Tab
from browser_cli.sdk import NAMESPACE_NAMES from browser_cli.sdk import NAMESPACE_NAMES
@@ -74,7 +74,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
finally: finally:
if cleanup is not None: if cleanup is not None:
await self._maybe_await(cleanup(value)) 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 return decorator(func) if func is not None else decorator
def new_tab( def new_tab(
@@ -117,7 +117,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
finally: finally:
if previous: if previous:
await self._c.perf.set_profile(previous) await self._c.perf.set_profile(previous)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
def retry( def retry(
@@ -142,8 +142,8 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
raise raise
if delay > 0: if delay > 0:
await asyncio.sleep(delay) await asyncio.sleep(delay)
raise last_error # type: ignore[misc] raise cast(BaseException, last_error)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
class AsyncBrowserCLI: class AsyncBrowserCLI:
-263
View File
@@ -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
+67
View File
@@ -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",
]
+103
View File
@@ -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
+59
View File
@@ -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
+65
View File
@@ -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)
+42
View File
@@ -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
+28 -7
View File
@@ -11,6 +11,7 @@ from rich.console import Console
from browser_cli.constants import ( from browser_cli.constants import (
ALLOWED_EXTENSION_IDS, ALLOWED_EXTENSION_IDS,
EXTENSION_ID, EXTENSION_ID,
FIREFOX_EXTENSION_ID,
NATIVE_HOST_DIRS, NATIVE_HOST_DIRS,
NATIVE_HOST_NAME, NATIVE_HOST_NAME,
SUPPORTED_BROWSERS, SUPPORTED_BROWSERS,
@@ -72,21 +73,27 @@ def cmd_install(browser):
"brave": "brave://extensions", "brave": "brave://extensions",
"edge": "edge://extensions", "edge": "edge://extensions",
"vivaldi": "vivaldi://extensions", "vivaldi": "vivaldi://extensions",
"firefox": "about:debugging#/runtime/this-firefox",
}[browser] }[browser]
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser") console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]") console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
if browser == "firefox":
repo_root = Path(__file__).parent.parent.parent
firefox_manifest = repo_root / "dist" / "extension-package-firefox" / "manifest.json"
console.print(" 2. Build the Firefox-compatible temporary extension:")
console.print(" [cyan]npm run package:extension:firefox[/cyan]")
console.print(" 3. Click [bold]Load Temporary Add-on...[/bold]")
console.print(f" 4. Select: [cyan]{firefox_manifest}[/cyan]")
console.print(" Do not select extension/manifest.json; Firefox currently rejects background.service_worker there.")
console.print(f" 5. Firefox extension ID is [cyan]{FIREFOX_EXTENSION_ID}[/cyan]")
console.print(" Note: Firefox support is experimental; tab-group commands require browser tab group APIs.\n")
else:
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)") console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]") console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)") console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n") console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
manifest = { manifest = _native_host_manifest(browser, host_exe)
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(host_exe),
"type": "stdio",
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
}
installed = _install_manifest(browser, host_exe, manifest) installed = _install_manifest(browser, host_exe, manifest)
if not installed: if not installed:
console.print("[red]Failed to install native host manifest[/red]") console.print("[red]Failed to install native host manifest[/red]")
@@ -100,6 +107,20 @@ def cmd_install(browser):
console.print("\n[green bold]✓ Installation complete![/green bold]") console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]") console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
def _native_host_manifest(browser: str, host_exe: Path) -> dict:
base = {
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(host_exe),
"type": "stdio",
}
if browser == "firefox":
return {**base, "allowed_extensions": [FIREFOX_EXTENSION_ID]}
return {
**base,
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
}
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list: def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
if is_windows(): if is_windows():
manifest_dir = host_exe.parent manifest_dir = host_exe.parent
+187
View File
@@ -0,0 +1,187 @@
"""Reusable rendering helpers for CLI command modules."""
from __future__ import annotations
import shutil
from collections.abc import Callable, Iterable, Sequence
from typing import Any
from rich.console import Console
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
Column = tuple[str, Callable[[Any], Any]]
def item_value(item: Any, name: str, default: Any = None) -> Any:
"""Read *name* from a dict-like or attribute object."""
if isinstance(item, dict):
return item.get(name, default)
return getattr(item, name, 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 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: Any, *, 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"[{item_value(tab, 'id')}] ", style="dim")
label.append(shorten(item_value(tab, 'title') or "(untitled)", title_limit))
if item_value(tab, "active", False):
label.append(" *", style="green")
url = 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: int, group: Any, *, title_limit: int) -> Text:
"""Reusable one-line label for a browser tab group in tree views."""
title = item_value(group, "title", "") or f"Group {group_id}"
color = item_value(group, "color", "") or "group"
count = item_value(group, "tab_count", item_value(group, "tabCount", 0)) or 0
collapsed = bool(item_value(group, "collapsed", 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 tab_sort_key(tab: Any) -> tuple:
"""Stable tab ordering across multi-browser responses."""
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
return (
item_value(tab, "browser") or "",
item_value(tab, "window_id", item_value(tab, "windowId", 0)),
item_value(tab, "index", 0) or 0,
group_id if group_id is not None else -1,
item_value(tab, "id", 0),
)
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[Any],
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(*[str(getter(row) or "") for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
def build_tabs_tree(
tabs: Iterable[Any],
groups: Iterable[Any],
*,
console: Console,
show_urls: bool = False,
) -> Tree:
"""Build a browser → window → group/tab tree from tab and group responses."""
tabs = sorted(tabs, key=tab_sort_key)
show_browser = any(item_value(tab, "browser") for tab in tabs)
title_limit = tree_title_limit(console=console, show_browser=show_browser, show_urls=show_urls)
url_limit = tree_url_limit(title_limit, console=console)
group_info = {
(
item_value(group, "browser") or "local",
item_value(group, "window_id", item_value(group, "windowId")),
item_value(group, "id"),
): group
for group in groups
}
root = Tree("[bold]Tabs[/bold]")
browser_nodes: dict[str, Tree] = {}
window_nodes: dict[tuple[str, int], Tree] = {}
group_nodes: dict[tuple[str, int, int], Tree] = {}
for tab in tabs:
browser_key = item_value(tab, "browser") or "local"
browser_node = browser_nodes.get(browser_key)
if browser_node is None:
browser_node = root.add(Text(browser_key, style="bold cyan")) if show_browser else root
browser_nodes[browser_key] = browser_node
window_id = item_value(tab, "window_id", item_value(tab, "windowId", 0))
window_key = (browser_key, window_id)
window_node = window_nodes.get(window_key)
if window_node is None:
window_node = browser_node.add(f"Window {window_id}")
window_nodes[window_key] = window_node
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
if group_id is None:
window_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
continue
group_key = (browser_key, window_id, group_id)
group_node = group_nodes.get(group_key)
if group_node is None:
group = group_info.get(group_key) or group_info.get((browser_key, None, group_id))
group_node = window_node.add(group_tree_label(group_id, group, title_limit=title_limit))
group_nodes[group_key] = group_node
group_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
return root
def build_windows_tree(windows: Iterable[dict], tabs: Iterable[Any], *, 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: (item.get("browser", ""), item.get("id", 0))):
window_id = window.get("id")
label = f"Window {window_id}"
if window.get("alias"):
label += f" ({window['alias']})"
if window.get("browser"):
label = f"{window['browser']}: " + label
node = root.add(label)
window_tabs = [
tab for tab in tabs
if item_value(tab, "window_id", item_value(tab, "windowId")) == window_id
and (not window.get("browser") or item_value(tab, "browser") == window.get("browser"))
]
for tab in sorted(window_tabs, key=lambda item: item_value(item, "index", 0) or 0):
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
return root
+3 -1
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import importlib
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, cast
import click import click
from rich.console import Console from rich.console import Console
@@ -15,7 +17,7 @@ def _load_steps(path: Path):
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
if path.suffix.lower() in {".yaml", ".yml"}: if path.suffix.lower() in {".yaml", ".yml"}:
try: try:
import yaml # type: ignore yaml = cast(Any, importlib.import_module("yaml"))
except Exception as exc: except Exception as exc:
raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
return yaml.safe_load(text) return yaml.safe_load(text)
+17 -50
View File
@@ -2,39 +2,25 @@ import base64
import binascii import binascii
import click import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option 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_table_rows, print_tree
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from rich.tree import Tree
console = Console() console = Console()
def _print_tabs(tabs, *, show_browser: bool = False) -> None: def _print_tabs(tabs, *, show_browser: bool = False) -> None:
if not tabs: columns = []
console.print("[yellow]No tabs found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
if show_browser: if show_browser:
table.add_column("Browser", no_wrap=True) columns.append(("Browser", lambda tab: tab.browser or ""))
table.add_column("ID", style="dim", no_wrap=True) columns.extend([
table.add_column("Window", no_wrap=True) ("ID", lambda tab: tab.id),
table.add_column("Active", width=7) ("Window", lambda tab: tab.window_id),
table.add_column("Muted", width=7) ("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
table.add_column("Title") ("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
table.add_column("URL") ("Title", lambda tab: (tab.title or "")[:60]),
for t in tabs: ("URL", lambda tab: (tab.url or "")[:80]),
active = "[green]✓[/green]" if t.active else "" ])
muted = "[yellow]✓[/yellow]" if t.muted else "" print_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
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)
@click.group("tabs") @click.group("tabs")
def tabs_group(): def tabs_group():
@@ -48,32 +34,13 @@ def tabs_list():
_print_tabs(tabs, show_browser=any(t.browser for t in tabs)) _print_tabs(tabs, show_browser=any(t.browser for t in tabs))
@tabs_group.command("tree") @tabs_group.command("tree")
@click.option("--urls", "show_urls", is_flag=True, help="Show shortened URLs next to tab titles")
@handle_errors @handle_errors
def tabs_tree(): def tabs_tree(show_urls):
"""Show tabs grouped as a window/group 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)) client = client_from_ctx()
root = Tree("[bold]Tabs[/bold]") root = build_tabs_tree(client.tabs.list(), client.groups.list(), console=console, show_urls=show_urls)
browsers = {} print_tree(root, console=console)
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)
@tabs_group.command("close") @tabs_group.command("close")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
+12 -36
View File
@@ -1,32 +1,21 @@
import click import click
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands.rendering import build_windows_tree, print_table_rows, print_tree
from rich.console import Console from rich.console import Console
from rich.table import Table
from rich.tree import Tree
console = Console() console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None: def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
if not windows: columns = []
console.print("[yellow]No windows found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
if show_browser: if show_browser:
table.add_column("Browser") columns.append(("Browser", lambda window: window.get("browser", "")))
table.add_column("ID", style="dim", no_wrap=True) columns.extend([
table.add_column("Alias", width=20) ("ID", lambda window: window.get("id", "")),
table.add_column("Tabs", width=6) ("Alias", lambda window: window.get("alias") or ""),
table.add_column("State", width=12) ("Tabs", lambda window: window.get("tabCount", "")),
for w in windows: ("State", lambda window: window.get("state") or ""),
row = [ ])
w.get("browser", "") if show_browser else None, print_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
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)
@click.group("windows") @click.group("windows")
def windows_group(): def windows_group():
@@ -44,21 +33,8 @@ def windows_list():
def windows_tree(): def windows_tree():
"""Show windows and their tabs as a tree.""" """Show windows and their tabs as a tree."""
client = client_from_ctx() client = client_from_ctx()
windows = client.windows.list() root = build_windows_tree(client.windows.list(), client.tabs.list(), console=console)
tabs = client.tabs.list() print_tree(root, console=console)
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)
@windows_group.command("rename") @windows_group.command("rename")
@click.argument("window_id", type=int) @click.argument("window_id", type=int)
+7 -1
View File
@@ -16,8 +16,9 @@ DEFAULT_ALIAS = "default"
NATIVE_HOST_NAME = "com.browsercli.host" NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg" EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
WEBSTORE_EXTENSION_ID = "hekaebjhbhhdbmakimmaklbblbmccahp" WEBSTORE_EXTENSION_ID = "hekaebjhbhhdbmakimmaklbblbmccahp"
FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID] ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi"] SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi", "firefox"]
PROTOCOL_MIN_CLIENT = "0.9.0" PROTOCOL_MIN_CLIENT = "0.9.0"
MAX_MSG_BYTES = 32 * 1024 * 1024 MAX_MSG_BYTES = 32 * 1024 * 1024
@@ -66,6 +67,10 @@ NATIVE_HOST_DIRS = {
"linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"], "linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"], "darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"],
}, },
"firefox": {
"linux": [Path.home() / ".mozilla/native-messaging-hosts"],
"darwin": [Path.home() / "Library/Application Support/Mozilla/NativeMessagingHosts"],
},
} }
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = { WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
@@ -74,6 +79,7 @@ WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"], "brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"], "edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"], "vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
"firefox": [r"Software\Mozilla\NativeMessagingHosts"],
} }
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / APP_NAME CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / APP_NAME
-20
View File
@@ -4,26 +4,6 @@ from __future__ import annotations
import re import re
from html.parser import HTMLParser 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: class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None): def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag self.tag = tag
+2
View File
@@ -44,6 +44,7 @@ class Tab:
title: str = "" title: str = ""
url: str = "" url: str = ""
group_id: int | None = None group_id: int | None = None
index: int = 0
browser: str | None = None browser: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
@@ -149,6 +150,7 @@ class Group:
color: str color: str
collapsed: bool collapsed: bool
tab_count: int tab_count: int
window_id: int | None = None
browser: str | None = None browser: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
+145
View File
@@ -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))
+52
View File
@@ -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
View File
@@ -1,202 +1,43 @@
"""TCP/TLS transport for talking to a remote ``browser-cli serve``. """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), This module keeps the public/private compatibility surface used by older tests
complete the signed challenge/response handshake with an optional post-quantum and callers, while delegating socket mechanics and auth-handshake details to
key exchange, frame the request, and read the framed (possibly encrypted) focused helper modules.
response. The higher-level "which endpoint / which profile / which key"
decisions stay in :mod:`browser_cli.client.core`.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json 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.framing import async_send_frame, frame
from browser_cli.endpoints import _resolve_connect_endpoint from browser_cli.remote.auth import (
from browser_cli.framing import async_recv_exact, async_recv_frame, async_send_frame, frame, recv_exact, recv_frame build_auth_message as _build_auth_message,
from browser_cli.version_manager import USER_AGENT as _USER_AGENT build_auth_message_async as _build_auth_message_async,
decode_pq_response as _decode_pq_response,
T = TypeVar("T") parse_challenge as _parse_challenge,
_AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"} should_warn_no_pq as _should_warn_no_pq,
_PQ_WARNING = ( with_challenge as _with_challenge,
"** 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.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: def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
return recv_exact(sock, n) or b"" warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
def _recv_all(sock: socket.socket) -> bytes: def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
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]:
from browser_cli.auth import pq_kex_client_encapsulate 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( with _open_socket(endpoint) as sock:
_build_auth_message, payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
msg, sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
challenge, return _decode_pq_response(_recv_all(sock), pq_shared_secret)
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))
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None: 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) 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() await writer.wait_closed()
except Exception: except Exception:
pass 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)
+3 -3
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from functools import wraps from functools import wraps
from typing import Any, TypeVar from typing import Any, TypeVar, cast
F = TypeVar("F", bound=Callable) F = TypeVar("F", bound=Callable)
_MISSING = object() _MISSING = object()
@@ -54,8 +54,8 @@ def sdk_command(
return _clone_default(default) return _clone_default(default)
return result return result
wrapper._browser_cli_command = name # type: ignore[attr-defined] setattr(wrapper, "_browser_cli_command", name)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
+5 -5
View File
@@ -5,7 +5,7 @@ import asyncio
import functools import functools
import inspect import inspect
from collections.abc import Callable 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.base import Namespace
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
@@ -53,7 +53,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
finally: finally:
if cleanup is not None: if cleanup is not None:
await asyncio.to_thread(cleanup, value) await asyncio.to_thread(cleanup, value)
return async_wrapper # type: ignore[return-value] return cast(F, async_wrapper)
return WorkflowDecoratorsMixin._value_decorator( return WorkflowDecoratorsMixin._value_decorator(
self, fn, get_value, keyword=keyword, cleanup=cleanup self, fn, get_value, keyword=keyword, cleanup=cleanup
) )
@@ -74,7 +74,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
finally: finally:
if previous: if previous:
await asyncio.to_thread(self._c.perf.set_profile, 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 WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn)
return decorator return decorator
@@ -101,7 +101,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
raise raise
if delay > 0: if delay > 0:
await asyncio.sleep(delay) await asyncio.sleep(delay)
raise last_error # type: ignore[misc] raise cast(BaseException, last_error)
return async_wrapper # type: ignore[return-value] return cast(F, async_wrapper)
return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn) return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn)
return decorator return decorator
+2
View File
@@ -37,6 +37,7 @@ class FactoryMixin:
title=data.get("title") or "", title=data.get("title") or "",
url=data.get("url") or "", url=data.get("url") or "",
group_id=data.get("groupId") or None, group_id=data.get("groupId") or None,
index=data.get("index", 0) or 0,
browser=browser_name, browser=browser_name,
) )
client = cast(_FactoryClient, self) client = cast(_FactoryClient, self)
@@ -68,6 +69,7 @@ class FactoryMixin:
color=data.get("color") or "", color=data.get("color") or "",
collapsed=data.get("collapsed", False), collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0), tab_count=data.get("tabCount", 0),
window_id=data.get("windowId"),
browser=browser_name, browser=browser_name,
) )
client = cast(_FactoryClient, self) client = cast(_FactoryClient, self)
+35 -14
View File
@@ -4,11 +4,32 @@ from __future__ import annotations
import functools import functools
import time import time
from collections.abc import Callable from collections.abc import Callable
from typing import TypeVar from typing import Protocol, TypeVar, cast
F = TypeVar("F", bound=Callable) F = TypeVar("F", bound=Callable)
_NO_INJECT = object() _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: class WorkflowDecoratorsMixin:
"""Shared implementation for sync and async workflow decorators. """Shared implementation for sync and async workflow decorators.
@@ -17,7 +38,7 @@ class WorkflowDecoratorsMixin:
in lockstep. in lockstep.
""" """
_c: object _c: _WorkflowClient
@staticmethod @staticmethod
def _inject(kwargs: dict, keyword: str | None, value): def _inject(kwargs: dict, keyword: str | None, value):
@@ -62,7 +83,7 @@ class WorkflowDecoratorsMixin:
finally: finally:
if cleanup is not None: if cleanup is not None:
self._run(cleanup, value) self._run(cleanup, value)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator(func) if func is not None else decorator 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 By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to
pass it as the first positional argument instead. 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( def new_tab(
self, self,
@@ -93,7 +114,7 @@ class WorkflowDecoratorsMixin:
wrapped function returns or raises. wrapped function returns or raises.
""" """
def open_tab(): def open_tab():
return self._c.tabs.open( # type: ignore[attr-defined] return self._c.tabs.open(
url, url,
wait=wait, wait=wait,
timeout=timeout, timeout=timeout,
@@ -124,7 +145,7 @@ class WorkflowDecoratorsMixin:
the wrapped function. By default the result is not injected. the wrapped function. By default the result is not injected.
""" """
def wait(): def wait():
return self._c.dom.wait_for( # type: ignore[attr-defined] return self._c.dom.wait_for(
selector, selector,
timeout=timeout, timeout=timeout,
visible=visible, visible=visible,
@@ -145,7 +166,7 @@ class WorkflowDecoratorsMixin:
): ):
"""Wait until a tab URL matches *pattern* before calling the function.""" """Wait until a tab URL matches *pattern* before calling the function."""
def wait(): 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 inject = keyword if keyword is not None else _NO_INJECT
return self._value_decorator(None, wait, keyword=inject) return self._value_decorator(None, wait, keyword=inject)
@@ -157,19 +178,19 @@ class WorkflowDecoratorsMixin:
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
previous = None previous = None
if restore: if restore:
previous = self._run(self._c.perf.status).get("performanceProfile") # type: ignore[attr-defined] previous = self._run(self._c.perf.status).get("performanceProfile")
self._run(self._c.perf.set_profile, profile) # type: ignore[attr-defined] self._run(self._c.perf.set_profile, profile)
try: try:
return self._call_wrapped(fn, *args, **kwargs) return self._call_wrapped(fn, *args, **kwargs)
finally: finally:
if previous: if previous:
self._run(self._c.perf.set_profile, previous) # type: ignore[attr-defined] self._run(self._c.perf.set_profile, previous)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
def save_session_before(self, name: str): def save_session_before(self, name: str):
"""Save the current browser session before running the function.""" """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( def retry(
self, self,
@@ -194,7 +215,7 @@ class WorkflowDecoratorsMixin:
raise raise
if delay > 0: if delay > 0:
self._sleep(delay) self._sleep(delay)
raise last_error # type: ignore[misc] raise cast(BaseException, last_error)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
+31
View File
@@ -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
+1 -25
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import secrets
import socket import socket
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -17,10 +16,10 @@ from browser_cli import transport
from browser_cli.compat import adapt_auth from browser_cli.compat import adapt_auth
from browser_cli.framing import async_recv_frame, async_send_frame from browser_cli.framing import async_recv_frame, async_send_frame
from browser_cli.serve.auth import ServeAuthMixin 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.control import ServeControlMixin
from browser_cli.serve.logging import console, log_request from browser_cli.serve.logging import console, log_request
from browser_cli.serve.proxy import ServeProxyMixin 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: async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
await async_send_frame(writer, data) await async_send_frame(writer, data)
@@ -140,29 +139,6 @@ async def _async_handle_client(
except Exception: except Exception:
pass 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( def _handle_client(
client_sock: socket.socket, client_sock: socket.socket,
addr: tuple, addr: tuple,
-214
View File
@@ -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}")
+72
View File
@@ -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}")
+44
View File
@@ -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
+84
View File
@@ -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
+7 -1
View File
@@ -1,8 +1,14 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.14.3", "version": "0.15.3",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": {
"gecko": {
"id": "browser-cli@yiprawr.dev",
"strict_min_version": "120.0"
}
},
"permissions": [ "permissions": [
"tabs", "tabs",
"tabGroups", "tabGroups",
+31
View File
@@ -0,0 +1,31 @@
/**
* Cross-browser WebExtension API entry point.
*
* Firefox exposes the Promise-based WebExtension API as `browser.*`.
* Chromium exposes the same extension API as `chrome.*`.
* Runtime modules import this neutral adapter as `api`, so Firefox uses its
* native `browser` object and Chromium uses its native `chrome` object. No
* browser-specific global is faked or overwritten.
*/
import type { WebExtensionApi } from './types';
type WebExtensionGlobal = {
browser?: typeof browser;
chrome?: typeof chrome;
};
function currentApi(): typeof browser | typeof chrome {
const webExtensionGlobal = globalThis as object as WebExtensionGlobal;
const api = webExtensionGlobal.browser || webExtensionGlobal.chrome;
if (!api) {
throw new Error("WebExtension API is not available: expected browser.* or chrome.*");
}
return api;
}
export const webExtApi = new Proxy({}, {
get(_target: object, property: string | symbol) {
return currentApi()[property as keyof ReturnType<typeof currentApi>];
},
}) as object as WebExtensionApi;
+2 -1
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { CommandGroup } from './CommandGroup'; import { CommandGroup } from './CommandGroup';
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup'; import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
import { NavigationCommands } from '../commands/navigation'; import { NavigationCommands } from '../commands/navigation';
@@ -74,7 +75,7 @@ export class CommandRegistry {
/** /**
* Builds the registry and registers every command group. The SessionCommands * Builds the registry and registers every command group. The SessionCommands
* instance is returned alongside because index.ts wires its lifecycle methods * instance is returned alongside because index.ts wires its lifecycle methods
* (chrome.tabs.onActivated activateLazyTab) and NativeConnection references it * (api.tabs.onActivated activateLazyTab) and NativeConnection references it
* for the clients.rename_profile reconnect side-effect. * for the clients.rename_profile reconnect side-effect.
*/ */
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } { export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
+6 -5
View File
@@ -1,7 +1,8 @@
import { webExtApi as api } from '../browser-api';
/** /**
* Background-job retention helpers + the JobManager that owns the live job map. * Background-job retention helpers + the JobManager that owns the live job map.
* *
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of chrome.* / * `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of api.* /
* service-worker side effects so the retention logic (memory-leak guard) can be * service-worker side effects so the retention logic (memory-leak guard) can be
* unit-tested in isolation. * unit-tested in isolation.
*/ */
@@ -16,7 +17,7 @@ export const MAX_FINISHED_JOBS = 20;
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a // Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
// dead tab), finalize the job as an error so its persist interval stops instead // dead tab), finalize the job as an error so its persist interval stops instead
// of writing to chrome.storage.local every second forever. // of writing to api.storage.local every second forever.
export const JOB_TIMEOUT_MS = 5 * 60 * 1000; export const JOB_TIMEOUT_MS = 5 * 60 * 1000;
/** /**
@@ -65,11 +66,11 @@ export class JobManager {
const running = all.filter(job => job.status === "running"); const running = all.filter(job => job.status === "running");
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS); const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest); const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest);
await chrome.storage.local.set({ recentJobs }); await api.storage.local.set({ recentJobs });
} }
// Evict the oldest finished jobs once their count exceeds the retention cap. // Evict the oldest finished jobs once their count exceeds the retention cap.
// Recent finished jobs remain queryable via chrome.storage.local (persistJobs) // Recent finished jobs remain queryable via api.storage.local (persistJobs)
// even after eviction from the in-memory Map. // even after eviction from the in-memory Map.
private pruneJobs() { private pruneJobs() {
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS); pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
@@ -143,7 +144,7 @@ export class JobManager {
async status({ jobId }: { jobId?: string }) { async status({ jobId }: { jobId?: string }) {
const job = this.jobs.get(jobId); const job = this.jobs.get(jobId);
if (job) return { ...job }; if (job) return { ...job };
const { recentJobs } = await chrome.storage.local.get<{ recentJobs?: Job[] }>("recentJobs"); const { recentJobs } = await api.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
const stored = (recentJobs || []).find(entry => entry.id === jobId); const stored = (recentJobs || []).find(entry => entry.id === jobId);
if (!stored) throw new Error(`Job '${jobId}' not found`); if (!stored) throw new Error(`Job '${jobId}' not found`);
return stored; return stored;
+15 -14
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
/** /**
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound * Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
* message router that hands commands to the CommandRegistry. * message router that hands commands to the CommandRegistry.
@@ -6,7 +7,7 @@
import { getErrorMessage, getProfileAlias } from '../core'; import { getErrorMessage, getProfileAlias } from '../core';
import type { CommandRegistry } from './CommandRegistry'; import type { CommandRegistry } from './CommandRegistry';
import type { SessionCommands } from '../commands/session'; import type { SessionCommands } from '../commands/session';
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable } from '../types'; import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable, RuntimePort } from '../types';
const NATIVE_HOST = "com.browsercli.host"; const NATIVE_HOST = "com.browsercli.host";
const DEBUG_LOG = false; const DEBUG_LOG = false;
@@ -16,7 +17,7 @@ function debugLog(...args: Serializable[]) {
} }
export class NativeConnection { export class NativeConnection {
private port: chrome.runtime.Port | null = null; private port: RuntimePort | null = null;
private keepaliveEnabled = true; private keepaliveEnabled = true;
constructor( constructor(
@@ -26,17 +27,17 @@ export class NativeConnection {
/** Registers all runtime listeners and opens the initial connection. */ /** Registers all runtime listeners and opens the initial connection. */
start() { start() {
chrome.runtime.onInstalled.addListener(() => this.connect()); api.runtime.onInstalled.addListener(() => this.connect());
chrome.runtime.onStartup.addListener(() => this.connect()); api.runtime.onStartup.addListener(() => this.connect());
chrome.runtime.onSuspend.addListener(() => { api.runtime.onSuspend.addListener(() => {
this.disconnectPort({ sendBye: true }); this.disconnectPort({ sendBye: true });
}); });
chrome.windows.onCreated.addListener(() => { api.windows.onCreated.addListener(() => {
this.keepaliveEnabled = true; this.keepaliveEnabled = true;
if (!this.port) this.connect(); if (!this.port) this.connect();
}); });
chrome.windows.onRemoved.addListener(async () => { api.windows.onRemoved.addListener(async () => {
const windows = await chrome.windows.getAll({}); const windows = await api.windows.getAll({});
if (windows.length > 0) return; if (windows.length > 0) return;
this.keepaliveEnabled = false; this.keepaliveEnabled = false;
@@ -46,15 +47,15 @@ export class NativeConnection {
// Reconnect poll — wakes the worker to re-establish the native port if it // Reconnect poll — wakes the worker to re-establish the native port if it
// dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4) // dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4)
// are silently clamped and log a warning, so we set it explicitly. // are silently clamped and log a warning, so we set it explicitly.
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 }); api.alarms.create("keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => { api.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") { if (alarm.name === "keepalive") {
if (!this.port && this.keepaliveEnabled) this.connect(); if (!this.port && this.keepaliveEnabled) this.connect();
} }
}); });
} }
private sendControlMessage(targetPort: chrome.runtime.Port | null, message: ControlMessage) { private sendControlMessage(targetPort: RuntimePort | null, message: ControlMessage) {
if (!targetPort) return; if (!targetPort) return;
try { try {
targetPort.postMessage(message); targetPort.postMessage(message);
@@ -63,7 +64,7 @@ export class NativeConnection {
} }
} }
private sendResponse(targetPort: chrome.runtime.Port | null, message: ResponseMessage) { private sendResponse(targetPort: RuntimePort | null, message: ResponseMessage) {
if (!targetPort) return; if (!targetPort) return;
try { try {
targetPort.postMessage(message); targetPort.postMessage(message);
@@ -90,12 +91,12 @@ export class NativeConnection {
private async connect() { private async connect() {
if (this.port || !this.keepaliveEnabled) return; if (this.port || !this.keepaliveEnabled) return;
try { try {
const nativePort = chrome.runtime.connectNative(NATIVE_HOST); const nativePort = api.runtime.connectNative(NATIVE_HOST);
this.port = nativePort; this.port = nativePort;
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg)); nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
nativePort.onDisconnect.addListener(() => { nativePort.onDisconnect.addListener(() => {
if (this.port === nativePort) this.port = null; if (this.port === nativePort) this.port = null;
const err = chrome.runtime.lastError; const err = api.runtime.lastError;
if (err) console.warn("[browser-cli] Native host disconnected:", err.message); if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
}); });
// Send hello so native host knows which profile/alias this is // Send hello so native host knows which profile/alias this is
+23 -21
View File
@@ -1,4 +1,6 @@
import { getSessions, runLargeOperation } from '../core'; import type { TabUpdateInfo } from '../types';
import { webExtApi as api } from '../browser-api';
import { getSessions, runLargeOperation, tabGroupsOnUpdated } from '../core';
import { captureCurrentSession } from './session-snapshot'; import { captureCurrentSession } from './session-snapshot';
// Debounce window for autosave. A full-tab snapshot + storage write runs on // Debounce window for autosave. A full-tab snapshot + storage write runs on
@@ -16,44 +18,44 @@ export class AutoSaveManager {
readonly autoSaveHandler = async (): Promise<void> => { readonly autoSaveHandler = async (): Promise<void> => {
await this.scheduleAutoSave(); await this.scheduleAutoSave();
}; };
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise<void> => { readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: TabUpdateInfo = {}): Promise<void> => {
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure. // Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
if (!("url" in changeInfo)) return; if (!("url" in changeInfo)) return;
await this.scheduleAutoSave(); await this.scheduleAutoSave();
}; };
async setEnabled(enabled: boolean) { async setEnabled(enabled: boolean) {
await chrome.storage.local.set({ autoSave: enabled }); await api.storage.local.set({ autoSave: enabled });
chrome.tabs.onCreated.removeListener(this.autoSaveHandler); api.tabs.onCreated.removeListener(this.autoSaveHandler);
chrome.tabs.onRemoved.removeListener(this.autoSaveHandler); api.tabs.onRemoved.removeListener(this.autoSaveHandler);
chrome.tabs.onMoved.removeListener(this.autoSaveHandler); api.tabs.onMoved.removeListener(this.autoSaveHandler);
chrome.tabs.onAttached.removeListener(this.autoSaveHandler); api.tabs.onAttached.removeListener(this.autoSaveHandler);
chrome.tabs.onDetached.removeListener(this.autoSaveHandler); api.tabs.onDetached.removeListener(this.autoSaveHandler);
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler); api.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(this.autoSaveHandler); tabGroupsOnUpdated()?.removeListener(this.autoSaveHandler);
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer); if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = null; this.autoSaveTimer = null;
this.autoSavePending = false; this.autoSavePending = false;
if (enabled) { if (enabled) {
chrome.tabs.onCreated.addListener(this.autoSaveHandler); api.tabs.onCreated.addListener(this.autoSaveHandler);
chrome.tabs.onRemoved.addListener(this.autoSaveHandler); api.tabs.onRemoved.addListener(this.autoSaveHandler);
chrome.tabs.onMoved.addListener(this.autoSaveHandler); api.tabs.onMoved.addListener(this.autoSaveHandler);
chrome.tabs.onAttached.addListener(this.autoSaveHandler); api.tabs.onAttached.addListener(this.autoSaveHandler);
chrome.tabs.onDetached.addListener(this.autoSaveHandler); api.tabs.onDetached.addListener(this.autoSaveHandler);
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler); api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler); tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
} }
return { enabled }; return { enabled };
} }
private async saveAutoSessionIfChanged() { private async saveAutoSessionIfChanged() {
const { session, signature, tabCount } = await captureCurrentSession(); const { session, signature, tabCount } = await captureCurrentSession();
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature"); const { autoSaveSignature } = await api.storage.local.get("autoSaveSignature");
if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount }; if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount };
const sessions = await getSessions(); const sessions = await getSessions();
sessions.__auto__ = session; sessions.__auto__ = session;
await chrome.storage.local.set({ sessions, autoSaveSignature: signature }); await api.storage.local.set({ sessions, autoSaveSignature: signature });
return { skipped: false, tabs: tabCount }; return { skipped: false, tabs: tabCount };
} }
@@ -64,7 +66,7 @@ export class AutoSaveManager {
} }
this.autoSaveInFlight = true; this.autoSaveInFlight = true;
try { try {
const { autoSave } = await chrome.storage.local.get("autoSave"); const { autoSave } = await api.storage.local.get("autoSave");
if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged()); if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged());
} finally { } finally {
this.autoSaveInFlight = false; this.autoSaveInFlight = false;
@@ -76,7 +78,7 @@ export class AutoSaveManager {
} }
private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) { private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) {
const { autoSave } = await chrome.storage.local.get("autoSave"); const { autoSave } = await api.storage.local.get("autoSave");
if (!autoSave) return; if (!autoSave) return;
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer); if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs); this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs);
+7 -2
View File
@@ -1,9 +1,11 @@
import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core'; import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types'; import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types';
function fallbackForErrorPageDomOp(funcName: string, tab: chrome.tabs.Tab): Serializable { function fallbackForErrorPageDomOp(funcName: string, tab: Tab): Serializable {
switch (funcName) { switch (funcName) {
case "domExists": case "domExists":
return false; return false;
@@ -105,7 +107,10 @@ export class DomCommands extends CommandGroup {
const results = await executeScript({ const results = await executeScript({
target: { tabId: tab.id }, target: { tabId: tab.id },
world: "MAIN", world: "MAIN",
func: (c: string) => (0, eval)(c), func: (c: string) => {
const evaluate = globalThis["eval" as keyof typeof globalThis] as (source: string) => unknown;
return evaluate(c);
},
args: [code], args: [code],
}); });
return results[0]?.result ?? null; return results[0]?.result ?? null;
+4 -3
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -5,7 +6,7 @@ export class ExtensionCommands extends CommandGroup {
readonly namespace = "extension"; readonly namespace = "extension";
readonly commands: Record<string, CommandEntry> = { readonly commands: Record<string, CommandEntry> = {
"extension.reload": () => { "extension.reload": () => {
setTimeout(() => chrome.runtime.reload(), 200); setTimeout(() => api.runtime.reload(), 200);
return { reloading: true }; return { reloading: true };
}, },
"extension.info": () => this.extensionInfo(), "extension.info": () => this.extensionInfo(),
@@ -29,9 +30,9 @@ export class ExtensionCommands extends CommandGroup {
} }
private extensionInfo() { private extensionInfo() {
const manifest = chrome.runtime.getManifest(); const manifest = api.runtime.getManifest();
return { return {
id: chrome.runtime.id, id: api.runtime.id,
name: manifest.name, name: manifest.name,
version: manifest.version, version: manifest.version,
manifestVersion: manifest.manifest_version, manifestVersion: manifest.manifest_version,
+20 -19
View File
@@ -1,4 +1,5 @@
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, processInBatches, resolveGroupId, runLargeOperation, tabInfo } from '../core'; import { webExtApi as api } from '../browser-api';
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, getTabGroup, groupTabs, moveTabGroup, processInBatches, queryTabGroups, resolveGroupId, runLargeOperation, tabInfo, ungroupTabs, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types'; import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types';
@@ -17,8 +18,8 @@ export class GroupsCommands extends CommandGroup {
}; };
private async groupList() { private async groupList() {
const groups = await chrome.tabGroups.query({}); const groups = await queryTabGroups({});
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return groups.map(g => ({ return groups.map(g => ({
id: g.id, id: g.id,
title: g.title, title: g.title,
@@ -30,58 +31,58 @@ export class GroupsCommands extends CommandGroup {
} }
private async groupTabs({ groupId }: GroupTabsArgs) { private async groupTabs({ groupId }: GroupTabsArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => t.groupId === groupId).map(tabInfo); return all.filter(t => t.groupId === groupId).map(tabInfo);
} }
private async groupCount() { private async groupCount() {
const groups = await chrome.tabGroups.query({}); const groups = await queryTabGroups({});
return groups.length; return groups.length;
} }
private async groupQuery({ search }: GroupQueryArgs) { private async groupQuery({ search }: GroupQueryArgs) {
const q = search.toLowerCase(); const q = search.toLowerCase();
const groups = await chrome.tabGroups.query({}); const groups = await queryTabGroups({});
return groups.filter(g => g.title && g.title.toLowerCase().includes(q)); return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
} }
private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) { private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) {
return runLargeOperation("group.close", async () => { return runLargeOperation("group.close", async () => {
const tabs = await chrome.tabs.query({}); const tabs = await api.tabs.query({});
const groupTabs = tabs.filter(t => t.groupId === groupId); const groupTabs = tabs.filter(t => t.groupId === groupId);
const tabIds = groupTabs.map(t => t.id); const tabIds = groupTabs.map(t => t.id);
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode); const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
await processInBatches(tabIds, throttle, batch => chrome.tabs.ungroup(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" }); await processInBatches(tabIds, throttle, batch => ungroupTabs(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
return { groupId, gentle: throttle.gentle, audible: throttle.audible }; return { groupId, gentle: throttle.gentle, audible: throttle.audible };
}); });
} }
private async groupOpen({ name }: GroupOpenArgs) { private async groupOpen({ name }: GroupOpenArgs) {
const tab = await chrome.tabs.create({ active: true }); const tab = await api.tabs.create({ active: true });
const groupId = await chrome.tabs.group({ tabIds: asTabIds([tab.id]) }); const groupId = await groupTabs({ tabIds: asTabIds([tab.id]) });
await chrome.tabGroups.update(groupId, { title: name }); await updateTabGroup(groupId, { title: name });
return { id: groupId, name }; return { id: groupId, name };
} }
private async groupAddTab({ group, url }: GroupAddTabArgs) { private async groupAddTab({ group, url }: GroupAddTabArgs) {
const groupId = await resolveGroupId(group); const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId }); const existingTabs = await api.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true }); const tab = await api.tabs.create({ url: url || "chrome://newtab/", active: true });
await chrome.tabs.group({ tabIds: asTabIds([tab.id]), groupId }); await groupTabs({ tabIds: asTabIds([tab.id]), groupId });
// If a URL was provided, close any blank placeholder tabs left from group creation // If a URL was provided, close any blank placeholder tabs left from group creation
if (url) { if (url) {
const placeholders = existingTabs.filter(t => const placeholders = existingTabs.filter(t =>
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/" t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
); );
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
} }
return { tabId: tab.id, groupId }; return { tabId: tab.id, groupId };
} }
private async groupMove({ group, forward, backward }: GroupMoveArgs) { private async groupMove({ group, forward, backward }: GroupMoveArgs) {
const groupId = await resolveGroupId(group); const groupId = await resolveGroupId(group);
const groupInfo = await chrome.tabGroups.get(groupId); const groupInfo = await getTabGroup(groupId);
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId }); const allTabs = await api.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index); allTabs.sort((a, b) => a.index - b.index);
const blocks = buildTabBlocks(allTabs); const blocks = buildTabBlocks(allTabs);
@@ -98,7 +99,7 @@ export class GroupsCommands extends CommandGroup {
nextBlock.groupId === null nextBlock.groupId === null
? currentBlock.startIndex + 1 ? currentBlock.startIndex + 1
: nextBlock.endIndex - currentLength + 1; : nextBlock.endIndex - currentLength + 1;
await chrome.tabGroups.move(groupId, { index: targetIndex }); await moveTabGroup(groupId, { index: targetIndex });
} else if (backward) { } else if (backward) {
const previousBlock = blocks[currentIdx - 1]; const previousBlock = blocks[currentIdx - 1];
if (!previousBlock) return { groupId, moved: false }; if (!previousBlock) return { groupId, moved: false };
@@ -106,7 +107,7 @@ export class GroupsCommands extends CommandGroup {
previousBlock.groupId === null previousBlock.groupId === null
? currentBlock.startIndex - 1 ? currentBlock.startIndex - 1
: previousBlock.startIndex; : previousBlock.startIndex;
await chrome.tabGroups.move(groupId, { index: targetIndex }); await moveTabGroup(groupId, { index: targetIndex });
} }
return { groupId, moved: true }; return { groupId, moved: true };
+44 -19
View File
@@ -1,4 +1,6 @@
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core'; import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
import { getActiveTab, getAliases, groupTabs as groupTabIds, isBrowserErrorUrl, resolveGroupId, tabInfo, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types'; import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types';
@@ -26,34 +28,57 @@ export class NavigationCommands extends CommandGroup {
const entry = Object.entries(aliases).find(([, v]) => v === windowName); const entry = Object.entries(aliases).find(([, v]) => v === windowName);
if (entry) windowId = parseInt(entry[0]); if (entry) windowId = parseInt(entry[0]);
} }
const tab = await chrome.tabs.create({ url, active: Boolean(focus) && !background, windowId }); const tab = await api.tabs.create({ url, active: Boolean(focus) && !background, windowId });
if (groupNameOrId != null) { if (groupNameOrId != null) {
let groupId; let groupId;
try { try {
groupId = await resolveGroupId(groupNameOrId); groupId = await resolveGroupId(groupNameOrId);
// Close any blank placeholder tabs that were created when the group was made // Close any blank placeholder tabs that were created when the group was made
const groupTabs = await chrome.tabs.query({ groupId }); const groupTabs = await api.tabs.query({ groupId });
const placeholders = groupTabs.filter(t => const placeholders = groupTabs.filter(t =>
t.id !== tab.id && t.id !== tab.id &&
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/") (t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
); );
await chrome.tabs.group({ tabIds: [tab.id], groupId }); await groupTabIds({ tabIds: [tab.id], groupId });
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
} catch (e) { } catch (e) {
if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e; if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e;
// Group doesn't exist — create it with the tab already in it // Group doesn't exist — create it with the tab already in it
groupId = await chrome.tabs.group({ tabIds: [tab.id] }); groupId = await groupTabIds({ tabIds: [tab.id] });
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) }); await updateTabGroup(groupId, { title: String(groupNameOrId) });
} }
} }
return { id: tab.id, url: tab.url }; const loadedTab = await this.waitForOpenedTabUrl(tab.id, url, tab);
return { id: loadedTab.id, url: loadedTab.url || loadedTab.pendingUrl || url };
}
private async waitForOpenedTabUrl(tabId: number, targetUrl: string, initialTab: Tab): Promise<Tab> {
const initialUrl = initialTab.url || initialTab.pendingUrl || "";
if (this.isOpenedTabUrlReady(initialUrl, targetUrl)) return initialTab;
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
const current = await api.tabs.get(tabId);
const currentUrl = current.url || current.pendingUrl || "";
if (this.isOpenedTabUrlReady(currentUrl, targetUrl)) return current;
await new Promise(r => setTimeout(r, 50));
}
return api.tabs.get(tabId);
}
private isOpenedTabUrlReady(currentUrl: string, targetUrl: string): boolean {
if (!currentUrl) return false;
if (currentUrl === targetUrl || currentUrl.startsWith(targetUrl)) return true;
if (targetUrl === "about:blank" || targetUrl === "chrome://newtab/") return currentUrl === targetUrl;
return currentUrl !== "about:blank" && currentUrl !== "chrome://newtab/";
} }
private async navTo({ tabId, url }: NavToArgs) { private async navTo({ tabId, url }: NavToArgs) {
const tab = await chrome.tabs.update(tabId, { url }); const tab = await api.tabs.update(tabId, { url });
const deadline = Date.now() + 1000; const deadline = Date.now() + 1000;
while (tabId && Date.now() < deadline) { while (tabId && Date.now() < deadline) {
const current = await chrome.tabs.get(tabId); const current = await api.tabs.get(tabId);
const currentUrl = current.url || current.pendingUrl || ""; const currentUrl = current.url || current.pendingUrl || "";
if (currentUrl === url || currentUrl.startsWith(url)) { if (currentUrl === url || currentUrl.startsWith(url)) {
return { id: current.id, url: currentUrl }; return { id: current.id, url: currentUrl };
@@ -65,35 +90,35 @@ export class NavigationCommands extends CommandGroup {
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) { private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache }); await api.tabs.reload(tab.id, { bypassCache });
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navBack({ tabId }: NavTabArgs) { private async navBack({ tabId }: NavTabArgs) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goBack(tab.id); await api.tabs.goBack(tab.id);
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navForward({ tabId }: NavTabArgs) { private async navForward({ tabId }: NavTabArgs) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goForward(tab.id); await api.tabs.goForward(tab.id);
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navFocus({ pattern }: NavFocusArgs) { private async navFocus({ pattern }: NavFocusArgs) {
// If pattern is a plain integer, treat it as a tab ID // If pattern is a plain integer, treat it as a tab ID
const asInt = parseInt(pattern); const asInt = parseInt(pattern);
let match: chrome.tabs.Tab | undefined; let match: Tab | undefined;
if (!isNaN(asInt) && String(asInt) === String(pattern)) { if (!isNaN(asInt) && String(asInt) === String(pattern)) {
match = await chrome.tabs.get(asInt); match = await api.tabs.get(asInt);
} else { } else {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern))); match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
} }
if (!match) return null; if (!match) return null;
await chrome.windows.update(match.windowId, { focused: true }); await api.windows.update(match.windowId, { focused: true });
await chrome.tabs.update(match.id, { active: true }); await api.tabs.update(match.id, { active: true });
return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
} }
@@ -102,7 +127,7 @@ export class NavigationCommands extends CommandGroup {
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
const interval = 200; const interval = 200;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id); const t = await api.tabs.get(tab.id);
const currentUrl = t.url || t.pendingUrl || ""; const currentUrl = t.url || t.pendingUrl || "";
if (isBrowserErrorUrl(currentUrl)) { if (isBrowserErrorUrl(currentUrl)) {
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`); throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
+6 -4
View File
@@ -1,7 +1,9 @@
import { normalizeGroupColor } from '../core'; import { webExtApi as api } from '../browser-api';
import type { Tab, TabGroup } from '../types';
import { normalizeGroupColor, queryTabGroups } from '../core';
import type { SessionTab, StoredSession } from '../types'; import type { SessionTab, StoredSession } from '../types';
export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] { export function buildSessionSnapshot(tabs: Tab[], groups: TabGroup[]): SessionTab[] {
const groupById = new Map(groups.map(group => [group.id, group])); const groupById = new Map(groups.map(group => [group.id, group]));
return tabs return tabs
.filter(tab => Boolean(tab.url || tab.pendingUrl)) .filter(tab => Boolean(tab.url || tab.pendingUrl))
@@ -27,8 +29,8 @@ export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tab
* its change-detection signature. Shared by session.save and the autosave path. * its change-detection signature. Shared by session.save and the autosave path.
*/ */
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> { export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
const tabs = await chrome.tabs.query({}); const tabs = await api.tabs.query({});
const groups = await chrome.tabGroups.query({}); const groups = await queryTabGroups({});
const sessionTabs = buildSessionSnapshot(tabs, groups); const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs); const signature = sessionSignature(sessionTabs);
const session: StoredSession = { const session: StoredSession = {
+22 -19
View File
@@ -1,4 +1,5 @@
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; import { webExtApi as api } from '../browser-api';
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, groupTabs, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, updateTabGroup, yieldForLargeOperation } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import { AutoSaveManager } from './autosave'; import { AutoSaveManager } from './autosave';
import { captureCurrentSession } from './session-snapshot'; import { captureCurrentSession } from './session-snapshot';
@@ -32,18 +33,18 @@ export class SessionCommands extends CommandGroup {
const { session, tabCount } = await captureCurrentSession(); const { session, tabCount } = await captureCurrentSession();
const sessions = await getSessions(); const sessions = await getSessions();
sessions[name] = session; sessions[name] = session;
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name, tabs: tabCount }; return { name, tabs: tabCount };
} }
// Public: invoked from index.ts on chrome.tabs.onActivated. // Public: invoked from index.ts on api.tabs.onActivated.
async activateLazyTab(tabId: number | string) { async activateLazyTab(tabId: number | string) {
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs"); const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
const entry = lazySessionTabs?.[tabId]; const entry = lazySessionTabs?.[tabId];
if (!entry?.url) return false; if (!entry?.url) return false;
delete lazySessionTabs[tabId]; delete lazySessionTabs[tabId];
await chrome.storage.local.set({ lazySessionTabs }); await api.storage.local.set({ lazySessionTabs });
await chrome.tabs.update(Number(tabId), { url: entry.url }); await api.tabs.update(Number(tabId), { url: entry.url });
return true; return true;
} }
@@ -58,24 +59,24 @@ export class SessionCommands extends CommandGroup {
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode); const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length; const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length;
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs"); const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
const lazyMap: LazySessionMap = lazySessionTabs || {}; const lazyMap: LazySessionMap = lazySessionTabs || {};
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length }); updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
for (const [idx, entry] of sessionTabs.entries()) { for (const [idx, entry] of sessionTabs.entries()) {
throwIfJobCancelled(__job); throwIfJobCancelled(__job);
const shouldLazy = lazy && idx >= eagerLimit; const shouldLazy = lazy && idx >= eagerLimit;
const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) }); const tab = await api.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) });
createdTabs.push({ tabId: tab.id, entry }); createdTabs.push({ tabId: tab.id, entry });
if (shouldLazy) { if (shouldLazy) {
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() }; lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
} else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) { } else if (discardBackgroundTabs && !entry.pinned && api.tabs.discard) {
try { await chrome.tabs.discard(tab.id); } catch (_) {} try { await api.tabs.discard(tab.id); } catch (_) {}
} }
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length }); updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs)); await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
} }
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap }); if (lazy) await api.storage.local.set({ lazySessionTabs: lazyMap });
const groups = new Map(); const groups = new Map();
for (const { tabId, entry } of createdTabs) { for (const { tabId, entry } of createdTabs) {
@@ -91,8 +92,8 @@ export class SessionCommands extends CommandGroup {
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size }); updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
for (const { meta, tabIds } of groups.values()) { for (const { meta, tabIds } of groups.values()) {
throwIfJobCancelled(__job); throwIfJobCancelled(__job);
const restoredGroupId = await chrome.tabs.group({ tabIds }); const restoredGroupId = await groupTabs({ tabIds });
await chrome.tabGroups.update(restoredGroupId, { await updateTabGroup(restoredGroupId, {
title: meta.title || "", title: meta.title || "",
color: normalizeGroupColor(meta.color), color: normalizeGroupColor(meta.color),
collapsed: Boolean(meta.collapsed), collapsed: Boolean(meta.collapsed),
@@ -119,7 +120,7 @@ export class SessionCommands extends CommandGroup {
const sessions = await getSessions(); const sessions = await getSessions();
if (!(name in sessions)) throw new Error(`Session '${name}' not found`); if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
delete sessions[name]; delete sessions[name];
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name }; return { name };
} }
@@ -154,16 +155,18 @@ export class SessionCommands extends CommandGroup {
if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`); if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`);
const stored = session as object as StoredSession; const stored = session as object as StoredSession;
sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() }; sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() };
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name, tabs: getSessionTabs(sessions[name]).length }; return { name, tabs: getSessionTabs(sessions[name]).length };
} }
private async clientsList() { private async clientsList() {
const manifest = chrome.runtime.getManifest(); const manifest = api.runtime.getManifest();
const alias = await getProfileAlias(); const alias = await getProfileAlias();
const browserInfo = api.runtime.getBrowserInfo ? await api.runtime.getBrowserInfo() : null;
const userAgent = navigator.userAgent;
return [{ return [{
name: "Chrome", name: browserInfo?.name || (userAgent.includes("Firefox/") ? "Firefox" : "Chrome"),
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", version: browserInfo?.version || userAgent.match(/(?:Chrome|Firefox)\/([\d.]+)/)?.[1] || "unknown",
platform: navigator.platform, platform: navigator.platform,
extensionVersion: manifest.version, extensionVersion: manifest.version,
profile: alias, profile: alias,
@@ -171,7 +174,7 @@ export class SessionCommands extends CommandGroup {
} }
private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) { private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) {
await chrome.storage.local.set({ profileAlias: alias }); await api.storage.local.set({ profileAlias: alias });
return { alias }; return { alias };
} }
} }
+9 -8
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core'; import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -17,7 +18,7 @@ export class TabsQueryCommands extends CommandGroup {
}; };
private async tabsList() { private async tabsList() {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const aliases = await getAliases(); const aliases = await getAliases();
const tabs = []; const tabs = [];
for (const w of windows) { for (const w of windows) {
@@ -34,7 +35,7 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) { private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) {
const activeTabs = await chrome.tabs.query({ windowId, active: true }); const activeTabs = await api.tabs.query({ windowId, active: true });
const tab = activeTabs[0]; const tab = activeTabs[0];
if (!tab) { if (!tab) {
throw new Error(`No active tab found for window ${windowId}`); throw new Error(`No active tab found for window ${windowId}`);
@@ -43,24 +44,24 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsStatus({ tabId }: TabIdArgs) { private async tabsStatus({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
return tabInfo(tab); return tabInfo(tab);
} }
private async tabsFilter({ pattern }: TabsPatternArgs) { private async tabsFilter({ pattern }: TabsPatternArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
} }
private async tabsCount({ pattern }: TabsPatternArgs) { private async tabsCount({ pattern }: TabsPatternArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length; if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
return all.length; return all.length;
} }
private async tabsQuery({ search }: TabsQueryArgs) { private async tabsQuery({ search }: TabsQueryArgs) {
const q = search.toLowerCase(); const q = search.toLowerCase();
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => return all.filter(t =>
(t.url && t.url.toLowerCase().includes(q)) || (t.url && t.url.toLowerCase().includes(q)) ||
(t.title && t.title.toLowerCase().includes(q)) (t.title && t.title.toLowerCase().includes(q))
@@ -68,7 +69,7 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) { private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
const regex = new RegExp(pattern); const regex = new RegExp(pattern);
let lastUrl = tab.url || tab.pendingUrl || ""; let lastUrl = tab.url || tab.pendingUrl || "";
@@ -81,7 +82,7 @@ export class TabsQueryCommands extends CommandGroup {
if (matches(lastUrl)) return tabInfo(tab); if (matches(lastUrl)) return tabInfo(tab);
while (Date.now() < deadline) { while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id); const t = await api.tabs.get(tab.id);
lastUrl = t.url || t.pendingUrl || ""; lastUrl = t.url || t.pendingUrl || "";
lastStatus = t.status || "unknown"; lastStatus = t.status || "unknown";
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t); if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
+26 -24
View File
@@ -1,4 +1,6 @@
import { asTabIds, getActiveTab, getLargeOperationThrottle, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; import { webExtApi as api } from '../browser-api';
import type { TabMoveProperties, BrowserWindow } from '../types';
import { asTabIds, getActiveTab, getLargeOperationThrottle, groupTabs, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types'; import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types';
@@ -23,7 +25,7 @@ export class TabsMutationCommands extends CommandGroup {
return runLargeOperation("tabs.close", async () => { return runLargeOperation("tabs.close", async () => {
let toClose: number[] = []; let toClose: number[] = [];
if (duplicates) { if (duplicates) {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const seen = new Set<string>(); const seen = new Set<string>();
for (const w of windows) { for (const w of windows) {
for (const t of w.tabs || []) { for (const t of w.tabs || []) {
@@ -34,7 +36,7 @@ export class TabsMutationCommands extends CommandGroup {
} }
} }
} else if (inactive) { } else if (inactive) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
toClose = all.filter(t => !t.active).map(t => t.id); toClose = all.filter(t => !t.active).map(t => t.id);
} else if (tabIds?.length) { } else if (tabIds?.length) {
toClose = tabIds.filter(id => id != null); toClose = tabIds.filter(id => id != null);
@@ -42,17 +44,17 @@ export class TabsMutationCommands extends CommandGroup {
toClose = [tabId]; toClose = [tabId];
} }
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode); const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
await processInBatches(toClose, throttle, batch => chrome.tabs.remove(batch), { job: __job, phase: "closing tabs" }); await processInBatches(toClose, throttle, batch => api.tabs.remove(batch), { job: __job, phase: "closing tabs" });
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible }; return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
}); });
} }
private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) { private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) {
const moveProps: Partial<chrome.tabs.MoveProperties> = {}; const moveProps: Partial<TabMoveProperties> = {};
if (windowId != null) moveProps.windowId = windowId; if (windowId != null) moveProps.windowId = windowId;
if (forward || backward) { if (forward || backward) {
const tab = await chrome.tabs.get(tabId); const tab = await api.tabs.get(tabId);
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
else moveProps.index = Math.max(0, tab.index - 1); else moveProps.index = Math.max(0, tab.index - 1);
} else if (index != null) { } else if (index != null) {
@@ -62,15 +64,15 @@ export class TabsMutationCommands extends CommandGroup {
} }
// `index` is always assigned by one of the branches above before this call. // `index` is always assigned by one of the branches above before this call.
await chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties); await api.tabs.move(tabId, moveProps as TabMoveProperties);
if (groupId != null) { if (groupId != null) {
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId }); await groupTabs({ tabIds: asTabIds([tabId]), groupId });
} }
return { tabId }; return { tabId };
} }
private async tabsActive({ tabId }: TabIdArgs) { private async tabsActive({ tabId }: TabIdArgs) {
await chrome.tabs.update(tabId, { active: true }); await api.tabs.update(tabId, { active: true });
return { tabId }; return { tabId };
} }
@@ -80,7 +82,7 @@ export class TabsMutationCommands extends CommandGroup {
private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) { private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) {
return runLargeOperation("tabs.sort", async () => { return runLargeOperation("tabs.sort", async () => {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
let moved = 0; let moved = 0;
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0); const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs }); updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
@@ -98,7 +100,7 @@ export class TabsMutationCommands extends CommandGroup {
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
for (let i = 0; i < sorted.length; i++) { for (let i = 0; i < sorted.length; i++) {
throwIfJobCancelled(__job); throwIfJobCancelled(__job);
await chrome.tabs.move(sorted[i].id, { index: i }); await api.tabs.move(sorted[i].id, { index: i });
moved++; moved++;
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs }); updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs); await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
@@ -108,13 +110,13 @@ export class TabsMutationCommands extends CommandGroup {
}); });
} }
private windowHasAudibleTabs(window: chrome.windows.Window): boolean { private windowHasAudibleTabs(window: BrowserWindow): boolean {
return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted)); return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted));
} }
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) { private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
return runLargeOperation("tabs.merge_windows", async () => { return runLargeOperation("tabs.merge_windows", async () => {
const all = await chrome.windows.getAll({ populate: true }); const all = await api.windows.getAll({ populate: true });
const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w)); const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w));
const target = movableWindows.find(w => w.focused) || movableWindows[0]; const target = movableWindows.find(w => w.focused) || movableWindows[0];
if (!target) return { moved: 0, skippedAudibleWindows: all.length }; if (!target) return { moved: 0, skippedAudibleWindows: all.length };
@@ -127,7 +129,7 @@ export class TabsMutationCommands extends CommandGroup {
const ids = w.tabs.map(t => t.id); const ids = w.tabs.map(t => t.id);
const throttle = await getLargeOperationThrottle(ids.length, gentleMode); const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
moved = await processInBatches(ids, throttle, moved = await processInBatches(ids, throttle,
batch => chrome.tabs.move(batch, { windowId: target.id, index: -1 }), batch => api.tabs.move(batch, { windowId: target.id, index: -1 }),
{ job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved }); { job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved });
} }
return { moved, skippedAudibleWindows: all.length - movableWindows.length }; return { moved, skippedAudibleWindows: all.length - movableWindows.length };
@@ -135,42 +137,42 @@ export class TabsMutationCommands extends CommandGroup {
} }
private async tabsPin({ tabId }: TabIdArgs) { private async tabsPin({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: true }); await api.tabs.update(tab.id, { pinned: true });
return { tabId: tab.id, pinned: true }; return { tabId: tab.id, pinned: true };
} }
private async tabsUnpin({ tabId }: TabIdArgs) { private async tabsUnpin({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: false }); await api.tabs.update(tab.id, { pinned: false });
return { tabId: tab.id, pinned: false }; return { tabId: tab.id, pinned: false };
} }
private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) { private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) {
let windowId: number | undefined; let windowId: number | undefined;
if (tabId) { if (tabId) {
const tab = await chrome.tabs.get(tabId); const tab = await api.tabs.get(tabId);
await chrome.tabs.update(tabId, { active: true }); await api.tabs.update(tabId, { active: true });
windowId = tab.windowId; windowId = tab.windowId;
} else { } else {
const tab = await getActiveTab(); const tab = await getActiveTab();
windowId = tab.windowId; windowId = tab.windowId;
} }
const opts: chrome.extensionTypes.ImageDetails = { format: format as chrome.extensionTypes.ImageFormat }; const opts: browser.extensionTypes.ImageDetails = { format: format as browser.extensionTypes.ImageFormat };
if (format === "jpeg" && quality != null) opts.quality = quality; if (format === "jpeg" && quality != null) opts.quality = quality;
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts); const dataUrl = await api.tabs.captureVisibleTab(windowId, opts);
return { dataUrl, format }; return { dataUrl, format };
} }
private async tabsMute({ tabId }: TabIdArgs) { private async tabsMute({ tabId }: TabIdArgs) {
const tab = await resolveTabForDirectAction(tabId, "mute"); const tab = await resolveTabForDirectAction(tabId, "mute");
await chrome.tabs.update(tab.id, { muted: true }); await api.tabs.update(tab.id, { muted: true });
return { tabId: tab.id, muted: true }; return { tabId: tab.id, muted: true };
} }
private async tabsUnmute({ tabId }: TabIdArgs) { private async tabsUnmute({ tabId }: TabIdArgs) {
const tab = await resolveTabForDirectAction(tabId, "unmute"); const tab = await resolveTabForDirectAction(tabId, "unmute");
await chrome.tabs.update(tab.id, { muted: false }); await api.tabs.update(tab.id, { muted: false });
return { tabId: tab.id, muted: false }; return { tabId: tab.id, muted: false };
} }
} }
+7 -5
View File
@@ -1,3 +1,5 @@
import { webExtApi as api } from '../browser-api';
import type { WindowCreateData } from '../types';
import { getAliases } from '../core'; import { getAliases } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -13,7 +15,7 @@ export class WindowsCommands extends CommandGroup {
}; };
private async windowsList() { private async windowsList() {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const aliases = await getAliases(); const aliases = await getAliases();
return windows.map(w => ({ return windows.map(w => ({
id: w.id, id: w.id,
@@ -27,19 +29,19 @@ export class WindowsCommands extends CommandGroup {
private async windowsRename({ windowId, name }: WindowsRenameArgs) { private async windowsRename({ windowId, name }: WindowsRenameArgs) {
const aliases = await getAliases(); const aliases = await getAliases();
aliases[windowId] = name; aliases[windowId] = name;
await chrome.storage.local.set({ windowAliases: aliases }); await api.storage.local.set({ windowAliases: aliases });
return { windowId, name }; return { windowId, name };
} }
private async windowsClose({ windowId }: WindowsCloseArgs) { private async windowsClose({ windowId }: WindowsCloseArgs) {
await chrome.windows.remove(windowId); await api.windows.remove(windowId);
return { windowId }; return { windowId };
} }
private async windowsOpen({ url }: WindowsOpenArgs) { private async windowsOpen({ url }: WindowsOpenArgs) {
const createData: chrome.windows.CreateData = { focused: true }; const createData: WindowCreateData = { focused: true };
if (url) createData.url = url; if (url) createData.url = url;
const w = await chrome.windows.create(createData); const w = await api.windows.create(createData);
return { id: w.id }; return { id: w.id };
} }
} }
-404
View File
@@ -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 ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`;
}
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);
}
+63
View File
@@ -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");
}
+9
View File
@@ -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);
}
+217
View File
@@ -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 ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`;
}
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));
}
+47
View File
@@ -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];
}
+85
View File
@@ -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;
}
+6 -3
View File
@@ -1,15 +1,18 @@
import type { TabGroupColor } from '../types';
import { webExtApi as api } from '../browser-api';
// Tab-group resolution and normalization helpers. // Tab-group resolution and normalization helpers.
import { queryTabGroups } from './tab-groups';
export async function resolveGroupId(nameOrId: string | number): Promise<number> { export async function resolveGroupId(nameOrId: string | number): Promise<number> {
const asInt = parseInt(String(nameOrId)); const asInt = parseInt(String(nameOrId));
if (!isNaN(asInt)) return asInt; if (!isNaN(asInt)) return asInt;
const groups = await chrome.tabGroups.query({}); const groups = await queryTabGroups({});
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase()); const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`); if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
return match.id; return match.id;
} }
export function normalizeGroupColor(color: string | undefined): chrome.tabGroups.Color { export function normalizeGroupColor(color: string | undefined): TabGroupColor {
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
return (allowed.has(color as string) ? color : "grey") as chrome.tabGroups.Color; return (allowed.has(color as string) ? color : "grey") as TabGroupColor;
} }
+1
View File
@@ -3,4 +3,5 @@ export * from './throttle';
export * from './scripting'; export * from './scripting';
export * from './tab-helpers'; export * from './tab-helpers';
export * from './group-helpers'; export * from './group-helpers';
export * from './tab-groups';
export * from './storage'; export * from './storage';
+6 -4
View File
@@ -1,15 +1,17 @@
// chrome.scripting.executeScript wrapper with transient-error retry. import { webExtApi as api } from '../browser-api';
import type { ScriptInjection, ScriptInjectionResult } from '../types';
// api.scripting.executeScript wrapper with transient-error retry.
import { isTransientScriptError } from './errors'; import { isTransientScriptError } from './errors';
import { sleep } from './throttle'; import { sleep } from './throttle';
import type { Serializable } from '../types'; import type { Serializable } from '../types';
export async function executeScript<Args extends Serializable[], Result>( export async function executeScript<Args extends Serializable[], Result>(
options: chrome.scripting.ScriptInjection<Args, Result>, options: ScriptInjection<Args>,
retries = 3, retries = 3,
): Promise<chrome.scripting.InjectionResult<chrome.scripting.Awaited<Result>>[]> { ): Promise<ScriptInjectionResult<Result>[]> {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
return await chrome.scripting.executeScript(options); return await api.scripting.executeScript(options);
} catch (e) { } catch (e) {
if (i < retries - 1 && isTransientScriptError(e)) { if (i < retries - 1 && isTransientScriptError(e)) {
await sleep(300); await sleep(300);
+5 -4
View File
@@ -1,8 +1,9 @@
// chrome.storage.local accessors for profile alias, window aliases, and sessions. import { webExtApi as api } from '../browser-api';
// api.storage.local accessors for profile alias, window aliases, and sessions.
import type { SessionTab, StoredSession } from '../types'; import type { SessionTab, StoredSession } from '../types';
export async function getProfileAlias(): Promise<string> { export async function getProfileAlias(): Promise<string> {
const { profileAlias } = await chrome.storage.local.get<{ profileAlias?: string }>("profileAlias"); const { profileAlias } = await api.storage.local.get<{ profileAlias?: string }>("profileAlias");
return profileAlias || "default"; return profileAlias || "default";
} }
@@ -20,11 +21,11 @@ export function getSessionTabs(session: StoredSession | undefined | null): Sessi
} }
export async function getAliases(): Promise<Record<string, string>> { export async function getAliases(): Promise<Record<string, string>> {
const { windowAliases } = await chrome.storage.local.get<{ windowAliases?: Record<string, string> }>("windowAliases"); const { windowAliases } = await api.storage.local.get<{ windowAliases?: Record<string, string> }>("windowAliases");
return windowAliases || {}; return windowAliases || {};
} }
export async function getSessions(): Promise<Record<string, StoredSession>> { export async function getSessions(): Promise<Record<string, StoredSession>> {
const { sessions } = await chrome.storage.local.get<{ sessions?: Record<string, StoredSession> }>("sessions"); const { sessions } = await api.storage.local.get<{ sessions?: Record<string, StoredSession> }>("sessions");
return sessions || {}; return sessions || {};
} }
+57
View File
@@ -0,0 +1,57 @@
import type { TabGroupQueryInfo, TabGroup, TabGroupUpdateProperties, TabGroupMoveProperties, TabGroupOptions, BrowserEvent } from '../types';
import { webExtApi as api } from '../browser-api';
// Optional tab-group API accessors. Firefox currently does not implement the
// Chromium tabGroups/tabs.group APIs, so keep runtime checks in one place and
// use bracket access to avoid Firefox package validation flagging static API
// references in commands that will fail gracefully at runtime.
const TAB_GROUPS_UNSUPPORTED = "Tab groups are not supported by this browser";
function tabGroupsApi(): typeof api.tabGroups {
const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
if (!tabGroups) throw new Error(TAB_GROUPS_UNSUPPORTED);
return tabGroups;
}
function tabsGroupApi(): typeof api.tabs.group {
const fn = api.tabs["group" as keyof typeof api.tabs] as typeof api.tabs.group | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(api.tabs);
}
function tabsUngroupApi(): typeof api.tabs.ungroup {
const fn = api.tabs["ungroup" as keyof typeof api.tabs] as typeof api.tabs.ungroup | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(api.tabs);
}
export async function queryTabGroups(queryInfo: TabGroupQueryInfo = {}): Promise<TabGroup[]> {
const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
if (!tabGroups) return [];
return tabGroups.query(queryInfo);
}
export async function getTabGroup(groupId: number): Promise<TabGroup> {
return tabGroupsApi().get(groupId);
}
export async function updateTabGroup(groupId: number, updateProperties: TabGroupUpdateProperties): Promise<TabGroup> {
return tabGroupsApi().update(groupId, updateProperties);
}
export async function moveTabGroup(groupId: number, moveProperties: TabGroupMoveProperties): Promise<TabGroup> {
return tabGroupsApi().move(groupId, moveProperties);
}
export async function groupTabs(createProperties: TabGroupOptions): Promise<number> {
return tabsGroupApi()(createProperties);
}
export async function ungroupTabs(tabIds: [number, ...number[]]): Promise<void> {
return tabsUngroupApi()(tabIds);
}
export function tabGroupsOnUpdated(): BrowserEvent<(group: TabGroup) => void> | undefined {
const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
return tabGroups?.onUpdated;
}
+18 -15
View File
@@ -1,3 +1,5 @@
import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
// Tab-related shared helpers: info shaping, scriptable-url checks, active-tab // Tab-related shared helpers: info shaping, scriptable-url checks, active-tab
// resolution, and HTML fetching. // resolution, and HTML fetching.
import { isBrowserErrorUrl, isErrorPageScriptError } from './errors'; import { isBrowserErrorUrl, isErrorPageScriptError } from './errors';
@@ -5,8 +7,8 @@ import { executeScript } from './scripting';
import type { TabBlock } from '../types'; import type { TabBlock } from '../types';
/** /**
* Narrow a plain id array to the non-empty-tuple shape that chrome.tabs.group / * Narrow a plain id array to the non-empty-tuple shape that api.tabs.group /
* chrome.tabs.ungroup declare. The runtime happily accepts any array (including * api.tabs.ungroup declare. The runtime happily accepts any array (including
* a single element); the published @types/chrome just over-constrain the param * a single element); the published @types/chrome just over-constrain the param
* to `[number, ...number[]]`. Callers guarantee non-emptiness before calling. * to `[number, ...number[]]`. Callers guarantee non-emptiness before calling.
*/ */
@@ -14,12 +16,13 @@ export function asTabIds(ids: number[]): [number, ...number[]] {
return ids as [number, ...number[]]; return ids as [number, ...number[]];
} }
export function tabInfo(t: chrome.tabs.Tab) { export function tabInfo(t: Tab) {
return { return {
id: t.id, id: t.id,
windowId: t.windowId, windowId: t.windowId,
active: t.active, active: t.active,
muted: Boolean(t.mutedInfo && t.mutedInfo.muted), muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
index: t.index,
groupId: t.groupId >= 0 ? t.groupId : null, groupId: t.groupId >= 0 ? t.groupId : null,
title: t.title, title: t.title,
url: t.url || t.pendingUrl || "", url: t.url || t.pendingUrl || "",
@@ -36,16 +39,16 @@ export function isScriptableUrl(url: string | undefined | null): boolean {
} }
export async function getActiveTab() { export async function getActiveTab() {
const activeTabs = await chrome.tabs.query({ active: true }); const activeTabs = await api.tabs.query({ active: true });
if (!activeTabs.length) throw new Error("No active tab found"); if (!activeTabs.length) throw new Error("No active tab found");
const windows = await chrome.windows.getAll({ populate: false }); const windows = await api.windows.getAll({ populate: false });
const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id)); const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id));
const chooseTab = (predicate: (tab: chrome.tabs.Tab) => boolean) => activeTabs.find(predicate); const chooseTab = (predicate: (tab: Tab) => boolean) => activeTabs.find(predicate);
const byFocusAndScriptable = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || ""); const byFocusAndScriptable = (tab: Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
const byScriptable = (tab: chrome.tabs.Tab) => isScriptableUrl(tab.url || tab.pendingUrl || ""); const byScriptable = (tab: Tab) => isScriptableUrl(tab.url || tab.pendingUrl || "");
const byFocus = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId); const byFocus = (tab: Tab) => focusedWindowIds.has(tab.windowId);
return chooseTab(byFocusAndScriptable) return chooseTab(byFocusAndScriptable)
|| chooseTab(byScriptable) || chooseTab(byScriptable)
@@ -54,8 +57,8 @@ export async function getActiveTab() {
} }
/** Resolve the target tab (explicit id or the active tab) and its current URL. */ /** Resolve the target tab (explicit id or the active tab) and its current URL. */
export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: chrome.tabs.Tab; url: string }> { export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: Tab; url: string }> {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
return { tab, url: tab.url || tab.pendingUrl || "" }; return { tab, url: tab.url || tab.pendingUrl || "" };
} }
@@ -70,11 +73,11 @@ export function assertScriptableUrl(url: string, action: string): void {
} }
} }
export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise<chrome.tabs.Tab> { export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise<Tab> {
if (tabId != null) { if (tabId != null) {
return chrome.tabs.get(tabId); return api.tabs.get(tabId);
} }
const allTabs = await chrome.tabs.query({}); const allTabs = await api.tabs.query({});
if (allTabs.length !== 1) { if (allTabs.length !== 1) {
throw new Error( throw new Error(
`Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open` `Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open`
@@ -83,7 +86,7 @@ export async function resolveTabForDirectAction(tabId: number | undefined | null
return allTabs[0]; return allTabs[0];
} }
export function buildTabBlocks(tabs: chrome.tabs.Tab[]): TabBlock[] { export function buildTabBlocks(tabs: Tab[]): TabBlock[] {
const blocks: TabBlock[] = []; const blocks: TabBlock[] = [];
for (const tab of tabs) { for (const tab of tabs) {
const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null; const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null;
+4 -3
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
// Large-operation throttling, performance profile, and job-progress helpers. // Large-operation throttling, performance profile, and job-progress helpers.
import type { Job, JobProgressUpdate } from '../types'; import type { Job, JobProgressUpdate } from '../types';
@@ -16,7 +17,7 @@ function debugLargeOperation(message: string) {
} }
export async function hasAudibleTabs() { export async function hasAudibleTabs() {
const audibleTabs = await chrome.tabs.query({ audible: true }); const audibleTabs = await api.tabs.query({ audible: true });
return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted)); return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted));
} }
@@ -36,14 +37,14 @@ export async function runLargeOperation<T>(name: string, fn: () => Promise<T>):
} }
export async function getPerformanceProfile() { export async function getPerformanceProfile() {
const { performanceProfile } = await chrome.storage.local.get<{ performanceProfile?: string }>("performanceProfile"); const { performanceProfile } = await api.storage.local.get<{ performanceProfile?: string }>("performanceProfile");
return performanceProfile || "auto"; return performanceProfile || "auto";
} }
export async function setPerformanceProfile(profile: string) { export async function setPerformanceProfile(profile: string) {
const allowed = new Set(["auto", "normal", "gentle", "ultra"]); const allowed = new Set(["auto", "normal", "gentle", "ultra"]);
const performanceProfile = allowed.has(profile) ? profile : "auto"; const performanceProfile = allowed.has(profile) ? profile : "auto";
await chrome.storage.local.set({ performanceProfile }); await api.storage.local.set({ performanceProfile });
return { performanceProfile }; return { performanceProfile };
} }
+2 -1
View File
@@ -6,6 +6,7 @@
* the native connection. * the native connection.
*/ */
import { webExtApi as api } from './browser-api';
import { JobManager } from './classes/JobManager'; import { JobManager } from './classes/JobManager';
import { assembleRegistry } from './classes/CommandRegistry'; import { assembleRegistry } from './classes/CommandRegistry';
import { NativeConnection } from './classes/NativeConnection'; import { NativeConnection } from './classes/NativeConnection';
@@ -15,7 +16,7 @@ const jobs = new JobManager();
const ctx: CommandContext = { jobs }; const ctx: CommandContext = { jobs };
const { registry, session } = assembleRegistry(ctx); const { registry, session } = assembleRegistry(ctx);
chrome.tabs.onActivated.addListener(async ({ tabId }) => { api.tabs.onActivated.addListener(async ({ tabId }) => {
await session.activateLazyTab(tabId); await session.activateLazyTab(tabId);
}); });
+1 -1
View File
@@ -54,7 +54,7 @@ export interface DomEvalArgs { code?: string; tabId?: number; }
export interface DomWaitForArgs { selector?: string; timeout?: number; visible?: boolean; hidden?: boolean; tabId?: number; } export interface DomWaitForArgs { selector?: string; timeout?: number; visible?: boolean; hidden?: boolean; tabId?: number; }
export interface DomPollArgs { selector?: string; pattern?: string; attr?: string; timeout?: number; interval?: number; tabId?: number; } export interface DomPollArgs { selector?: string; pattern?: string; attr?: string; timeout?: number; interval?: number; tabId?: number; }
/** Arguments forwarded to the in-page content functions over chrome.scripting. */ /** Arguments forwarded to the in-page content functions over browser.scripting. */
export interface ContentArgs { export interface ContentArgs {
selector?: string; selector?: string;
text?: string; text?: string;
+1
View File
@@ -2,5 +2,6 @@ export * from './json';
export * from './jobs'; export * from './jobs';
export * from './session'; export * from './session';
export * from './tabs'; export * from './tabs';
export * from './webextension';
export * from './messages'; export * from './messages';
export * from './command-args'; export * from './command-args';
+41
View File
@@ -0,0 +1,41 @@
import type { Serializable } from './json';
export type RuntimePort = browser.runtime.Port;
export type Tab = browser.tabs.Tab & {
groupId?: number;
pendingUrl?: string;
};
export type TabUpdateInfo = Parameters<typeof browser.tabs.onUpdated.addListener>[0] extends (tabId: number, changeInfo: infer ChangeInfo, tab: browser.tabs.Tab) => void ? ChangeInfo : { url?: string };
export type BrowserWindow = browser.windows.Window & { tabs?: Tab[] };
export type WindowCreateData = browser.windows._CreateCreateData;
export type TabMoveProperties = browser.tabs._MoveMoveProperties;
export type TabGroupOptions = browser.tabs._GroupOptions;
export type TabGroup = browser.tabGroups.TabGroup;
export type TabGroupColor = browser.tabGroups.Color;
export type TabGroupQueryInfo = browser.tabGroups._QueryInfo;
export type TabGroupUpdateProperties = browser.tabGroups._UpdateProperties;
export type TabGroupMoveProperties = browser.tabGroups._MoveProperties;
export type BrowserEvent<TCallback extends (...args: never[]) => void> = {
addListener(cb: TCallback): void;
removeListener(cb: TCallback): void;
hasListener(cb: TCallback): boolean;
};
export type ScriptInjection<Args extends Serializable[]> = browser.scripting.ScriptInjection<Args>;
export type ScriptInjectionResult<Result> = browser.scripting.InjectionResult & { result?: Awaited<Result> };
export type StorageLocal = Omit<typeof browser.storage.local, "get"> & {
get<T extends object = { [key: string]: Serializable }>(keys?: string | string[] | object | null): Promise<T>;
};
export type WebExtensionApi = Omit<typeof browser, "tabs" | "windows" | "storage"> & {
tabs: Omit<typeof browser.tabs, "query" | "get" | "create" | "update" | "move"> & {
query(queryInfo: browser.tabs._QueryQueryInfo): Promise<Tab[]>;
get(tabId: number): Promise<Tab>;
create(createProperties: browser.tabs._CreateCreateProperties): Promise<Tab>;
update(tabId: number, updateProperties: browser.tabs._UpdateUpdateProperties): Promise<Tab>;
move(tabIds: number | number[], moveProperties: TabMoveProperties): Promise<Tab | Tab[]>;
};
windows: Omit<typeof browser.windows, "getAll" | "create"> & {
getAll(getInfo?: browser.windows._GetAllGetInfo): Promise<BrowserWindow[]>;
create(createData?: WindowCreateData): Promise<BrowserWindow>;
};
storage: Omit<typeof browser.storage, "local"> & { local: StorageLocal };
};
+43
View File
@@ -0,0 +1,43 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { webExtApi } from '../src/browser-api';
test('browser-api uses Firefox browser.* before Chromium chrome.*', () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const firefoxApi = { runtime: { id: 'firefox-api' } };
const chromiumApi = { runtime: { id: 'chromium-api' } };
try {
globalThis.chrome = chromiumApi;
globalThis.browser = firefoxApi;
assert.equal(webExtApi.runtime, firefoxApi.runtime);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
}
});
test('browser-api falls back to chrome.* in Chromium', () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const chromiumApi = { runtime: { id: 'chromium-api' } };
try {
globalThis.chrome = chromiumApi;
delete globalThis.browser;
assert.equal(webExtApi.runtime, chromiumApi.runtime);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
}
});
+86
View File
@@ -0,0 +1,86 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { SessionCommands } from '../src/commands/session';
import { JobManager } from '../src/classes/JobManager';
import { makeChromeMock } from './chrome-mock';
function makeSessionCommands() {
return new SessionCommands({ jobs: new JobManager() });
}
test('clients.list uses Firefox runtime.getBrowserInfo when available', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const chromeMock = makeChromeMock();
try {
delete globalThis.chrome;
globalThis.browser = {
...chromeMock,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
getBrowserInfo: async () => ({ name: 'Firefox', vendor: 'Mozilla', version: '149.0', buildID: 'test' }),
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Firefox/149.0' },
configurable: true,
});
const clients = await makeSessionCommands().commands['clients.list']({});
assert.equal(clients[0].name, 'Firefox');
assert.equal(clients[0].version, '149.0');
assert.equal(clients[0].extensionVersion, '0.15.1');
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
test('clients.list falls back to Chromium user-agent when getBrowserInfo is missing', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const chromeMock = makeChromeMock();
try {
delete globalThis.browser;
globalThis.chrome = {
...chromeMock,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Chrome/149.0.0.0 Safari/537.36' },
configurable: true,
});
const clients = await makeSessionCommands().commands['clients.list']({});
assert.equal(clients[0].name, 'Chrome');
assert.equal(clients[0].version, '149.0.0.0');
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
+66
View File
@@ -0,0 +1,66 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { NavigationCommands } from '../src/commands/navigation';
import { JobManager } from '../src/classes/JobManager';
import { makeChromeMock } from './chrome-mock';
function makeNavigationCommands() {
return new NavigationCommands({ jobs: new JobManager() });
}
test('navigate.open waits until Firefox updates about:blank to the requested URL', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const firefoxApi = makeChromeMock();
const targetUrl = 'https://example.com/?browser-cli-firefox-open-wait=1';
let getCalls = 0;
try {
delete globalThis.chrome;
globalThis.browser = {
...firefoxApi,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
getBrowserInfo: async () => ({ name: 'Firefox', vendor: 'Mozilla', version: '151.0.2', buildID: 'test' }),
},
tabs: {
...firefoxApi.tabs,
create: async () => ({ id: 123, windowId: 1, index: 0, active: true, groupId: -1, url: 'about:blank' }),
get: async () => {
getCalls += 1;
return {
id: 123,
windowId: 1,
index: 0,
active: true,
groupId: -1,
url: getCalls < 2 ? 'about:blank' : targetUrl,
};
},
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Firefox/151.0.2' },
configurable: true,
});
const result = await makeNavigationCommands().commands['navigate.open']({ url: targetUrl, focus: true });
assert.equal(result.id, 123);
assert.equal(result.url, targetUrl);
assert.ok(getCalls >= 2);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
+8
View File
@@ -7,6 +7,7 @@
"name": "browser-cli-extension-build", "name": "browser-cli-extension-build",
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.1.40", "@types/chrome": "^0.1.40",
"@types/firefox-webext-browser": "^143.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
@@ -481,6 +482,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/firefox-webext-browser": {
"version": "143.0.0",
"resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-143.0.0.tgz",
"integrity": "sha512-865dYKMOP0CllFyHmgXV4IQgVL51OSQQCwSoihQ17EwugePKFSAZRc0EI+y7Ly4q7j5KyURlA7LgRpFieO4JOw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": { "node_modules/@types/har-format": {
"version": "1.2.16", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
+3 -1
View File
@@ -8,10 +8,12 @@
"test:extension": "npm run build:tests && node --disable-warning=ExperimentalWarning --test extension/test-dist/*.test.mjs", "test:extension": "npm run build:tests && node --disable-warning=ExperimentalWarning --test extension/test-dist/*.test.mjs",
"check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension", "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": "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": "npm run build:extension && python scripts/package_extension.py --webstore",
"package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox"
}, },
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.1.40", "@types/chrome": "^0.1.40",
"@types/firefox-webext-browser": "^143.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.14.3" version = "0.15.3"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+21 -10
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Package the Chrome extension. """Package the browser extension.
Default builds a testing/unpacked-style archive that keeps manifest.key so the Default builds a testing/unpacked-style archive that keeps manifest.key so the
extension ID stays stable for native messaging. ``--webstore`` writes the same Chromium extension ID stays stable for native messaging. ``--webstore`` writes
runtime files but strips ``key`` from manifest.json because the Chrome Web Store the same runtime files but strips ``key`` from manifest.json because the Chrome
rejects that field. Web Store rejects that field. ``--firefox`` writes a Firefox-friendly archive
with the Gecko extension ID and without Chromium-only manifest keys.
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,10 +27,17 @@ RUNTIME_FILES = (
) )
RUNTIME_DIRS = ("icons",) RUNTIME_DIRS = ("icons",)
def _read_manifest(webstore: bool) -> dict: def _read_manifest(webstore: bool, firefox: bool) -> dict:
manifest = json.loads((EXTENSION_DIR / "manifest.json").read_text(encoding="utf-8")) manifest = json.loads((EXTENSION_DIR / "manifest.json").read_text(encoding="utf-8"))
if webstore: if webstore or firefox:
manifest.pop("key", None) manifest.pop("key", None)
if firefox:
manifest["permissions"] = [p for p in manifest.get("permissions", []) if p != "windows"]
manifest["background"] = {"scripts": ["background.js"]}
gecko = manifest.setdefault("browser_specific_settings", {}).setdefault("gecko", {})
gecko["strict_min_version"] = "140.0"
manifest.setdefault("browser_specific_settings", {}).setdefault("gecko_android", {})["strict_min_version"] = "142.0"
gecko["data_collection_permissions"] = {"required": ["none"]}
return manifest return manifest
def _copy_tree(src: Path, dst: Path) -> None: def _copy_tree(src: Path, dst: Path) -> None:
@@ -37,10 +45,12 @@ def _copy_tree(src: Path, dst: Path) -> None:
shutil.rmtree(dst) shutil.rmtree(dst)
shutil.copytree(src, dst) shutil.copytree(src, dst)
def package_extension(*, webstore: bool = False, out: Path | None = None) -> Path: def package_extension(*, webstore: bool = False, firefox: bool = False, out: Path | None = None) -> Path:
manifest = _read_manifest(webstore) if webstore and firefox:
raise ValueError("--webstore and --firefox are mutually exclusive")
manifest = _read_manifest(webstore, firefox)
version = manifest["version"] version = manifest["version"]
suffix = "webstore" if webstore else "testing" suffix = "firefox" if firefox else "webstore" if webstore else "testing"
out = out or DIST_DIR / f"browser-cli-extension-{suffix}-v{version}.zip" out = out or DIST_DIR / f"browser-cli-extension-{suffix}-v{version}.zip"
staging = DIST_DIR / f"extension-package-{suffix}" staging = DIST_DIR / f"extension-package-{suffix}"
@@ -70,9 +80,10 @@ def package_extension(*, webstore: bool = False, out: Path | None = None) -> Pat
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Package browser-cli extension") parser = argparse.ArgumentParser(description="Package browser-cli extension")
parser.add_argument("--webstore", action="store_true", help="strip manifest.key for Chrome Web Store upload") parser.add_argument("--webstore", action="store_true", help="strip manifest.key for Chrome Web Store upload")
parser.add_argument("--firefox", action="store_true", help="build a Firefox-friendly extension zip")
parser.add_argument("--out", type=Path, default=None, help="output zip path") parser.add_argument("--out", type=Path, default=None, help="output zip path")
args = parser.parse_args() args = parser.parse_args()
print(package_extension(webstore=args.webstore, out=args.out)) print(package_extension(webstore=args.webstore, firefox=args.firefox, out=args.out))
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+10
View File
@@ -18,6 +18,7 @@ TAB_DATA = {
"id": 10, "id": 10,
"windowId": 1, "windowId": 1,
"active": True, "active": True,
"index": 3,
"title": "Example", "title": "Example",
"url": "https://example.com", "url": "https://example.com",
"groupId": None, "groupId": None,
@@ -73,6 +74,15 @@ class TestBrowserCLIInit:
assert b.remote == "browser-host.example:443" assert b.remote == "browser-host.example:443"
assert b.key == "agent" 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): def test_namespaces_present_and_bound(self):
b = BrowserCLI() b = BrowserCLI()
for name in ("nav", "tabs", "groups", "windows", "dom", "extract", for name in ("nav", "tabs", "groups", "windows", "dom", "extract",
+30 -1
View File
@@ -87,7 +87,7 @@ def test_install_help_lists_supported_browsers():
result = CliRunner().invoke(main, ["install", "--help"]) result = CliRunner().invoke(main, ["install", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output assert "[chrome|chromium|brave|edge|vivaldi|firefox]" in result.output
def test_install_writes_testing_and_webstore_allowed_origins(tmp_path): def test_install_writes_testing_and_webstore_allowed_origins(tmp_path):
manifests = [] manifests = []
@@ -117,6 +117,35 @@ def test_install_writes_testing_and_webstore_allowed_origins(tmp_path):
assert "Testing extension ID" in result.output assert "Testing extension ID" in result.output
assert "Chrome Web Store extension ID" in result.output assert "Chrome Web Store extension ID" in result.output
def test_install_writes_firefox_allowed_extensions(tmp_path):
manifests = []
def fake_install_manifest(_browser, _host_exe, manifest):
manifests.append(manifest)
return [tmp_path / "com.browsercli.host.json"]
with patch("browser_cli.commands.install.native_host_exe", return_value=tmp_path / "browser-cli-native-host"), patch(
"browser_cli.commands.install.write_native_host_exe"
), patch("browser_cli.commands.install._install_manifest", side_effect=fake_install_manifest):
result = CliRunner().invoke(main, ["install", "firefox"])
assert result.exit_code == 0
assert manifests == [
{
"name": "com.browsercli.host",
"description": "browser-cli native messaging host",
"path": str(tmp_path / "browser-cli-native-host"),
"type": "stdio",
"allowed_extensions": ["browser-cli@yiprawr.dev"],
}
]
assert "about:debugging#/runtime/this-firefox" in result.output
assert "npm run package:extension:firefox" 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): def test_install_windows_registers_native_host(tmp_path):
writes = [] writes = []
+29 -5
View File
@@ -1,7 +1,15 @@
from pathlib import Path from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
def read_built_background() -> str:
background_path = ROOT / "extension" / "background.js"
if not background_path.exists():
pytest.skip("extension/background.js is a generated build artifact; run npm run build:extension")
return background_path.read_text()
def test_extension_retries_error_page_script_injection_before_failing(): def test_extension_retries_error_page_script_injection_before_failing():
# core.ts was split into a core/ subfolder during the structure refactor: # core.ts was split into a core/ subfolder during the structure refactor:
# the URL/error classifiers live in core/errors.ts and the executeScript # the URL/error classifiers live in core/errors.ts and the executeScript
@@ -66,7 +74,7 @@ def test_large_extension_operations_yield_between_batches():
assert "GENTLE_OPERATION_PAUSE_MS" in core assert "GENTLE_OPERATION_PAUSE_MS" in core
assert "itemCount >= 300" in core assert "itemCount >= 300" in core
assert "itemCount >= 100" in core assert "itemCount >= 100" in core
assert "chrome.tabs.query({ audible: true })" in core assert "api.tabs.query({ audible: true })" in core
# The centralized batch loop drives cancellation + progress + throttled yield. # The centralized batch loop drives cancellation + progress + throttled yield.
assert "processInBatches" in core assert "processInBatches" in core
assert "throwIfJobCancelled(progress.job)" in core assert "throwIfJobCancelled(progress.job)" in core
@@ -85,7 +93,7 @@ def test_large_extension_operations_yield_between_batches():
assert "yieldForLargeOperation(createdTabs.length" in session assert "yieldForLargeOperation(createdTabs.length" in session
assert "getLargeOperationThrottle" in session assert "getLargeOperationThrottle" in session
assert "runLargeOperation(\"session.load\"" in session assert "runLargeOperation(\"session.load\"" in session
assert "chrome.tabs.discard" in session assert "api.tabs.discard" in session
assert "lazyPlaceholderUrl" in session assert "lazyPlaceholderUrl" in session
assert "activateLazyTab" in session assert "activateLazyTab" in session
assert "lazySessionTabs" in session assert "lazySessionTabs" in session
@@ -122,6 +130,22 @@ def test_tab_activation_open_and_merge_do_not_steal_audible_video_window():
assert "skippedAudibleWindows" in tabs assert "skippedAudibleWindows" in tabs
assert "const target = movableWindows.find(w => w.focused) || movableWindows[0];" in tabs assert "const target = movableWindows.find(w => w.focused) || movableWindows[0];" in tabs
def test_built_extension_avoids_static_firefox_unsupported_tab_group_api_refs():
background = read_built_background()
assert "chrome.tabGroups" not in background
assert "chrome.tabs.group" not in background
assert "chrome.tabs.ungroup" not in background
assert 'webExtApi["tabGroups"' in background
assert 'webExtApi.tabs["group"' in background
def test_built_extension_avoids_direct_eval_token_for_firefox_linter():
background = read_built_background()
assert "(0, eval)(" not in background
assert "eval(" not in background
assert 'globalThis["eval"]' in background
def test_session_autosave_is_debounced_and_non_overlapping(): def test_session_autosave_is_debounced_and_non_overlapping():
# The autosave lifecycle moved out of session.ts into a dedicated # The autosave lifecycle moved out of session.ts into a dedicated
# AutoSaveManager (autosave.ts) during the structure refactor; the shared # AutoSaveManager (autosave.ts) during the structure refactor; the shared
@@ -140,9 +164,9 @@ def test_session_autosave_is_debounced_and_non_overlapping():
assert "autoSaveSignature" in autosave assert "autoSaveSignature" in autosave
# AutoSaveManager binds the handlers as instance fields (this.*), so the # AutoSaveManager binds the handlers as instance fields (this.*), so the
# add/removeListener references stay identity-stable across enable/disable. # add/removeListener references stay identity-stable across enable/disable.
assert "chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave assert "api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave
assert "chrome.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave assert "api.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave
assert "chrome.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave assert "api.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave
assert "if (!(\"url\" in changeInfo)) return;" in autosave assert "if (!(\"url\" in changeInfo)) return;" in autosave
assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave
assert "clearTimeout(this.autoSaveTimer)" in autosave assert "clearTimeout(this.autoSaveTimer)" in autosave
+21
View File
@@ -19,6 +19,9 @@ def _fake_extension(tmp_path: Path) -> Path:
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "1.2.3", "version": "1.2.3",
"permissions": ["tabs", "tabGroups", "windows", "nativeMessaging"],
"background": {"service_worker": "background.js"},
"browser_specific_settings": {"gecko": {"id": "browser-cli@yiprawr.dev"}},
"key": "test-key", "key": "test-key",
}), encoding="utf-8") }), encoding="utf-8")
for name in ("background.js", "content-dispatch.js", "content.js"): for name in ("background.js", "content-dispatch.js", "content.js"):
@@ -47,6 +50,24 @@ def test_webstore_package_strips_manifest_key(tmp_path):
assert "content.js" in names assert "content.js" in names
assert "icons/icon-128.png" in names assert "icons/icon-128.png" in names
def test_firefox_package_strips_chromium_key_and_firefox_incompatible_permission(tmp_path):
packager = _packager_with_fake_extension(tmp_path)
out = packager.package_extension(firefox=True, out=tmp_path / "firefox.zip")
with zipfile.ZipFile(out) as zf:
manifest = json.loads(zf.read("manifest.json"))
assert "key" not in manifest
assert manifest["browser_specific_settings"]["gecko"]["id"] == "browser-cli@yiprawr.dev"
assert "tabGroups" in manifest["permissions"]
assert "windows" not in manifest["permissions"]
assert "nativeMessaging" in manifest["permissions"]
assert "service_worker" not in manifest["background"]
assert manifest["background"]["scripts"] == ["background.js"]
assert manifest["browser_specific_settings"]["gecko"]["strict_min_version"] == "140.0"
assert manifest["browser_specific_settings"]["gecko_android"]["strict_min_version"] == "142.0"
assert manifest["browser_specific_settings"]["gecko"]["data_collection_permissions"] == {"required": ["none"]}
def test_local_package_keeps_manifest_key(tmp_path): def test_local_package_keeps_manifest_key(tmp_path):
packager = _packager_with_fake_extension(tmp_path) packager = _packager_with_fake_extension(tmp_path)
out = packager.package_extension(webstore=False, out=tmp_path / "local.zip") out = packager.package_extension(webstore=False, out=tmp_path / "local.zip")
+84 -1
View File
@@ -7,6 +7,7 @@ from click.testing import CliRunner
import pytest import pytest
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.client import BrowserTarget
from browser_cli.cli import main from browser_cli.cli import main
from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category 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"}), ("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(): 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"]) result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Tabs" in result.output 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(): def test_doctor_command_reports_connection_failure_cleanly():
with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \ with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \
patch("browser_cli.send_command", side_effect=RuntimeError("no browser")): patch("browser_cli.send_command", side_effect=RuntimeError("no browser")):
+65
View File
@@ -0,0 +1,65 @@
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
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(rendering.shutil, "get_terminal_size", lambda fallback: terminal_size((140, 20)))
assert rendering.terminal_width(Console(width=80)) == 140
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(rendering, "Console", CapturingConsole)
monkeypatch.setattr(rendering, "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_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
+1 -1
View File
@@ -4,7 +4,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
"types": ["chrome"], "types": ["chrome", "firefox-webext-browser"],
"allowJs": false, "allowJs": false,
"strict": false, "strict": false,
"noImplicitAny": true, "noImplicitAny": true,
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.14.3" version = "0.15.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },