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.
This commit is contained in:
2026-06-14 17:19:25 +02:00
parent 9df5e1bd8f
commit 9b8cefcd72
21 changed files with 225 additions and 62 deletions
+9 -8
View File
@@ -50,7 +50,7 @@ Every response:
## 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
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
uv tool install real-browser-cli
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`.
@@ -80,12 +80,12 @@ uv tool upgrade real-browser-cli
git clone <repo>
cd browser-cli
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:
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
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
@@ -515,11 +515,12 @@ 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 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: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.
---
@@ -527,8 +528,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.
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
- **Linux and macOS only** — Windows native messaging paths are not yet handled.
- **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.
- **Firefox support is experimental**. Basic tab/window/navigation/native-messaging support is wired, including tab-group APIs on supported Firefox versions.
---
+27 -11
View File
@@ -11,6 +11,7 @@ from rich.console import Console
from browser_cli.constants import (
ALLOWED_EXTENSION_IDS,
EXTENSION_ID,
FIREFOX_EXTENSION_ID,
NATIVE_HOST_DIRS,
NATIVE_HOST_NAME,
SUPPORTED_BROWSERS,
@@ -72,21 +73,22 @@ def cmd_install(browser):
"brave": "brave://extensions",
"edge": "edge://extensions",
"vivaldi": "vivaldi://extensions",
"firefox": "about:debugging#/runtime/this-firefox",
}[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(" 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" 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")
if browser == "firefox":
console.print(" 2. Click [bold]Load Temporary Add-on...[/bold]")
console.print(f" 3. Select: [cyan]{Path(__file__).parent.parent.parent / 'extension' / 'manifest.json'}[/cyan]")
console.print(f" 4. 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(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" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
manifest = {
"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],
}
manifest = _native_host_manifest(browser, host_exe)
installed = _install_manifest(browser, host_exe, manifest)
if not installed:
console.print("[red]Failed to install native host manifest[/red]")
@@ -100,6 +102,20 @@ def cmd_install(browser):
console.print("\n[green bold]✓ Installation complete![/green bold]")
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:
if is_windows():
manifest_dir = host_exe.parent
+7 -1
View File
@@ -16,8 +16,9 @@ DEFAULT_ALIAS = "default"
NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
WEBSTORE_EXTENSION_ID = "hekaebjhbhhdbmakimmaklbblbmccahp"
FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
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"
MAX_MSG_BYTES = 32 * 1024 * 1024
@@ -66,6 +67,10 @@ NATIVE_HOST_DIRS = {
"linux": [Path.home() / ".config/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 = {
@@ -74,6 +79,7 @@ WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
"edge": [r"Software\Microsoft\Edge\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
+7 -1
View File
@@ -1,8 +1,14 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.14.3",
"version": "0.15.1",
"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": [
"tabs",
"tabGroups",
+3 -3
View File
@@ -1,4 +1,4 @@
import { getSessions, runLargeOperation } from '../core';
import { getSessions, runLargeOperation, tabGroupsOnUpdated } from '../core';
import { captureCurrentSession } from './session-snapshot';
// Debounce window for autosave. A full-tab snapshot + storage write runs on
@@ -30,7 +30,7 @@ export class AutoSaveManager {
chrome.tabs.onAttached.removeListener(this.autoSaveHandler);
chrome.tabs.onDetached.removeListener(this.autoSaveHandler);
chrome.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);
this.autoSaveTimer = null;
this.autoSavePending = false;
@@ -41,7 +41,7 @@ export class AutoSaveManager {
chrome.tabs.onAttached.addListener(this.autoSaveHandler);
chrome.tabs.onDetached.addListener(this.autoSaveHandler);
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler);
tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
}
return { enabled };
}
+4 -1
View File
@@ -105,7 +105,10 @@ export class DomCommands extends CommandGroup {
const results = await executeScript({
target: { tabId: tab.id },
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],
});
return results[0]?.result ?? null;
+11 -11
View File
@@ -1,4 +1,4 @@
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, processInBatches, resolveGroupId, runLargeOperation, tabInfo } from '../core';
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, getTabGroup, groupTabs, moveTabGroup, processInBatches, queryTabGroups, resolveGroupId, runLargeOperation, tabInfo, ungroupTabs, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types';
@@ -17,7 +17,7 @@ export class GroupsCommands extends CommandGroup {
};
private async groupList() {
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
const all = await chrome.tabs.query({});
return groups.map(g => ({
id: g.id,
@@ -35,13 +35,13 @@ export class GroupsCommands extends CommandGroup {
}
private async groupCount() {
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
return groups.length;
}
private async groupQuery({ search }: GroupQueryArgs) {
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));
}
@@ -51,15 +51,15 @@ export class GroupsCommands extends CommandGroup {
const groupTabs = tabs.filter(t => t.groupId === groupId);
const tabIds = groupTabs.map(t => t.id);
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 };
});
}
private async groupOpen({ name }: GroupOpenArgs) {
const tab = await chrome.tabs.create({ active: true });
const groupId = await chrome.tabs.group({ tabIds: asTabIds([tab.id]) });
await chrome.tabGroups.update(groupId, { title: name });
const groupId = await groupTabs({ tabIds: asTabIds([tab.id]) });
await updateTabGroup(groupId, { title: name });
return { id: groupId, name };
}
@@ -67,7 +67,7 @@ export class GroupsCommands extends CommandGroup {
const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
await chrome.tabs.group({ tabIds: 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 (url) {
const placeholders = existingTabs.filter(t =>
@@ -80,7 +80,7 @@ export class GroupsCommands extends CommandGroup {
private async groupMove({ group, forward, backward }: GroupMoveArgs) {
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 });
allTabs.sort((a, b) => a.index - b.index);
@@ -98,7 +98,7 @@ export class GroupsCommands extends CommandGroup {
nextBlock.groupId === null
? currentBlock.startIndex + 1
: nextBlock.endIndex - currentLength + 1;
await chrome.tabGroups.move(groupId, { index: targetIndex });
await moveTabGroup(groupId, { index: targetIndex });
} else if (backward) {
const previousBlock = blocks[currentIdx - 1];
if (!previousBlock) return { groupId, moved: false };
@@ -106,7 +106,7 @@ export class GroupsCommands extends CommandGroup {
previousBlock.groupId === null
? currentBlock.startIndex - 1
: previousBlock.startIndex;
await chrome.tabGroups.move(groupId, { index: targetIndex });
await moveTabGroup(groupId, { index: targetIndex });
}
return { groupId, moved: true };
+4 -4
View File
@@ -1,4 +1,4 @@
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core';
import { getActiveTab, getAliases, groupTabs as groupTabIds, isBrowserErrorUrl, resolveGroupId, tabInfo, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types';
@@ -37,13 +37,13 @@ export class NavigationCommands extends CommandGroup {
t.id !== tab.id &&
(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));
} catch (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
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
groupId = await groupTabIds({ tabIds: [tab.id] });
await updateTabGroup(groupId, { title: String(groupNameOrId) });
}
}
return { id: tab.id, url: tab.url };
+2 -2
View File
@@ -1,4 +1,4 @@
import { normalizeGroupColor } from '../core';
import { normalizeGroupColor, queryTabGroups } from '../core';
import type { SessionTab, StoredSession } from '../types';
export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] {
@@ -28,7 +28,7 @@ export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tab
*/
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const session: StoredSession = {
+3 -3
View File
@@ -1,4 +1,4 @@
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, groupTabs, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, updateTabGroup, yieldForLargeOperation } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import { AutoSaveManager } from './autosave';
import { captureCurrentSession } from './session-snapshot';
@@ -91,8 +91,8 @@ export class SessionCommands extends CommandGroup {
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
for (const { meta, tabIds } of groups.values()) {
throwIfJobCancelled(__job);
const restoredGroupId = await chrome.tabs.group({ tabIds });
await chrome.tabGroups.update(restoredGroupId, {
const restoredGroupId = await groupTabs({ tabIds });
await updateTabGroup(restoredGroupId, {
title: meta.title || "",
color: normalizeGroupColor(meta.color),
collapsed: Boolean(meta.collapsed),
+2 -2
View File
@@ -1,4 +1,4 @@
import { asTabIds, getActiveTab, getLargeOperationThrottle, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
import { asTabIds, getActiveTab, getLargeOperationThrottle, groupTabs, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types';
@@ -64,7 +64,7 @@ export class TabsMutationCommands extends CommandGroup {
// `index` is always assigned by one of the branches above before this call.
await chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties);
if (groupId != null) {
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId });
await groupTabs({ tabIds: asTabIds([tabId]), groupId });
}
return { tabId };
}
+2 -1
View File
@@ -1,9 +1,10 @@
// Tab-group resolution and normalization helpers.
import { queryTabGroups } from './tab-groups';
export async function resolveGroupId(nameOrId: string | number): Promise<number> {
const asInt = parseInt(String(nameOrId));
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());
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
return match.id;
+1
View File
@@ -3,4 +3,5 @@ export * from './throttle';
export * from './scripting';
export * from './tab-helpers';
export * from './group-helpers';
export * from './tab-groups';
export * from './storage';
+55
View File
@@ -0,0 +1,55 @@
// 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 chrome.tabGroups {
const api = chrome["tabGroups" as keyof typeof chrome] as typeof chrome.tabGroups | undefined;
if (!api) throw new Error(TAB_GROUPS_UNSUPPORTED);
return api;
}
function tabsGroupApi(): typeof chrome.tabs.group {
const fn = chrome.tabs["group" as keyof typeof chrome.tabs] as typeof chrome.tabs.group | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(chrome.tabs);
}
function tabsUngroupApi(): typeof chrome.tabs.ungroup {
const fn = chrome.tabs["ungroup" as keyof typeof chrome.tabs] as typeof chrome.tabs.ungroup | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(chrome.tabs);
}
export async function queryTabGroups(queryInfo: chrome.tabGroups.QueryInfo = {}): Promise<chrome.tabGroups.TabGroup[]> {
const api = chrome["tabGroups" as keyof typeof chrome] as typeof chrome.tabGroups | undefined;
if (!api) return [];
return api.query(queryInfo);
}
export async function getTabGroup(groupId: number): Promise<chrome.tabGroups.TabGroup> {
return tabGroupsApi().get(groupId);
}
export async function updateTabGroup(groupId: number, updateProperties: chrome.tabGroups.UpdateProperties): Promise<chrome.tabGroups.TabGroup> {
return tabGroupsApi().update(groupId, updateProperties);
}
export async function moveTabGroup(groupId: number, moveProperties: chrome.tabGroups.MoveProperties): Promise<chrome.tabGroups.TabGroup> {
return tabGroupsApi().move(groupId, moveProperties);
}
export async function groupTabs(createProperties: chrome.tabs.GroupOptions): Promise<number> {
return tabsGroupApi()(createProperties);
}
export async function ungroupTabs(tabIds: [number, ...number[]]): Promise<void> {
return tabsUngroupApi()(tabIds);
}
export function tabGroupsOnUpdated(): chrome.events.Event<(group: chrome.tabGroups.TabGroup) => void> | undefined {
const api = chrome["tabGroups" as keyof typeof chrome] as typeof chrome.tabGroups | undefined;
return api?.onUpdated;
}
+2 -1
View File
@@ -8,7 +8,8 @@
"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",
"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": {
"@types/chrome": "^0.1.40",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "real-browser-cli"
version = "0.14.3"
version = "0.15.1"
description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md"
license = { file = "LICENSE" }
+21 -10
View File
@@ -1,10 +1,11 @@
#!/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
extension ID stays stable for native messaging. ``--webstore`` writes the same
runtime files but strips ``key`` from manifest.json because the Chrome Web Store
rejects that field.
Chromium extension ID stays stable for native messaging. ``--webstore`` writes
the same runtime files but strips ``key`` from manifest.json because the Chrome
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
@@ -26,10 +27,17 @@ RUNTIME_FILES = (
)
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"))
if webstore:
if webstore or firefox:
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
def _copy_tree(src: Path, dst: Path) -> None:
@@ -37,10 +45,12 @@ def _copy_tree(src: Path, dst: Path) -> None:
shutil.rmtree(dst)
shutil.copytree(src, dst)
def package_extension(*, webstore: bool = False, out: Path | None = None) -> Path:
manifest = _read_manifest(webstore)
def package_extension(*, webstore: bool = False, firefox: bool = False, out: Path | None = None) -> Path:
if webstore and firefox:
raise ValueError("--webstore and --firefox are mutually exclusive")
manifest = _read_manifest(webstore, firefox)
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"
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:
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("--firefox", action="store_true", help="build a Firefox-friendly extension zip")
parser.add_argument("--out", type=Path, default=None, help="output zip path")
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__":
main()
+26 -1
View File
@@ -87,7 +87,7 @@ def test_install_help_lists_supported_browsers():
result = CliRunner().invoke(main, ["install", "--help"])
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):
manifests = []
@@ -117,6 +117,31 @@ def test_install_writes_testing_and_webstore_allowed_origins(tmp_path):
assert "Testing 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 "Firefox extension ID" in result.output
def test_install_windows_registers_native_host(tmp_path):
writes = []
@@ -122,6 +122,22 @@ def test_tab_activation_open_and_merge_do_not_steal_audible_video_window():
assert "skippedAudibleWindows" 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 = (ROOT / "extension" / "background.js").read_text()
assert "chrome.tabGroups" not in background
assert "chrome.tabs.group" not in background
assert "chrome.tabs.ungroup" not in background
assert 'chrome["tabGroups"' in background
assert 'chrome.tabs["group"' in background
def test_built_extension_avoids_direct_eval_token_for_firefox_linter():
background = (ROOT / "extension" / "background.js").read_text()
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():
# The autosave lifecycle moved out of session.ts into a dedicated
# AutoSaveManager (autosave.ts) during the structure refactor; the shared
+21
View File
@@ -19,6 +19,9 @@ def _fake_extension(tmp_path: Path) -> Path:
"manifest_version": 3,
"name": "browser-cli",
"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",
}), encoding="utf-8")
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 "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):
packager = _packager_with_fake_extension(tmp_path)
out = packager.package_extension(webstore=False, out=tmp_path / "local.zip")
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]]
name = "real-browser-cli"
version = "0.14.3"
version = "0.15.1"
source = { editable = "." }
dependencies = [
{ name = "click" },