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.
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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]")
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user