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
|
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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]")
|
||||||
|
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(" 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" 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" 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")
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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",
|
"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
@@ -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" }
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user