From 371b7941703f98fd1c4212648660b49234859158 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Wed, 17 Jun 2026 16:54:20 +0200 Subject: [PATCH] chore: prepare verified CRX uploads and release 0.15.4 - Add helper scripts for Chrome Web Store verified CRX uploads using a dedicated RSA upload key protected by GPG. - Document the verified upload packaging flow and ignore local signing secrets. - Add npm packaging entry point for signed webstore CRX artifacts. - Chunk large SDK tab close batches to avoid native-host response timeouts. - Bump project and extension versions to 0.15.4 with matching tests. --- .gitignore | 5 ++ README.md | 17 +++-- browser_cli/sdk/tabs.py | 19 ++++++ extension/manifest.json | 2 +- package.json | 1 + pyproject.toml | 2 +- scripts/package_verified_crx.sh | 102 ++++++++++++++++++++++++++++++ scripts/setup_verified_crx_key.sh | 83 ++++++++++++++++++++++++ tests/test_api.py | 21 ++++++ uv.lock | 2 +- 10 files changed, 247 insertions(+), 7 deletions(-) create mode 100755 scripts/package_verified_crx.sh create mode 100755 scripts/setup_verified_crx_key.sh diff --git a/.gitignore b/.gitignore index 2e16b8a..210d49a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ extension/test-dist/ node_modules/ dist/ +# Local secrets / signing keys +secrets/ +*.pem +*.pem.gpg + # Python __pycache__/ *.pyc diff --git a/README.md b/README.md index 5a0c3d2..b2269d6 100644 --- a/README.md +++ b/README.md @@ -515,12 +515,21 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e Packaging: ```bash -npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID -npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key -npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions +npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID +npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key +npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads +npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions ``` -Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For Firefox, use the `*-firefox-*` zip. +Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key: + +```bash +scripts/setup_verified_crx_key.sh --recipient '' +# Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads. +npm run package:extension:webstore:verified +``` + +The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip. For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons. diff --git a/browser_cli/sdk/tabs.py b/browser_cli/sdk/tabs.py index 6a05a22..0502729 100644 --- a/browser_cli/sdk/tabs.py +++ b/browser_cli/sdk/tabs.py @@ -6,6 +6,11 @@ from collections.abc import Callable, Iterable from browser_cli.models import BrowserCounts, Tab from browser_cli.sdk.base import Namespace +# Keep SDK-driven bulk closes comfortably below the native-host response +# timeout. The extension can close larger batches, but real browsers may take +# much longer when hundreds of visible tabs are involved. +BULK_CLOSE_CHUNK_SIZE = 50 + class TabsNS(Namespace): """List, open, close, move, and inspect browser tabs.""" @@ -75,6 +80,20 @@ class TabsNS(Namespace): ids = None if tab_ids is not None: ids = [t.id if isinstance(t, Tab) else t for t in tab_ids] + if ids is not None and len(ids) > BULK_CLOSE_CHUNK_SIZE and not inactive and not duplicates and tab_id is None: + closed = 0 + for start in range(0, len(ids), BULK_CLOSE_CHUNK_SIZE): + chunk = ids[start:start + BULK_CLOSE_CHUNK_SIZE] + result = self.command("tabs.close", { + "tabId": None, + "tabIds": chunk, + "inactive": False, + "duplicates": False, + "gentleMode": gentle_mode, + }) + closed += self.field(result, "closed", len(chunk)) + return closed + result = self.command("tabs.close", { "tabId": tab_id, "tabIds": ids, diff --git a/extension/manifest.json b/extension/manifest.json index f748d32..0111c6e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.15.3", + "version": "0.15.4", "description": "Control your browser from the terminal or Python SDK", "browser_specific_settings": { "gecko": { diff --git a/package.json b/package.json index 69d67f6..9d3d46a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension", "package:extension": "npm run build:extension && python scripts/package_extension.py", "package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore", + "package:extension:webstore:verified": "scripts/package_verified_crx.sh", "package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox" }, "devDependencies": { diff --git a/pyproject.toml b/pyproject.toml index 8815f1f..0a58ba4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "real-browser-cli" -version = "0.15.3" +version = "0.15.4" description = "Control your real running browser from the terminal or Python SDK" readme = "README.md" license = { file = "LICENSE" } diff --git a/scripts/package_verified_crx.sh b/scripts/package_verified_crx.sh new file mode 100755 index 0000000..4aca54d --- /dev/null +++ b/scripts/package_verified_crx.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/package_verified_crx.sh [--key FILE.gpg] [--browser COMMAND] [--out FILE.crx] + +Builds the Chrome Web Store package and creates a CRX signed with the dedicated +verified-upload RSA key. The RSA private key is expected to be GPG-encrypted. + +Environment alternatives: + VERIFIED_CRX_KEY_GPG Path to encrypted RSA private key + CHROME_FOR_PACKING Browser command with --pack-extension support +EOF +} + +key_gpg="${VERIFIED_CRX_KEY_GPG:-secrets/verified-crx/chrome-webstore-verified-crx-private-key.pem.gpg}" +browser_cmd="${CHROME_FOR_PACKING:-}" +out="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --key) + key_gpg="${2:-}" + shift 2 + ;; + --browser) + browser_cmd="${2:-}" + shift 2 + ;; + --out) + out="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ ! -f "$key_gpg" ]]; then + echo "Encrypted verified CRX key not found: $key_gpg" >&2 + echo "Create it with: scripts/setup_verified_crx_key.sh --recipient ''" >&2 + exit 1 +fi + +if [[ -z "$browser_cmd" ]]; then + for candidate in google-chrome chrome chromium chromium-browser brave-browser brave; do + if command -v "$candidate" >/dev/null 2>&1; then + browser_cmd="$candidate" + break + fi + done +fi + +if [[ -z "$browser_cmd" ]]; then + echo "No Chromium-based browser with --pack-extension found. Pass --browser or set CHROME_FOR_PACKING." >&2 + exit 1 +fi + +version="$(python - <<'PY' +import json +from pathlib import Path +print(json.loads(Path('extension/manifest.json').read_text())['version']) +PY +)" +out="${out:-dist/browser-cli-extension-webstore-verified-v${version}.crx}" + +npm run build:extension +python scripts/package_extension.py --webstore --out "dist/browser-cli-extension-webstore-v${version}.zip" >/dev/null + +staging="$PWD/dist/extension-package-webstore" +if [[ ! -d "$staging" ]]; then + echo "Missing webstore staging directory: $staging" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d)" +private_key="$tmp_dir/verified-crx-private-key.pem" +trap 'rm -rf "$tmp_dir"' EXIT + +gpg --decrypt --output "$private_key" "$key_gpg" +chmod 600 "$private_key" + +rm -f "$staging.crx" +"$browser_cmd" \ + --pack-extension="$staging" \ + --pack-extension-key="$private_key" \ + --no-message-box \ + --disable-gpu \ + --no-sandbox >/dev/null + +mkdir -p "$(dirname "$out")" +mv "$staging.crx" "$out" + +echo "$out" diff --git a/scripts/setup_verified_crx_key.sh b/scripts/setup_verified_crx_key.sh new file mode 100755 index 0000000..38b10f4 --- /dev/null +++ b/scripts/setup_verified_crx_key.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/setup_verified_crx_key.sh [--recipient GPG_RECIPIENT] [--out-dir DIR] + +Generates a dedicated RSA private key for Chrome Web Store verified CRX uploads, +encrypts it to your GPG key, and writes the public key material for the Chrome +Developer Dashboard. + +Chrome Web Store verified uploads require an RSA CRX signing key. A GPG/OpenPGP +key cannot be used directly for CRX signing, but it can protect the RSA private +key at rest. +EOF +} + +recipient="" +out_dir="secrets/verified-crx" + +while [[ $# -gt 0 ]]; do + case "$1" in + --recipient) + recipient="${2:-}" + shift 2 + ;; + --out-dir) + out_dir="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$recipient" ]]; then + recipient="$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1 == "uid" { print $10; exit }')" +fi + +if [[ -z "$recipient" ]]; then + echo "No GPG recipient found. Pass --recipient ''." >&2 + exit 1 +fi + +mkdir -p "$out_dir" +chmod 700 "$out_dir" + +private_key="$(mktemp)" +public_pem="$out_dir/chrome-webstore-verified-crx-public-key.pem" +public_der_b64="$out_dir/chrome-webstore-verified-crx-public-key.der.base64.txt" +encrypted_private="$out_dir/chrome-webstore-verified-crx-private-key.pem.gpg" +trap 'rm -f "$private_key"' EXIT + +if [[ -e "$encrypted_private" ]]; then + echo "Refusing to overwrite existing encrypted private key: $encrypted_private" >&2 + exit 1 +fi + +openssl genrsa -out "$private_key" 2048 >/dev/null 2>&1 +chmod 600 "$private_key" +openssl rsa -in "$private_key" -pubout -out "$public_pem" >/dev/null 2>&1 +openssl rsa -in "$private_key" -pubout -outform DER 2>/dev/null | base64 -w0 > "$public_der_b64" +printf '\n' >> "$public_der_b64" + +gpg --encrypt --recipient "$recipient" --output "$encrypted_private" "$private_key" +chmod 600 "$encrypted_private" + +cat < Package -> Verified uploads. +Keep the encrypted private key. Do not commit or upload the decrypted PEM. +EOF diff --git a/tests/test_api.py b/tests/test_api.py index 7fa690c..3a7af80 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -364,6 +364,27 @@ class TestTabs: profile=None, remote=None, key=None, ) + def test_tabs_close_by_ids_chunks_large_batches(self, b, mock_send): + mock_send.side_effect = [{"closed": 50}, {"closed": 50}, {"closed": 20}] + assert b.tabs.close(tab_ids=range(120), gentle_mode="normal") == 120 + assert mock_send.call_args_list == [ + call( + "tabs.close", + {"tabId": None, "tabIds": list(range(0, 50)), "inactive": False, "duplicates": False, "gentleMode": "normal"}, + profile=None, remote=None, key=None, + ), + call( + "tabs.close", + {"tabId": None, "tabIds": list(range(50, 100)), "inactive": False, "duplicates": False, "gentleMode": "normal"}, + profile=None, remote=None, key=None, + ), + call( + "tabs.close", + {"tabId": None, "tabIds": list(range(100, 120)), "inactive": False, "duplicates": False, "gentleMode": "normal"}, + profile=None, remote=None, key=None, + ), + ] + def test_tabs_move(self, b, mock_send): b.tabs.move(10, forward=True) mock_send.assert_called_once_with( diff --git a/uv.lock b/uv.lock index 0857078..7726b98 100644 --- a/uv.lock +++ b/uv.lock @@ -465,7 +465,7 @@ wheels = [ [[package]] name = "real-browser-cli" -version = "0.15.3" +version = "0.15.4" source = { editable = "." } dependencies = [ { name = "click" },