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 ## 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,12 @@ 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.
--- ---
@@ -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. - **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.
--- ---
+27 -11
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,22 @@ 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]")
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)") if browser == "firefox":
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]") console.print(" 2. Click [bold]Load Temporary Add-on...[/bold]")
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)") console.print(f" 3. Select: [cyan]{Path(__file__).parent.parent.parent / 'extension' / 'manifest.json'}[/cyan]")
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n") 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 = { 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 +102,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
+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
+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.1",
"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",
+3 -3
View File
@@ -1,4 +1,4 @@
import { getSessions, runLargeOperation } from '../core'; 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
@@ -30,7 +30,7 @@ export class AutoSaveManager {
chrome.tabs.onAttached.removeListener(this.autoSaveHandler); chrome.tabs.onAttached.removeListener(this.autoSaveHandler);
chrome.tabs.onDetached.removeListener(this.autoSaveHandler); chrome.tabs.onDetached.removeListener(this.autoSaveHandler);
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler); 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); if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = null; this.autoSaveTimer = null;
this.autoSavePending = false; this.autoSavePending = false;
@@ -41,7 +41,7 @@ export class AutoSaveManager {
chrome.tabs.onAttached.addListener(this.autoSaveHandler); chrome.tabs.onAttached.addListener(this.autoSaveHandler);
chrome.tabs.onDetached.addListener(this.autoSaveHandler); chrome.tabs.onDetached.addListener(this.autoSaveHandler);
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler); chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler); tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
} }
return { enabled }; return { enabled };
} }
+4 -1
View File
@@ -105,7 +105,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;
+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 { 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,7 +17,7 @@ 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 chrome.tabs.query({});
return groups.map(g => ({ return groups.map(g => ({
id: g.id, id: g.id,
@@ -35,13 +35,13 @@ export class GroupsCommands extends CommandGroup {
} }
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));
} }
@@ -51,15 +51,15 @@ export class GroupsCommands extends CommandGroup {
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 chrome.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 };
} }
@@ -67,7 +67,7 @@ export class GroupsCommands extends CommandGroup {
const groupId = await resolveGroupId(group); const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId }); const existingTabs = await chrome.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true }); 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 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 =>
@@ -80,7 +80,7 @@ export class GroupsCommands extends CommandGroup {
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 chrome.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index); allTabs.sort((a, b) => a.index - b.index);
@@ -98,7 +98,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 +106,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 };
+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 { 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';
@@ -37,13 +37,13 @@ export class NavigationCommands extends CommandGroup {
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 chrome.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 }; 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'; import type { SessionTab, StoredSession } from '../types';
export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] { 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 }> { export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
const tabs = await chrome.tabs.query({}); const tabs = await chrome.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 = {
+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 { CommandGroup } from '../classes/CommandGroup';
import { AutoSaveManager } from './autosave'; import { AutoSaveManager } from './autosave';
import { captureCurrentSession } from './session-snapshot'; import { captureCurrentSession } from './session-snapshot';
@@ -91,8 +91,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),
+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 { 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';
@@ -64,7 +64,7 @@ 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 chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties);
if (groupId != null) { if (groupId != null) {
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId }); await groupTabs({ tabIds: asTabIds([tabId]), groupId });
} }
return { tabId }; return { tabId };
} }
+2 -1
View File
@@ -1,9 +1,10 @@
// 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;
+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';
+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", "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",
+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.1"
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()
+26 -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,31 @@ 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 "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 = []
@@ -122,6 +122,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 = (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(): 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
+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")
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.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },