From 9b8cefcd72aa134a9eca688674b61beaa51309aa Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Sun, 14 Jun 2026 17:19:25 +0200 Subject: [PATCH] feat: add Firefox extension support - 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. --- README.md | 17 ++++--- browser_cli/commands/install.py | 38 +++++++++----- browser_cli/constants.py | 8 ++- extension/manifest.json | 8 ++- extension/src/commands/autosave.ts | 6 +-- extension/src/commands/dom.ts | 5 +- extension/src/commands/groups.ts | 22 ++++----- extension/src/commands/navigation.ts | 8 +-- extension/src/commands/session-snapshot.ts | 4 +- extension/src/commands/session.ts | 6 +-- extension/src/commands/tabs.ts | 4 +- extension/src/core/group-helpers.ts | 3 +- extension/src/core/index.ts | 1 + extension/src/core/tab-groups.ts | 55 +++++++++++++++++++++ package.json | 3 +- pyproject.toml | 2 +- scripts/package_extension.py | 31 ++++++++---- tests/test_cli.py | 27 +++++++++- tests/test_extension_error_page_handling.py | 16 ++++++ tests/test_extension_packaging.py | 21 ++++++++ uv.lock | 2 +- 21 files changed, 225 insertions(+), 62 deletions(-) create mode 100644 extension/src/core/tab-groups.ts diff --git a/README.md b/README.md index 4e3a397..4aaad90 100644 --- a/README.md +++ b/README.md @@ -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 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 `. -- **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. --- diff --git a/browser_cli/commands/install.py b/browser_cli/commands/install.py index 4b6f018..259922e 100644 --- a/browser_cli/commands/install.py +++ b/browser_cli/commands/install.py @@ -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 diff --git a/browser_cli/constants.py b/browser_cli/constants.py index af18531..c7f7609 100644 --- a/browser_cli/constants.py +++ b/browser_cli/constants.py @@ -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 diff --git a/extension/manifest.json b/extension/manifest.json index ac38f11..8b557c2 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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", diff --git a/extension/src/commands/autosave.ts b/extension/src/commands/autosave.ts index 9bb61dd..cedf2ae 100644 --- a/extension/src/commands/autosave.ts +++ b/extension/src/commands/autosave.ts @@ -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 }; } diff --git a/extension/src/commands/dom.ts b/extension/src/commands/dom.ts index 63b6768..ec324f8 100644 --- a/extension/src/commands/dom.ts +++ b/extension/src/commands/dom.ts @@ -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; diff --git a/extension/src/commands/groups.ts b/extension/src/commands/groups.ts index a1d212a..b088916 100644 --- a/extension/src/commands/groups.ts +++ b/extension/src/commands/groups.ts @@ -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 }; diff --git a/extension/src/commands/navigation.ts b/extension/src/commands/navigation.ts index 2176bf8..73eb90f 100644 --- a/extension/src/commands/navigation.ts +++ b/extension/src/commands/navigation.ts @@ -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 }; diff --git a/extension/src/commands/session-snapshot.ts b/extension/src/commands/session-snapshot.ts index b21d1cc..5340745 100644 --- a/extension/src/commands/session-snapshot.ts +++ b/extension/src/commands/session-snapshot.ts @@ -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 = { diff --git a/extension/src/commands/session.ts b/extension/src/commands/session.ts index 66fa619..52f0b34 100644 --- a/extension/src/commands/session.ts +++ b/extension/src/commands/session.ts @@ -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), diff --git a/extension/src/commands/tabs.ts b/extension/src/commands/tabs.ts index 620d7f7..435178e 100644 --- a/extension/src/commands/tabs.ts +++ b/extension/src/commands/tabs.ts @@ -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 }; } diff --git a/extension/src/core/group-helpers.ts b/extension/src/core/group-helpers.ts index 22fdccb..5782dac 100644 --- a/extension/src/core/group-helpers.ts +++ b/extension/src/core/group-helpers.ts @@ -1,9 +1,10 @@ // Tab-group resolution and normalization helpers. +import { queryTabGroups } from './tab-groups'; export async function resolveGroupId(nameOrId: string | number): Promise { 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; diff --git a/extension/src/core/index.ts b/extension/src/core/index.ts index 6889c97..105b37a 100644 --- a/extension/src/core/index.ts +++ b/extension/src/core/index.ts @@ -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'; diff --git a/extension/src/core/tab-groups.ts b/extension/src/core/tab-groups.ts new file mode 100644 index 0000000..544cd45 --- /dev/null +++ b/extension/src/core/tab-groups.ts @@ -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 { + 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 { + return tabGroupsApi().get(groupId); +} + +export async function updateTabGroup(groupId: number, updateProperties: chrome.tabGroups.UpdateProperties): Promise { + return tabGroupsApi().update(groupId, updateProperties); +} + +export async function moveTabGroup(groupId: number, moveProperties: chrome.tabGroups.MoveProperties): Promise { + return tabGroupsApi().move(groupId, moveProperties); +} + +export async function groupTabs(createProperties: chrome.tabs.GroupOptions): Promise { + return tabsGroupApi()(createProperties); +} + +export async function ungroupTabs(tabIds: [number, ...number[]]): Promise { + 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; +} diff --git a/package.json b/package.json index db1cb67..fd731b0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pyproject.toml b/pyproject.toml index 09d25a1..f967b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/scripts/package_extension.py b/scripts/package_extension.py index ce6107c..0f8f35c 100644 --- a/scripts/package_extension.py +++ b/scripts/package_extension.py @@ -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() diff --git a/tests/test_cli.py b/tests/test_cli.py index 19ff608..5bee1cf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 = [] diff --git a/tests/test_extension_error_page_handling.py b/tests/test_extension_error_page_handling.py index e847a90..623625e 100644 --- a/tests/test_extension_error_page_handling.py +++ b/tests/test_extension_error_page_handling.py @@ -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 diff --git a/tests/test_extension_packaging.py b/tests/test_extension_packaging.py index 1c7cf3c..92bcfad 100644 --- a/tests/test_extension_packaging.py +++ b/tests/test_extension_packaging.py @@ -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") diff --git a/uv.lock b/uv.lock index 65a37c4..cb59c8d 100644 --- a/uv.lock +++ b/uv.lock @@ -465,7 +465,7 @@ wheels = [ [[package]] name = "real-browser-cli" -version = "0.14.3" +version = "0.15.1" source = { editable = "." } dependencies = [ { name = "click" },