chore: prepare verified CRX uploads and release 0.15.4
Testing / remote-protocol-compat (0.9.5) (push) Successful in 36s
Package Extension / package-extension (push) Successful in 33s
Build & Publish Package / publish (push) Successful in 31s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 32s
Testing / test (push) Successful in 36s

- 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.
This commit is contained in:
2026-06-17 16:54:20 +02:00
parent 0ac652beee
commit 371b794170
10 changed files with 247 additions and 7 deletions
+5
View File
@@ -5,6 +5,11 @@ extension/test-dist/
node_modules/ node_modules/
dist/ dist/
# Local secrets / signing keys
secrets/
*.pem
*.pem.gpg
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
+10 -1
View File
@@ -517,10 +517,19 @@ Packaging:
```bash ```bash
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium 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:webstore:verified # Chrome Web Store CRX signed for verified uploads
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
``` ```
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For Firefox, use the `*-firefox-*` zip. Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key:
```bash
scripts/setup_verified_crx_key.sh --recipient '<your GPG key id or email>'
# Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads.
npm run package:extension:webstore:verified
```
The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip.
For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons. 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.
+19
View File
@@ -6,6 +6,11 @@ from collections.abc import Callable, Iterable
from browser_cli.models import BrowserCounts, Tab from browser_cli.models import BrowserCounts, Tab
from browser_cli.sdk.base import Namespace 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): class TabsNS(Namespace):
"""List, open, close, move, and inspect browser tabs.""" """List, open, close, move, and inspect browser tabs."""
@@ -75,6 +80,20 @@ class TabsNS(Namespace):
ids = None ids = None
if tab_ids is not None: if tab_ids is not None:
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids] 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", { result = self.command("tabs.close", {
"tabId": tab_id, "tabId": tab_id,
"tabIds": ids, "tabIds": ids,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.15.3", "version": "0.15.4",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+1
View File
@@ -9,6 +9,7 @@
"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:webstore:verified": "scripts/package_verified_crx.sh",
"package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox" "package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.3" version = "0.15.4"
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" }
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/package_verified_crx.sh [--key FILE.gpg] [--browser COMMAND] [--out FILE.crx]
Builds the Chrome Web Store package and creates a CRX signed with the dedicated
verified-upload RSA key. The RSA private key is expected to be GPG-encrypted.
Environment alternatives:
VERIFIED_CRX_KEY_GPG Path to encrypted RSA private key
CHROME_FOR_PACKING Browser command with --pack-extension support
EOF
}
key_gpg="${VERIFIED_CRX_KEY_GPG:-secrets/verified-crx/chrome-webstore-verified-crx-private-key.pem.gpg}"
browser_cmd="${CHROME_FOR_PACKING:-}"
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--key)
key_gpg="${2:-}"
shift 2
;;
--browser)
browser_cmd="${2:-}"
shift 2
;;
--out)
out="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ ! -f "$key_gpg" ]]; then
echo "Encrypted verified CRX key not found: $key_gpg" >&2
echo "Create it with: scripts/setup_verified_crx_key.sh --recipient '<your GPG key>'" >&2
exit 1
fi
if [[ -z "$browser_cmd" ]]; then
for candidate in google-chrome chrome chromium chromium-browser brave-browser brave; do
if command -v "$candidate" >/dev/null 2>&1; then
browser_cmd="$candidate"
break
fi
done
fi
if [[ -z "$browser_cmd" ]]; then
echo "No Chromium-based browser with --pack-extension found. Pass --browser or set CHROME_FOR_PACKING." >&2
exit 1
fi
version="$(python - <<'PY'
import json
from pathlib import Path
print(json.loads(Path('extension/manifest.json').read_text())['version'])
PY
)"
out="${out:-dist/browser-cli-extension-webstore-verified-v${version}.crx}"
npm run build:extension
python scripts/package_extension.py --webstore --out "dist/browser-cli-extension-webstore-v${version}.zip" >/dev/null
staging="$PWD/dist/extension-package-webstore"
if [[ ! -d "$staging" ]]; then
echo "Missing webstore staging directory: $staging" >&2
exit 1
fi
tmp_dir="$(mktemp -d)"
private_key="$tmp_dir/verified-crx-private-key.pem"
trap 'rm -rf "$tmp_dir"' EXIT
gpg --decrypt --output "$private_key" "$key_gpg"
chmod 600 "$private_key"
rm -f "$staging.crx"
"$browser_cmd" \
--pack-extension="$staging" \
--pack-extension-key="$private_key" \
--no-message-box \
--disable-gpu \
--no-sandbox >/dev/null
mkdir -p "$(dirname "$out")"
mv "$staging.crx" "$out"
echo "$out"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/setup_verified_crx_key.sh [--recipient GPG_RECIPIENT] [--out-dir DIR]
Generates a dedicated RSA private key for Chrome Web Store verified CRX uploads,
encrypts it to your GPG key, and writes the public key material for the Chrome
Developer Dashboard.
Chrome Web Store verified uploads require an RSA CRX signing key. A GPG/OpenPGP
key cannot be used directly for CRX signing, but it can protect the RSA private
key at rest.
EOF
}
recipient=""
out_dir="secrets/verified-crx"
while [[ $# -gt 0 ]]; do
case "$1" in
--recipient)
recipient="${2:-}"
shift 2
;;
--out-dir)
out_dir="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "$recipient" ]]; then
recipient="$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1 == "uid" { print $10; exit }')"
fi
if [[ -z "$recipient" ]]; then
echo "No GPG recipient found. Pass --recipient '<key id or email>'." >&2
exit 1
fi
mkdir -p "$out_dir"
chmod 700 "$out_dir"
private_key="$(mktemp)"
public_pem="$out_dir/chrome-webstore-verified-crx-public-key.pem"
public_der_b64="$out_dir/chrome-webstore-verified-crx-public-key.der.base64.txt"
encrypted_private="$out_dir/chrome-webstore-verified-crx-private-key.pem.gpg"
trap 'rm -f "$private_key"' EXIT
if [[ -e "$encrypted_private" ]]; then
echo "Refusing to overwrite existing encrypted private key: $encrypted_private" >&2
exit 1
fi
openssl genrsa -out "$private_key" 2048 >/dev/null 2>&1
chmod 600 "$private_key"
openssl rsa -in "$private_key" -pubout -out "$public_pem" >/dev/null 2>&1
openssl rsa -in "$private_key" -pubout -outform DER 2>/dev/null | base64 -w0 > "$public_der_b64"
printf '\n' >> "$public_der_b64"
gpg --encrypt --recipient "$recipient" --output "$encrypted_private" "$private_key"
chmod 600 "$encrypted_private"
cat <<EOF
Created verified CRX upload key material:
encrypted private key: $encrypted_private
public key PEM: $public_pem
public key DER/base64: $public_der_b64
Use the public key in the Chrome Developer Dashboard -> Package -> Verified uploads.
Keep the encrypted private key. Do not commit or upload the decrypted PEM.
EOF
+21
View File
@@ -364,6 +364,27 @@ class TestTabs:
profile=None, remote=None, key=None, 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): def test_tabs_move(self, b, mock_send):
b.tabs.move(10, forward=True) b.tabs.move(10, forward=True)
mock_send.assert_called_once_with( mock_send.assert_called_once_with(
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.3" version = "0.15.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },