Compare commits

..

4 Commits

Author SHA1 Message Date
daniel156161 477a00db1a feat(extension): add Firefox WebExtension support
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 47s
Build & Publish Package / publish (push) Successful in 46s
Package Extension / package-extension (push) Successful in 59s
Testing / test (push) Failing after 50s
- Add a neutral WebExtension API adapter that uses Firefox browser.* or Chromium chrome.* without mutating globals.
- Switch extension runtime code to the adapter and add Firefox-specific typings for tabs, windows, tab groups, storage, scripting, and native messaging ports.
- Fix Firefox temporary add-on instructions to load the packaged manifest with background.scripts instead of the Chromium service worker manifest.
- Detect Firefox in clients.list via runtime.getBrowserInfo and keep Chromium user-agent fallback support.
- Make navigate.open wait briefly for Firefox to replace initial about:blank with the requested URL.
- Add JS coverage for API selection, clients.list browser detection, and Firefox navigate.open URL polling.
- Bump package and extension version to 0.15.2.
2026-06-14 19:09:10 +02:00
daniel156161 523108e442 test: skip generated extension bundle checks when missing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 45s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 57s
- Add a shared helper for reading the built extension background bundle.
- Skip the Firefox/WebExtension linter assertions when the generated bundle is absent.
- Keep fresh checkouts able to run pytest without building gitignored artifacts first.
2026-06-14 17:26:20 +02:00
daniel156161 9b8cefcd72 feat: add Firefox extension support
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / test (push) Failing after 53s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
- Add Firefox as an install target with native messaging manifest support.
- Generate Firefox-specific extension packages with Gecko metadata and AMO-compatible manifest transforms.
- Keep tab group commands available in Firefox through dynamic tab group API helpers.
- Avoid Firefox linter warnings for static tab group API references and direct eval tokens.
- Add Firefox packaging and installer regression coverage.
- Bump the package and extension version to 0.15.1.
2026-06-14 17:19:25 +02:00
daniel156161 9df5e1bd8f chore(release): publish as real-browser-cli
Testing / remote-protocol-compat (0.9.5) (push) Successful in 40s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Package Extension / package-extension (push) Successful in 32s
Build & Publish Package / publish (push) Successful in 25s
Testing / test (push) Successful in 31s
- Rename the PyPI distribution from browser-cli to real-browser-cli after PyPI rejected the original name as too similar to an existing project.
- Keep the installed console command as browser-cli so user-facing CLI usage remains unchanged.
- Add README-based package metadata, author information, and project URLs so PyPI renders a proper project description.
- Centralize the PyPI distribution name for importlib.metadata version lookups used by the CLI, doctor command, and remote user agent.
- Document uv tool install, optional fast extra installation, and upgrade commands.
- Bump package and extension metadata to 0.14.3 for the republished release.
2026-06-14 16:18:12 +02:00
44 changed files with 807 additions and 260 deletions
+34 -7
View File
@@ -50,18 +50,42 @@ 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
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, firefox
```
The PyPI package is named `real-browser-cli`; the installed command is still `browser-cli`.
For better remote-response compression, install the optional `fast` extra:
```sh
uv tool install "real-browser-cli[fast]"
```
To upgrade later:
```sh
uv tool upgrade real-browser-cli
```
### Install from source
```sh ```sh
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`
@@ -491,11 +515,14 @@ 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.
For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons.
--- ---
@@ -503,8 +530,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.
--- ---
+2 -1
View File
@@ -35,6 +35,7 @@ from browser_cli.commands.serve_http import cmd_serve_http
from browser_cli.commands.watch import watch_group from browser_cli.commands.watch import watch_group
from browser_cli.commands.workspace import workspace_group from browser_cli.commands.workspace import workspace_group
from browser_cli.commands.raw import cmd_command from browser_cli.commands.raw import cmd_command
from browser_cli.constants import PYPI_PACKAGE_NAME
console = Console() console = Console()
@@ -63,7 +64,7 @@ def _project_version() -> str:
pass pass
try: try:
return package_version("browser-cli") return package_version(PYPI_PACKAGE_NAME)
except PackageNotFoundError: except PackageNotFoundError:
return "unknown" return "unknown"
+2 -2
View File
@@ -11,7 +11,7 @@ from rich.table import Table
from browser_cli.commands import handle_errors, client_from_ctx from browser_cli.commands import handle_errors, client_from_ctx
from browser_cli.client import active_browser_targets from browser_cli.client import active_browser_targets
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME, PYPI_PACKAGE_NAME
from browser_cli.platform import is_windows from browser_cli.platform import is_windows
console = Console() console = Console()
@@ -26,7 +26,7 @@ def _project_version() -> str:
except OSError: except OSError:
pass pass
try: try:
return package_version("browser-cli") return package_version(PYPI_PACKAGE_NAME)
except PackageNotFoundError: except PackageNotFoundError:
return "unknown" return "unknown"
+32 -11
View File
@@ -11,6 +11,7 @@ from rich.console import Console
from browser_cli.constants import ( from browser_cli.constants import (
ALLOWED_EXTENSION_IDS, ALLOWED_EXTENSION_IDS,
EXTENSION_ID, EXTENSION_ID,
FIREFOX_EXTENSION_ID,
NATIVE_HOST_DIRS, NATIVE_HOST_DIRS,
NATIVE_HOST_NAME, NATIVE_HOST_NAME,
SUPPORTED_BROWSERS, SUPPORTED_BROWSERS,
@@ -72,21 +73,27 @@ def cmd_install(browser):
"brave": "brave://extensions", "brave": "brave://extensions",
"edge": "edge://extensions", "edge": "edge://extensions",
"vivaldi": "vivaldi://extensions", "vivaldi": "vivaldi://extensions",
"firefox": "about:debugging#/runtime/this-firefox",
}[browser] }[browser]
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser") console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]") console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)") if browser == "firefox":
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]") repo_root = Path(__file__).parent.parent.parent
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)") firefox_manifest = repo_root / "dist" / "extension-package-firefox" / "manifest.json"
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n") console.print(" 2. Build the Firefox-compatible temporary extension:")
console.print(" [cyan]npm run package:extension:firefox[/cyan]")
console.print(" 3. Click [bold]Load Temporary Add-on...[/bold]")
console.print(f" 4. Select: [cyan]{firefox_manifest}[/cyan]")
console.print(" Do not select extension/manifest.json; Firefox currently rejects background.service_worker there.")
console.print(f" 5. Firefox extension ID is [cyan]{FIREFOX_EXTENSION_ID}[/cyan]")
console.print(" Note: Firefox support is experimental; tab-group commands require browser tab group APIs.\n")
else:
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
manifest = { manifest = _native_host_manifest(browser, host_exe)
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(host_exe),
"type": "stdio",
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
}
installed = _install_manifest(browser, host_exe, manifest) installed = _install_manifest(browser, host_exe, manifest)
if not installed: if not installed:
console.print("[red]Failed to install native host manifest[/red]") console.print("[red]Failed to install native host manifest[/red]")
@@ -100,6 +107,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
+8 -1
View File
@@ -9,14 +9,16 @@ import os
from pathlib import Path from pathlib import Path
APP_NAME = "browser-cli" APP_NAME = "browser-cli"
PYPI_PACKAGE_NAME = "real-browser-cli"
RUNTIME_DIRNAME = ".browser_cli" RUNTIME_DIRNAME = ".browser_cli"
DEFAULT_ALIAS = "default" 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
@@ -65,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 = {
@@ -73,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
+2 -2
View File
@@ -1,6 +1,6 @@
from importlib.metadata import version as _pkg_version from importlib.metadata import version as _pkg_version
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT, PYPI_PACKAGE_NAME
def parse_version(v: str) -> tuple[int, ...]: def parse_version(v: str) -> tuple[int, ...]:
try: try:
@@ -10,7 +10,7 @@ def parse_version(v: str) -> tuple[int, ...]:
def get_installed_version() -> str: def get_installed_version() -> str:
try: try:
return _pkg_version("browser-cli") return _pkg_version(PYPI_PACKAGE_NAME)
except Exception: except Exception:
return "0.0.0" return "0.0.0"
+7 -1
View File
@@ -1,8 +1,14 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.14.2", "version": "0.15.2",
"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",
+31
View File
@@ -0,0 +1,31 @@
/**
* Cross-browser WebExtension API entry point.
*
* Firefox exposes the Promise-based WebExtension API as `browser.*`.
* Chromium exposes the same extension API as `chrome.*`.
* Runtime modules import this neutral adapter as `api`, so Firefox uses its
* native `browser` object and Chromium uses its native `chrome` object. No
* browser-specific global is faked or overwritten.
*/
import type { WebExtensionApi } from './types';
type WebExtensionGlobal = {
browser?: typeof browser;
chrome?: typeof chrome;
};
function currentApi(): typeof browser | typeof chrome {
const webExtensionGlobal = globalThis as object as WebExtensionGlobal;
const api = webExtensionGlobal.browser || webExtensionGlobal.chrome;
if (!api) {
throw new Error("WebExtension API is not available: expected browser.* or chrome.*");
}
return api;
}
export const webExtApi = new Proxy({}, {
get(_target: object, property: string | symbol) {
return currentApi()[property as keyof ReturnType<typeof currentApi>];
},
}) as object as WebExtensionApi;
+2 -1
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { CommandGroup } from './CommandGroup'; import { CommandGroup } from './CommandGroup';
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup'; import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
import { NavigationCommands } from '../commands/navigation'; import { NavigationCommands } from '../commands/navigation';
@@ -74,7 +75,7 @@ export class CommandRegistry {
/** /**
* Builds the registry and registers every command group. The SessionCommands * Builds the registry and registers every command group. The SessionCommands
* instance is returned alongside because index.ts wires its lifecycle methods * instance is returned alongside because index.ts wires its lifecycle methods
* (chrome.tabs.onActivated activateLazyTab) and NativeConnection references it * (api.tabs.onActivated activateLazyTab) and NativeConnection references it
* for the clients.rename_profile reconnect side-effect. * for the clients.rename_profile reconnect side-effect.
*/ */
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } { export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
+6 -5
View File
@@ -1,7 +1,8 @@
import { webExtApi as api } from '../browser-api';
/** /**
* Background-job retention helpers + the JobManager that owns the live job map. * Background-job retention helpers + the JobManager that owns the live job map.
* *
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of chrome.* / * `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of api.* /
* service-worker side effects so the retention logic (memory-leak guard) can be * service-worker side effects so the retention logic (memory-leak guard) can be
* unit-tested in isolation. * unit-tested in isolation.
*/ */
@@ -16,7 +17,7 @@ export const MAX_FINISHED_JOBS = 20;
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a // Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
// dead tab), finalize the job as an error so its persist interval stops instead // dead tab), finalize the job as an error so its persist interval stops instead
// of writing to chrome.storage.local every second forever. // of writing to api.storage.local every second forever.
export const JOB_TIMEOUT_MS = 5 * 60 * 1000; export const JOB_TIMEOUT_MS = 5 * 60 * 1000;
/** /**
@@ -65,11 +66,11 @@ export class JobManager {
const running = all.filter(job => job.status === "running"); const running = all.filter(job => job.status === "running");
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS); const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest); const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest);
await chrome.storage.local.set({ recentJobs }); await api.storage.local.set({ recentJobs });
} }
// Evict the oldest finished jobs once their count exceeds the retention cap. // Evict the oldest finished jobs once their count exceeds the retention cap.
// Recent finished jobs remain queryable via chrome.storage.local (persistJobs) // Recent finished jobs remain queryable via api.storage.local (persistJobs)
// even after eviction from the in-memory Map. // even after eviction from the in-memory Map.
private pruneJobs() { private pruneJobs() {
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS); pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
@@ -143,7 +144,7 @@ export class JobManager {
async status({ jobId }: { jobId?: string }) { async status({ jobId }: { jobId?: string }) {
const job = this.jobs.get(jobId); const job = this.jobs.get(jobId);
if (job) return { ...job }; if (job) return { ...job };
const { recentJobs } = await chrome.storage.local.get<{ recentJobs?: Job[] }>("recentJobs"); const { recentJobs } = await api.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
const stored = (recentJobs || []).find(entry => entry.id === jobId); const stored = (recentJobs || []).find(entry => entry.id === jobId);
if (!stored) throw new Error(`Job '${jobId}' not found`); if (!stored) throw new Error(`Job '${jobId}' not found`);
return stored; return stored;
+15 -14
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
/** /**
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound * Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
* message router that hands commands to the CommandRegistry. * message router that hands commands to the CommandRegistry.
@@ -6,7 +7,7 @@
import { getErrorMessage, getProfileAlias } from '../core'; import { getErrorMessage, getProfileAlias } from '../core';
import type { CommandRegistry } from './CommandRegistry'; import type { CommandRegistry } from './CommandRegistry';
import type { SessionCommands } from '../commands/session'; import type { SessionCommands } from '../commands/session';
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable } from '../types'; import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable, RuntimePort } from '../types';
const NATIVE_HOST = "com.browsercli.host"; const NATIVE_HOST = "com.browsercli.host";
const DEBUG_LOG = false; const DEBUG_LOG = false;
@@ -16,7 +17,7 @@ function debugLog(...args: Serializable[]) {
} }
export class NativeConnection { export class NativeConnection {
private port: chrome.runtime.Port | null = null; private port: RuntimePort | null = null;
private keepaliveEnabled = true; private keepaliveEnabled = true;
constructor( constructor(
@@ -26,17 +27,17 @@ export class NativeConnection {
/** Registers all runtime listeners and opens the initial connection. */ /** Registers all runtime listeners and opens the initial connection. */
start() { start() {
chrome.runtime.onInstalled.addListener(() => this.connect()); api.runtime.onInstalled.addListener(() => this.connect());
chrome.runtime.onStartup.addListener(() => this.connect()); api.runtime.onStartup.addListener(() => this.connect());
chrome.runtime.onSuspend.addListener(() => { api.runtime.onSuspend.addListener(() => {
this.disconnectPort({ sendBye: true }); this.disconnectPort({ sendBye: true });
}); });
chrome.windows.onCreated.addListener(() => { api.windows.onCreated.addListener(() => {
this.keepaliveEnabled = true; this.keepaliveEnabled = true;
if (!this.port) this.connect(); if (!this.port) this.connect();
}); });
chrome.windows.onRemoved.addListener(async () => { api.windows.onRemoved.addListener(async () => {
const windows = await chrome.windows.getAll({}); const windows = await api.windows.getAll({});
if (windows.length > 0) return; if (windows.length > 0) return;
this.keepaliveEnabled = false; this.keepaliveEnabled = false;
@@ -46,15 +47,15 @@ export class NativeConnection {
// Reconnect poll — wakes the worker to re-establish the native port if it // Reconnect poll — wakes the worker to re-establish the native port if it
// dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4) // dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4)
// are silently clamped and log a warning, so we set it explicitly. // are silently clamped and log a warning, so we set it explicitly.
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 }); api.alarms.create("keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => { api.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") { if (alarm.name === "keepalive") {
if (!this.port && this.keepaliveEnabled) this.connect(); if (!this.port && this.keepaliveEnabled) this.connect();
} }
}); });
} }
private sendControlMessage(targetPort: chrome.runtime.Port | null, message: ControlMessage) { private sendControlMessage(targetPort: RuntimePort | null, message: ControlMessage) {
if (!targetPort) return; if (!targetPort) return;
try { try {
targetPort.postMessage(message); targetPort.postMessage(message);
@@ -63,7 +64,7 @@ export class NativeConnection {
} }
} }
private sendResponse(targetPort: chrome.runtime.Port | null, message: ResponseMessage) { private sendResponse(targetPort: RuntimePort | null, message: ResponseMessage) {
if (!targetPort) return; if (!targetPort) return;
try { try {
targetPort.postMessage(message); targetPort.postMessage(message);
@@ -90,12 +91,12 @@ export class NativeConnection {
private async connect() { private async connect() {
if (this.port || !this.keepaliveEnabled) return; if (this.port || !this.keepaliveEnabled) return;
try { try {
const nativePort = chrome.runtime.connectNative(NATIVE_HOST); const nativePort = api.runtime.connectNative(NATIVE_HOST);
this.port = nativePort; this.port = nativePort;
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg)); nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
nativePort.onDisconnect.addListener(() => { nativePort.onDisconnect.addListener(() => {
if (this.port === nativePort) this.port = null; if (this.port === nativePort) this.port = null;
const err = chrome.runtime.lastError; const err = api.runtime.lastError;
if (err) console.warn("[browser-cli] Native host disconnected:", err.message); if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
}); });
// Send hello so native host knows which profile/alias this is // Send hello so native host knows which profile/alias this is
+23 -21
View File
@@ -1,4 +1,6 @@
import { getSessions, runLargeOperation } from '../core'; import type { TabUpdateInfo } from '../types';
import { webExtApi as api } from '../browser-api';
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
@@ -16,44 +18,44 @@ export class AutoSaveManager {
readonly autoSaveHandler = async (): Promise<void> => { readonly autoSaveHandler = async (): Promise<void> => {
await this.scheduleAutoSave(); await this.scheduleAutoSave();
}; };
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise<void> => { readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: TabUpdateInfo = {}): Promise<void> => {
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure. // Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
if (!("url" in changeInfo)) return; if (!("url" in changeInfo)) return;
await this.scheduleAutoSave(); await this.scheduleAutoSave();
}; };
async setEnabled(enabled: boolean) { async setEnabled(enabled: boolean) {
await chrome.storage.local.set({ autoSave: enabled }); await api.storage.local.set({ autoSave: enabled });
chrome.tabs.onCreated.removeListener(this.autoSaveHandler); api.tabs.onCreated.removeListener(this.autoSaveHandler);
chrome.tabs.onRemoved.removeListener(this.autoSaveHandler); api.tabs.onRemoved.removeListener(this.autoSaveHandler);
chrome.tabs.onMoved.removeListener(this.autoSaveHandler); api.tabs.onMoved.removeListener(this.autoSaveHandler);
chrome.tabs.onAttached.removeListener(this.autoSaveHandler); api.tabs.onAttached.removeListener(this.autoSaveHandler);
chrome.tabs.onDetached.removeListener(this.autoSaveHandler); api.tabs.onDetached.removeListener(this.autoSaveHandler);
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler); api.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;
if (enabled) { if (enabled) {
chrome.tabs.onCreated.addListener(this.autoSaveHandler); api.tabs.onCreated.addListener(this.autoSaveHandler);
chrome.tabs.onRemoved.addListener(this.autoSaveHandler); api.tabs.onRemoved.addListener(this.autoSaveHandler);
chrome.tabs.onMoved.addListener(this.autoSaveHandler); api.tabs.onMoved.addListener(this.autoSaveHandler);
chrome.tabs.onAttached.addListener(this.autoSaveHandler); api.tabs.onAttached.addListener(this.autoSaveHandler);
chrome.tabs.onDetached.addListener(this.autoSaveHandler); api.tabs.onDetached.addListener(this.autoSaveHandler);
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler); api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler); tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
} }
return { enabled }; return { enabled };
} }
private async saveAutoSessionIfChanged() { private async saveAutoSessionIfChanged() {
const { session, signature, tabCount } = await captureCurrentSession(); const { session, signature, tabCount } = await captureCurrentSession();
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature"); const { autoSaveSignature } = await api.storage.local.get("autoSaveSignature");
if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount }; if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount };
const sessions = await getSessions(); const sessions = await getSessions();
sessions.__auto__ = session; sessions.__auto__ = session;
await chrome.storage.local.set({ sessions, autoSaveSignature: signature }); await api.storage.local.set({ sessions, autoSaveSignature: signature });
return { skipped: false, tabs: tabCount }; return { skipped: false, tabs: tabCount };
} }
@@ -64,7 +66,7 @@ export class AutoSaveManager {
} }
this.autoSaveInFlight = true; this.autoSaveInFlight = true;
try { try {
const { autoSave } = await chrome.storage.local.get("autoSave"); const { autoSave } = await api.storage.local.get("autoSave");
if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged()); if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged());
} finally { } finally {
this.autoSaveInFlight = false; this.autoSaveInFlight = false;
@@ -76,7 +78,7 @@ export class AutoSaveManager {
} }
private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) { private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) {
const { autoSave } = await chrome.storage.local.get("autoSave"); const { autoSave } = await api.storage.local.get("autoSave");
if (!autoSave) return; if (!autoSave) return;
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer); if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs); this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs);
+7 -2
View File
@@ -1,9 +1,11 @@
import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core'; import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } 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 { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types'; import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types';
function fallbackForErrorPageDomOp(funcName: string, tab: chrome.tabs.Tab): Serializable { function fallbackForErrorPageDomOp(funcName: string, tab: Tab): Serializable {
switch (funcName) { switch (funcName) {
case "domExists": case "domExists":
return false; return false;
@@ -105,7 +107,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;
+4 -3
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -5,7 +6,7 @@ export class ExtensionCommands extends CommandGroup {
readonly namespace = "extension"; readonly namespace = "extension";
readonly commands: Record<string, CommandEntry> = { readonly commands: Record<string, CommandEntry> = {
"extension.reload": () => { "extension.reload": () => {
setTimeout(() => chrome.runtime.reload(), 200); setTimeout(() => api.runtime.reload(), 200);
return { reloading: true }; return { reloading: true };
}, },
"extension.info": () => this.extensionInfo(), "extension.info": () => this.extensionInfo(),
@@ -29,9 +30,9 @@ export class ExtensionCommands extends CommandGroup {
} }
private extensionInfo() { private extensionInfo() {
const manifest = chrome.runtime.getManifest(); const manifest = api.runtime.getManifest();
return { return {
id: chrome.runtime.id, id: api.runtime.id,
name: manifest.name, name: manifest.name,
version: manifest.version, version: manifest.version,
manifestVersion: manifest.manifest_version, manifestVersion: manifest.manifest_version,
+20 -19
View File
@@ -1,4 +1,5 @@
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, processInBatches, resolveGroupId, runLargeOperation, tabInfo } from '../core'; import { webExtApi as api } from '../browser-api';
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,8 +18,8 @@ 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 api.tabs.query({});
return groups.map(g => ({ return groups.map(g => ({
id: g.id, id: g.id,
title: g.title, title: g.title,
@@ -30,58 +31,58 @@ export class GroupsCommands extends CommandGroup {
} }
private async groupTabs({ groupId }: GroupTabsArgs) { private async groupTabs({ groupId }: GroupTabsArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => t.groupId === groupId).map(tabInfo); return all.filter(t => t.groupId === groupId).map(tabInfo);
} }
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));
} }
private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) { private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) {
return runLargeOperation("group.close", async () => { return runLargeOperation("group.close", async () => {
const tabs = await chrome.tabs.query({}); const tabs = await api.tabs.query({});
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 api.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 };
} }
private async groupAddTab({ group, url }: GroupAddTabArgs) { private async groupAddTab({ group, url }: GroupAddTabArgs) {
const groupId = await resolveGroupId(group); const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId }); const existingTabs = await api.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true }); const tab = await api.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 =>
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/" t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
); );
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
} }
return { tabId: tab.id, groupId }; return { tabId: tab.id, groupId };
} }
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 api.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index); allTabs.sort((a, b) => a.index - b.index);
const blocks = buildTabBlocks(allTabs); const blocks = buildTabBlocks(allTabs);
@@ -98,7 +99,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 +107,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 };
+44 -19
View File
@@ -1,4 +1,6 @@
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core'; import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
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';
@@ -26,34 +28,57 @@ export class NavigationCommands extends CommandGroup {
const entry = Object.entries(aliases).find(([, v]) => v === windowName); const entry = Object.entries(aliases).find(([, v]) => v === windowName);
if (entry) windowId = parseInt(entry[0]); if (entry) windowId = parseInt(entry[0]);
} }
const tab = await chrome.tabs.create({ url, active: Boolean(focus) && !background, windowId }); const tab = await api.tabs.create({ url, active: Boolean(focus) && !background, windowId });
if (groupNameOrId != null) { if (groupNameOrId != null) {
let groupId; let groupId;
try { try {
groupId = await resolveGroupId(groupNameOrId); groupId = await resolveGroupId(groupNameOrId);
// Close any blank placeholder tabs that were created when the group was made // Close any blank placeholder tabs that were created when the group was made
const groupTabs = await chrome.tabs.query({ groupId }); const groupTabs = await api.tabs.query({ groupId });
const placeholders = groupTabs.filter(t => const placeholders = groupTabs.filter(t =>
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 api.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 }; const loadedTab = await this.waitForOpenedTabUrl(tab.id, url, tab);
return { id: loadedTab.id, url: loadedTab.url || loadedTab.pendingUrl || url };
}
private async waitForOpenedTabUrl(tabId: number, targetUrl: string, initialTab: Tab): Promise<Tab> {
const initialUrl = initialTab.url || initialTab.pendingUrl || "";
if (this.isOpenedTabUrlReady(initialUrl, targetUrl)) return initialTab;
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
const current = await api.tabs.get(tabId);
const currentUrl = current.url || current.pendingUrl || "";
if (this.isOpenedTabUrlReady(currentUrl, targetUrl)) return current;
await new Promise(r => setTimeout(r, 50));
}
return api.tabs.get(tabId);
}
private isOpenedTabUrlReady(currentUrl: string, targetUrl: string): boolean {
if (!currentUrl) return false;
if (currentUrl === targetUrl || currentUrl.startsWith(targetUrl)) return true;
if (targetUrl === "about:blank" || targetUrl === "chrome://newtab/") return currentUrl === targetUrl;
return currentUrl !== "about:blank" && currentUrl !== "chrome://newtab/";
} }
private async navTo({ tabId, url }: NavToArgs) { private async navTo({ tabId, url }: NavToArgs) {
const tab = await chrome.tabs.update(tabId, { url }); const tab = await api.tabs.update(tabId, { url });
const deadline = Date.now() + 1000; const deadline = Date.now() + 1000;
while (tabId && Date.now() < deadline) { while (tabId && Date.now() < deadline) {
const current = await chrome.tabs.get(tabId); const current = await api.tabs.get(tabId);
const currentUrl = current.url || current.pendingUrl || ""; const currentUrl = current.url || current.pendingUrl || "";
if (currentUrl === url || currentUrl.startsWith(url)) { if (currentUrl === url || currentUrl.startsWith(url)) {
return { id: current.id, url: currentUrl }; return { id: current.id, url: currentUrl };
@@ -65,35 +90,35 @@ export class NavigationCommands extends CommandGroup {
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) { private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache }); await api.tabs.reload(tab.id, { bypassCache });
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navBack({ tabId }: NavTabArgs) { private async navBack({ tabId }: NavTabArgs) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goBack(tab.id); await api.tabs.goBack(tab.id);
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navForward({ tabId }: NavTabArgs) { private async navForward({ tabId }: NavTabArgs) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goForward(tab.id); await api.tabs.goForward(tab.id);
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navFocus({ pattern }: NavFocusArgs) { private async navFocus({ pattern }: NavFocusArgs) {
// If pattern is a plain integer, treat it as a tab ID // If pattern is a plain integer, treat it as a tab ID
const asInt = parseInt(pattern); const asInt = parseInt(pattern);
let match: chrome.tabs.Tab | undefined; let match: Tab | undefined;
if (!isNaN(asInt) && String(asInt) === String(pattern)) { if (!isNaN(asInt) && String(asInt) === String(pattern)) {
match = await chrome.tabs.get(asInt); match = await api.tabs.get(asInt);
} else { } else {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern))); match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
} }
if (!match) return null; if (!match) return null;
await chrome.windows.update(match.windowId, { focused: true }); await api.windows.update(match.windowId, { focused: true });
await chrome.tabs.update(match.id, { active: true }); await api.tabs.update(match.id, { active: true });
return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
} }
@@ -102,7 +127,7 @@ export class NavigationCommands extends CommandGroup {
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
const interval = 200; const interval = 200;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id); const t = await api.tabs.get(tab.id);
const currentUrl = t.url || t.pendingUrl || ""; const currentUrl = t.url || t.pendingUrl || "";
if (isBrowserErrorUrl(currentUrl)) { if (isBrowserErrorUrl(currentUrl)) {
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`); throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
+6 -4
View File
@@ -1,7 +1,9 @@
import { normalizeGroupColor } from '../core'; import { webExtApi as api } from '../browser-api';
import type { Tab, TabGroup } from '../types';
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: Tab[], groups: TabGroup[]): SessionTab[] {
const groupById = new Map(groups.map(group => [group.id, group])); const groupById = new Map(groups.map(group => [group.id, group]));
return tabs return tabs
.filter(tab => Boolean(tab.url || tab.pendingUrl)) .filter(tab => Boolean(tab.url || tab.pendingUrl))
@@ -27,8 +29,8 @@ export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tab
* its change-detection signature. Shared by session.save and the autosave path. * its change-detection signature. Shared by session.save and the autosave path.
*/ */
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 api.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 = {
+22 -19
View File
@@ -1,4 +1,5 @@
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; import { webExtApi as api } from '../browser-api';
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';
@@ -32,18 +33,18 @@ export class SessionCommands extends CommandGroup {
const { session, tabCount } = await captureCurrentSession(); const { session, tabCount } = await captureCurrentSession();
const sessions = await getSessions(); const sessions = await getSessions();
sessions[name] = session; sessions[name] = session;
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name, tabs: tabCount }; return { name, tabs: tabCount };
} }
// Public: invoked from index.ts on chrome.tabs.onActivated. // Public: invoked from index.ts on api.tabs.onActivated.
async activateLazyTab(tabId: number | string) { async activateLazyTab(tabId: number | string) {
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs"); const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
const entry = lazySessionTabs?.[tabId]; const entry = lazySessionTabs?.[tabId];
if (!entry?.url) return false; if (!entry?.url) return false;
delete lazySessionTabs[tabId]; delete lazySessionTabs[tabId];
await chrome.storage.local.set({ lazySessionTabs }); await api.storage.local.set({ lazySessionTabs });
await chrome.tabs.update(Number(tabId), { url: entry.url }); await api.tabs.update(Number(tabId), { url: entry.url });
return true; return true;
} }
@@ -58,24 +59,24 @@ export class SessionCommands extends CommandGroup {
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode); const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length; const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length;
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs"); const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
const lazyMap: LazySessionMap = lazySessionTabs || {}; const lazyMap: LazySessionMap = lazySessionTabs || {};
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length }); updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
for (const [idx, entry] of sessionTabs.entries()) { for (const [idx, entry] of sessionTabs.entries()) {
throwIfJobCancelled(__job); throwIfJobCancelled(__job);
const shouldLazy = lazy && idx >= eagerLimit; const shouldLazy = lazy && idx >= eagerLimit;
const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) }); const tab = await api.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) });
createdTabs.push({ tabId: tab.id, entry }); createdTabs.push({ tabId: tab.id, entry });
if (shouldLazy) { if (shouldLazy) {
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() }; lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
} else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) { } else if (discardBackgroundTabs && !entry.pinned && api.tabs.discard) {
try { await chrome.tabs.discard(tab.id); } catch (_) {} try { await api.tabs.discard(tab.id); } catch (_) {}
} }
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length }); updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs)); await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
} }
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap }); if (lazy) await api.storage.local.set({ lazySessionTabs: lazyMap });
const groups = new Map(); const groups = new Map();
for (const { tabId, entry } of createdTabs) { for (const { tabId, entry } of createdTabs) {
@@ -91,8 +92,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),
@@ -119,7 +120,7 @@ export class SessionCommands extends CommandGroup {
const sessions = await getSessions(); const sessions = await getSessions();
if (!(name in sessions)) throw new Error(`Session '${name}' not found`); if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
delete sessions[name]; delete sessions[name];
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name }; return { name };
} }
@@ -154,16 +155,18 @@ export class SessionCommands extends CommandGroup {
if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`); if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`);
const stored = session as object as StoredSession; const stored = session as object as StoredSession;
sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() }; sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() };
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name, tabs: getSessionTabs(sessions[name]).length }; return { name, tabs: getSessionTabs(sessions[name]).length };
} }
private async clientsList() { private async clientsList() {
const manifest = chrome.runtime.getManifest(); const manifest = api.runtime.getManifest();
const alias = await getProfileAlias(); const alias = await getProfileAlias();
const browserInfo = api.runtime.getBrowserInfo ? await api.runtime.getBrowserInfo() : null;
const userAgent = navigator.userAgent;
return [{ return [{
name: "Chrome", name: browserInfo?.name || (userAgent.includes("Firefox/") ? "Firefox" : "Chrome"),
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", version: browserInfo?.version || userAgent.match(/(?:Chrome|Firefox)\/([\d.]+)/)?.[1] || "unknown",
platform: navigator.platform, platform: navigator.platform,
extensionVersion: manifest.version, extensionVersion: manifest.version,
profile: alias, profile: alias,
@@ -171,7 +174,7 @@ export class SessionCommands extends CommandGroup {
} }
private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) { private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) {
await chrome.storage.local.set({ profileAlias: alias }); await api.storage.local.set({ profileAlias: alias });
return { alias }; return { alias };
} }
} }
+9 -8
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core'; import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -17,7 +18,7 @@ export class TabsQueryCommands extends CommandGroup {
}; };
private async tabsList() { private async tabsList() {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const aliases = await getAliases(); const aliases = await getAliases();
const tabs = []; const tabs = [];
for (const w of windows) { for (const w of windows) {
@@ -34,7 +35,7 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) { private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) {
const activeTabs = await chrome.tabs.query({ windowId, active: true }); const activeTabs = await api.tabs.query({ windowId, active: true });
const tab = activeTabs[0]; const tab = activeTabs[0];
if (!tab) { if (!tab) {
throw new Error(`No active tab found for window ${windowId}`); throw new Error(`No active tab found for window ${windowId}`);
@@ -43,24 +44,24 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsStatus({ tabId }: TabIdArgs) { private async tabsStatus({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
return tabInfo(tab); return tabInfo(tab);
} }
private async tabsFilter({ pattern }: TabsPatternArgs) { private async tabsFilter({ pattern }: TabsPatternArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
} }
private async tabsCount({ pattern }: TabsPatternArgs) { private async tabsCount({ pattern }: TabsPatternArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length; if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
return all.length; return all.length;
} }
private async tabsQuery({ search }: TabsQueryArgs) { private async tabsQuery({ search }: TabsQueryArgs) {
const q = search.toLowerCase(); const q = search.toLowerCase();
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => return all.filter(t =>
(t.url && t.url.toLowerCase().includes(q)) || (t.url && t.url.toLowerCase().includes(q)) ||
(t.title && t.title.toLowerCase().includes(q)) (t.title && t.title.toLowerCase().includes(q))
@@ -68,7 +69,7 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) { private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
const regex = new RegExp(pattern); const regex = new RegExp(pattern);
let lastUrl = tab.url || tab.pendingUrl || ""; let lastUrl = tab.url || tab.pendingUrl || "";
@@ -81,7 +82,7 @@ export class TabsQueryCommands extends CommandGroup {
if (matches(lastUrl)) return tabInfo(tab); if (matches(lastUrl)) return tabInfo(tab);
while (Date.now() < deadline) { while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id); const t = await api.tabs.get(tab.id);
lastUrl = t.url || t.pendingUrl || ""; lastUrl = t.url || t.pendingUrl || "";
lastStatus = t.status || "unknown"; lastStatus = t.status || "unknown";
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t); if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
+26 -24
View File
@@ -1,4 +1,6 @@
import { asTabIds, getActiveTab, getLargeOperationThrottle, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; import { webExtApi as api } from '../browser-api';
import type { TabMoveProperties, BrowserWindow } from '../types';
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';
@@ -23,7 +25,7 @@ export class TabsMutationCommands extends CommandGroup {
return runLargeOperation("tabs.close", async () => { return runLargeOperation("tabs.close", async () => {
let toClose: number[] = []; let toClose: number[] = [];
if (duplicates) { if (duplicates) {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const seen = new Set<string>(); const seen = new Set<string>();
for (const w of windows) { for (const w of windows) {
for (const t of w.tabs || []) { for (const t of w.tabs || []) {
@@ -34,7 +36,7 @@ export class TabsMutationCommands extends CommandGroup {
} }
} }
} else if (inactive) { } else if (inactive) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
toClose = all.filter(t => !t.active).map(t => t.id); toClose = all.filter(t => !t.active).map(t => t.id);
} else if (tabIds?.length) { } else if (tabIds?.length) {
toClose = tabIds.filter(id => id != null); toClose = tabIds.filter(id => id != null);
@@ -42,17 +44,17 @@ export class TabsMutationCommands extends CommandGroup {
toClose = [tabId]; toClose = [tabId];
} }
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode); const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
await processInBatches(toClose, throttle, batch => chrome.tabs.remove(batch), { job: __job, phase: "closing tabs" }); await processInBatches(toClose, throttle, batch => api.tabs.remove(batch), { job: __job, phase: "closing tabs" });
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible }; return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
}); });
} }
private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) { private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) {
const moveProps: Partial<chrome.tabs.MoveProperties> = {}; const moveProps: Partial<TabMoveProperties> = {};
if (windowId != null) moveProps.windowId = windowId; if (windowId != null) moveProps.windowId = windowId;
if (forward || backward) { if (forward || backward) {
const tab = await chrome.tabs.get(tabId); const tab = await api.tabs.get(tabId);
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
else moveProps.index = Math.max(0, tab.index - 1); else moveProps.index = Math.max(0, tab.index - 1);
} else if (index != null) { } else if (index != null) {
@@ -62,15 +64,15 @@ 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 api.tabs.move(tabId, moveProps as TabMoveProperties);
if (groupId != null) { if (groupId != null) {
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId }); await groupTabs({ tabIds: asTabIds([tabId]), groupId });
} }
return { tabId }; return { tabId };
} }
private async tabsActive({ tabId }: TabIdArgs) { private async tabsActive({ tabId }: TabIdArgs) {
await chrome.tabs.update(tabId, { active: true }); await api.tabs.update(tabId, { active: true });
return { tabId }; return { tabId };
} }
@@ -80,7 +82,7 @@ export class TabsMutationCommands extends CommandGroup {
private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) { private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) {
return runLargeOperation("tabs.sort", async () => { return runLargeOperation("tabs.sort", async () => {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
let moved = 0; let moved = 0;
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0); const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs }); updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
@@ -98,7 +100,7 @@ export class TabsMutationCommands extends CommandGroup {
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
for (let i = 0; i < sorted.length; i++) { for (let i = 0; i < sorted.length; i++) {
throwIfJobCancelled(__job); throwIfJobCancelled(__job);
await chrome.tabs.move(sorted[i].id, { index: i }); await api.tabs.move(sorted[i].id, { index: i });
moved++; moved++;
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs }); updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs); await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
@@ -108,13 +110,13 @@ export class TabsMutationCommands extends CommandGroup {
}); });
} }
private windowHasAudibleTabs(window: chrome.windows.Window): boolean { private windowHasAudibleTabs(window: BrowserWindow): boolean {
return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted)); return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted));
} }
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) { private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
return runLargeOperation("tabs.merge_windows", async () => { return runLargeOperation("tabs.merge_windows", async () => {
const all = await chrome.windows.getAll({ populate: true }); const all = await api.windows.getAll({ populate: true });
const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w)); const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w));
const target = movableWindows.find(w => w.focused) || movableWindows[0]; const target = movableWindows.find(w => w.focused) || movableWindows[0];
if (!target) return { moved: 0, skippedAudibleWindows: all.length }; if (!target) return { moved: 0, skippedAudibleWindows: all.length };
@@ -127,7 +129,7 @@ export class TabsMutationCommands extends CommandGroup {
const ids = w.tabs.map(t => t.id); const ids = w.tabs.map(t => t.id);
const throttle = await getLargeOperationThrottle(ids.length, gentleMode); const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
moved = await processInBatches(ids, throttle, moved = await processInBatches(ids, throttle,
batch => chrome.tabs.move(batch, { windowId: target.id, index: -1 }), batch => api.tabs.move(batch, { windowId: target.id, index: -1 }),
{ job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved }); { job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved });
} }
return { moved, skippedAudibleWindows: all.length - movableWindows.length }; return { moved, skippedAudibleWindows: all.length - movableWindows.length };
@@ -135,42 +137,42 @@ export class TabsMutationCommands extends CommandGroup {
} }
private async tabsPin({ tabId }: TabIdArgs) { private async tabsPin({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: true }); await api.tabs.update(tab.id, { pinned: true });
return { tabId: tab.id, pinned: true }; return { tabId: tab.id, pinned: true };
} }
private async tabsUnpin({ tabId }: TabIdArgs) { private async tabsUnpin({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: false }); await api.tabs.update(tab.id, { pinned: false });
return { tabId: tab.id, pinned: false }; return { tabId: tab.id, pinned: false };
} }
private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) { private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) {
let windowId: number | undefined; let windowId: number | undefined;
if (tabId) { if (tabId) {
const tab = await chrome.tabs.get(tabId); const tab = await api.tabs.get(tabId);
await chrome.tabs.update(tabId, { active: true }); await api.tabs.update(tabId, { active: true });
windowId = tab.windowId; windowId = tab.windowId;
} else { } else {
const tab = await getActiveTab(); const tab = await getActiveTab();
windowId = tab.windowId; windowId = tab.windowId;
} }
const opts: chrome.extensionTypes.ImageDetails = { format: format as chrome.extensionTypes.ImageFormat }; const opts: browser.extensionTypes.ImageDetails = { format: format as browser.extensionTypes.ImageFormat };
if (format === "jpeg" && quality != null) opts.quality = quality; if (format === "jpeg" && quality != null) opts.quality = quality;
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts); const dataUrl = await api.tabs.captureVisibleTab(windowId, opts);
return { dataUrl, format }; return { dataUrl, format };
} }
private async tabsMute({ tabId }: TabIdArgs) { private async tabsMute({ tabId }: TabIdArgs) {
const tab = await resolveTabForDirectAction(tabId, "mute"); const tab = await resolveTabForDirectAction(tabId, "mute");
await chrome.tabs.update(tab.id, { muted: true }); await api.tabs.update(tab.id, { muted: true });
return { tabId: tab.id, muted: true }; return { tabId: tab.id, muted: true };
} }
private async tabsUnmute({ tabId }: TabIdArgs) { private async tabsUnmute({ tabId }: TabIdArgs) {
const tab = await resolveTabForDirectAction(tabId, "unmute"); const tab = await resolveTabForDirectAction(tabId, "unmute");
await chrome.tabs.update(tab.id, { muted: false }); await api.tabs.update(tab.id, { muted: false });
return { tabId: tab.id, muted: false }; return { tabId: tab.id, muted: false };
} }
} }
+7 -5
View File
@@ -1,3 +1,5 @@
import { webExtApi as api } from '../browser-api';
import type { WindowCreateData } from '../types';
import { getAliases } from '../core'; import { getAliases } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -13,7 +15,7 @@ export class WindowsCommands extends CommandGroup {
}; };
private async windowsList() { private async windowsList() {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const aliases = await getAliases(); const aliases = await getAliases();
return windows.map(w => ({ return windows.map(w => ({
id: w.id, id: w.id,
@@ -27,19 +29,19 @@ export class WindowsCommands extends CommandGroup {
private async windowsRename({ windowId, name }: WindowsRenameArgs) { private async windowsRename({ windowId, name }: WindowsRenameArgs) {
const aliases = await getAliases(); const aliases = await getAliases();
aliases[windowId] = name; aliases[windowId] = name;
await chrome.storage.local.set({ windowAliases: aliases }); await api.storage.local.set({ windowAliases: aliases });
return { windowId, name }; return { windowId, name };
} }
private async windowsClose({ windowId }: WindowsCloseArgs) { private async windowsClose({ windowId }: WindowsCloseArgs) {
await chrome.windows.remove(windowId); await api.windows.remove(windowId);
return { windowId }; return { windowId };
} }
private async windowsOpen({ url }: WindowsOpenArgs) { private async windowsOpen({ url }: WindowsOpenArgs) {
const createData: chrome.windows.CreateData = { focused: true }; const createData: WindowCreateData = { focused: true };
if (url) createData.url = url; if (url) createData.url = url;
const w = await chrome.windows.create(createData); const w = await api.windows.create(createData);
return { id: w.id }; return { id: w.id };
} }
} }
+6 -3
View File
@@ -1,15 +1,18 @@
import type { TabGroupColor } from '../types';
import { webExtApi as api } from '../browser-api';
// 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;
} }
export function normalizeGroupColor(color: string | undefined): chrome.tabGroups.Color { export function normalizeGroupColor(color: string | undefined): TabGroupColor {
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
return (allowed.has(color as string) ? color : "grey") as chrome.tabGroups.Color; return (allowed.has(color as string) ? color : "grey") as TabGroupColor;
} }
+1
View File
@@ -3,4 +3,5 @@ export * from './throttle';
export * from './scripting'; export * from './scripting';
export * from './tab-helpers'; export * from './tab-helpers';
export * from './group-helpers'; export * from './group-helpers';
export * from './tab-groups';
export * from './storage'; export * from './storage';
+6 -4
View File
@@ -1,15 +1,17 @@
// chrome.scripting.executeScript wrapper with transient-error retry. import { webExtApi as api } from '../browser-api';
import type { ScriptInjection, ScriptInjectionResult } from '../types';
// api.scripting.executeScript wrapper with transient-error retry.
import { isTransientScriptError } from './errors'; import { isTransientScriptError } from './errors';
import { sleep } from './throttle'; import { sleep } from './throttle';
import type { Serializable } from '../types'; import type { Serializable } from '../types';
export async function executeScript<Args extends Serializable[], Result>( export async function executeScript<Args extends Serializable[], Result>(
options: chrome.scripting.ScriptInjection<Args, Result>, options: ScriptInjection<Args>,
retries = 3, retries = 3,
): Promise<chrome.scripting.InjectionResult<chrome.scripting.Awaited<Result>>[]> { ): Promise<ScriptInjectionResult<Result>[]> {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
return await chrome.scripting.executeScript(options); return await api.scripting.executeScript(options);
} catch (e) { } catch (e) {
if (i < retries - 1 && isTransientScriptError(e)) { if (i < retries - 1 && isTransientScriptError(e)) {
await sleep(300); await sleep(300);
+5 -4
View File
@@ -1,8 +1,9 @@
// chrome.storage.local accessors for profile alias, window aliases, and sessions. import { webExtApi as api } from '../browser-api';
// api.storage.local accessors for profile alias, window aliases, and sessions.
import type { SessionTab, StoredSession } from '../types'; import type { SessionTab, StoredSession } from '../types';
export async function getProfileAlias(): Promise<string> { export async function getProfileAlias(): Promise<string> {
const { profileAlias } = await chrome.storage.local.get<{ profileAlias?: string }>("profileAlias"); const { profileAlias } = await api.storage.local.get<{ profileAlias?: string }>("profileAlias");
return profileAlias || "default"; return profileAlias || "default";
} }
@@ -20,11 +21,11 @@ export function getSessionTabs(session: StoredSession | undefined | null): Sessi
} }
export async function getAliases(): Promise<Record<string, string>> { export async function getAliases(): Promise<Record<string, string>> {
const { windowAliases } = await chrome.storage.local.get<{ windowAliases?: Record<string, string> }>("windowAliases"); const { windowAliases } = await api.storage.local.get<{ windowAliases?: Record<string, string> }>("windowAliases");
return windowAliases || {}; return windowAliases || {};
} }
export async function getSessions(): Promise<Record<string, StoredSession>> { export async function getSessions(): Promise<Record<string, StoredSession>> {
const { sessions } = await chrome.storage.local.get<{ sessions?: Record<string, StoredSession> }>("sessions"); const { sessions } = await api.storage.local.get<{ sessions?: Record<string, StoredSession> }>("sessions");
return sessions || {}; return sessions || {};
} }
+57
View File
@@ -0,0 +1,57 @@
import type { TabGroupQueryInfo, TabGroup, TabGroupUpdateProperties, TabGroupMoveProperties, TabGroupOptions, BrowserEvent } from '../types';
import { webExtApi as api } from '../browser-api';
// 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 api.tabGroups {
const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
if (!tabGroups) throw new Error(TAB_GROUPS_UNSUPPORTED);
return tabGroups;
}
function tabsGroupApi(): typeof api.tabs.group {
const fn = api.tabs["group" as keyof typeof api.tabs] as typeof api.tabs.group | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(api.tabs);
}
function tabsUngroupApi(): typeof api.tabs.ungroup {
const fn = api.tabs["ungroup" as keyof typeof api.tabs] as typeof api.tabs.ungroup | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(api.tabs);
}
export async function queryTabGroups(queryInfo: TabGroupQueryInfo = {}): Promise<TabGroup[]> {
const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
if (!tabGroups) return [];
return tabGroups.query(queryInfo);
}
export async function getTabGroup(groupId: number): Promise<TabGroup> {
return tabGroupsApi().get(groupId);
}
export async function updateTabGroup(groupId: number, updateProperties: TabGroupUpdateProperties): Promise<TabGroup> {
return tabGroupsApi().update(groupId, updateProperties);
}
export async function moveTabGroup(groupId: number, moveProperties: TabGroupMoveProperties): Promise<TabGroup> {
return tabGroupsApi().move(groupId, moveProperties);
}
export async function groupTabs(createProperties: TabGroupOptions): Promise<number> {
return tabsGroupApi()(createProperties);
}
export async function ungroupTabs(tabIds: [number, ...number[]]): Promise<void> {
return tabsUngroupApi()(tabIds);
}
export function tabGroupsOnUpdated(): BrowserEvent<(group: TabGroup) => void> | undefined {
const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
return tabGroups?.onUpdated;
}
+17 -15
View File
@@ -1,3 +1,5 @@
import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
// Tab-related shared helpers: info shaping, scriptable-url checks, active-tab // Tab-related shared helpers: info shaping, scriptable-url checks, active-tab
// resolution, and HTML fetching. // resolution, and HTML fetching.
import { isBrowserErrorUrl, isErrorPageScriptError } from './errors'; import { isBrowserErrorUrl, isErrorPageScriptError } from './errors';
@@ -5,8 +7,8 @@ import { executeScript } from './scripting';
import type { TabBlock } from '../types'; import type { TabBlock } from '../types';
/** /**
* Narrow a plain id array to the non-empty-tuple shape that chrome.tabs.group / * Narrow a plain id array to the non-empty-tuple shape that api.tabs.group /
* chrome.tabs.ungroup declare. The runtime happily accepts any array (including * api.tabs.ungroup declare. The runtime happily accepts any array (including
* a single element); the published @types/chrome just over-constrain the param * a single element); the published @types/chrome just over-constrain the param
* to `[number, ...number[]]`. Callers guarantee non-emptiness before calling. * to `[number, ...number[]]`. Callers guarantee non-emptiness before calling.
*/ */
@@ -14,7 +16,7 @@ export function asTabIds(ids: number[]): [number, ...number[]] {
return ids as [number, ...number[]]; return ids as [number, ...number[]];
} }
export function tabInfo(t: chrome.tabs.Tab) { export function tabInfo(t: Tab) {
return { return {
id: t.id, id: t.id,
windowId: t.windowId, windowId: t.windowId,
@@ -36,16 +38,16 @@ export function isScriptableUrl(url: string | undefined | null): boolean {
} }
export async function getActiveTab() { export async function getActiveTab() {
const activeTabs = await chrome.tabs.query({ active: true }); const activeTabs = await api.tabs.query({ active: true });
if (!activeTabs.length) throw new Error("No active tab found"); if (!activeTabs.length) throw new Error("No active tab found");
const windows = await chrome.windows.getAll({ populate: false }); const windows = await api.windows.getAll({ populate: false });
const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id)); const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id));
const chooseTab = (predicate: (tab: chrome.tabs.Tab) => boolean) => activeTabs.find(predicate); const chooseTab = (predicate: (tab: Tab) => boolean) => activeTabs.find(predicate);
const byFocusAndScriptable = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || ""); const byFocusAndScriptable = (tab: Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
const byScriptable = (tab: chrome.tabs.Tab) => isScriptableUrl(tab.url || tab.pendingUrl || ""); const byScriptable = (tab: Tab) => isScriptableUrl(tab.url || tab.pendingUrl || "");
const byFocus = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId); const byFocus = (tab: Tab) => focusedWindowIds.has(tab.windowId);
return chooseTab(byFocusAndScriptable) return chooseTab(byFocusAndScriptable)
|| chooseTab(byScriptable) || chooseTab(byScriptable)
@@ -54,8 +56,8 @@ export async function getActiveTab() {
} }
/** Resolve the target tab (explicit id or the active tab) and its current URL. */ /** Resolve the target tab (explicit id or the active tab) and its current URL. */
export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: chrome.tabs.Tab; url: string }> { export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: Tab; url: string }> {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
return { tab, url: tab.url || tab.pendingUrl || "" }; return { tab, url: tab.url || tab.pendingUrl || "" };
} }
@@ -70,11 +72,11 @@ export function assertScriptableUrl(url: string, action: string): void {
} }
} }
export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise<chrome.tabs.Tab> { export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise<Tab> {
if (tabId != null) { if (tabId != null) {
return chrome.tabs.get(tabId); return api.tabs.get(tabId);
} }
const allTabs = await chrome.tabs.query({}); const allTabs = await api.tabs.query({});
if (allTabs.length !== 1) { if (allTabs.length !== 1) {
throw new Error( throw new Error(
`Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open` `Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open`
@@ -83,7 +85,7 @@ export async function resolveTabForDirectAction(tabId: number | undefined | null
return allTabs[0]; return allTabs[0];
} }
export function buildTabBlocks(tabs: chrome.tabs.Tab[]): TabBlock[] { export function buildTabBlocks(tabs: Tab[]): TabBlock[] {
const blocks: TabBlock[] = []; const blocks: TabBlock[] = [];
for (const tab of tabs) { for (const tab of tabs) {
const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null; const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null;
+4 -3
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
// Large-operation throttling, performance profile, and job-progress helpers. // Large-operation throttling, performance profile, and job-progress helpers.
import type { Job, JobProgressUpdate } from '../types'; import type { Job, JobProgressUpdate } from '../types';
@@ -16,7 +17,7 @@ function debugLargeOperation(message: string) {
} }
export async function hasAudibleTabs() { export async function hasAudibleTabs() {
const audibleTabs = await chrome.tabs.query({ audible: true }); const audibleTabs = await api.tabs.query({ audible: true });
return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted)); return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted));
} }
@@ -36,14 +37,14 @@ export async function runLargeOperation<T>(name: string, fn: () => Promise<T>):
} }
export async function getPerformanceProfile() { export async function getPerformanceProfile() {
const { performanceProfile } = await chrome.storage.local.get<{ performanceProfile?: string }>("performanceProfile"); const { performanceProfile } = await api.storage.local.get<{ performanceProfile?: string }>("performanceProfile");
return performanceProfile || "auto"; return performanceProfile || "auto";
} }
export async function setPerformanceProfile(profile: string) { export async function setPerformanceProfile(profile: string) {
const allowed = new Set(["auto", "normal", "gentle", "ultra"]); const allowed = new Set(["auto", "normal", "gentle", "ultra"]);
const performanceProfile = allowed.has(profile) ? profile : "auto"; const performanceProfile = allowed.has(profile) ? profile : "auto";
await chrome.storage.local.set({ performanceProfile }); await api.storage.local.set({ performanceProfile });
return { performanceProfile }; return { performanceProfile };
} }
+2 -1
View File
@@ -6,6 +6,7 @@
* the native connection. * the native connection.
*/ */
import { webExtApi as api } from './browser-api';
import { JobManager } from './classes/JobManager'; import { JobManager } from './classes/JobManager';
import { assembleRegistry } from './classes/CommandRegistry'; import { assembleRegistry } from './classes/CommandRegistry';
import { NativeConnection } from './classes/NativeConnection'; import { NativeConnection } from './classes/NativeConnection';
@@ -15,7 +16,7 @@ const jobs = new JobManager();
const ctx: CommandContext = { jobs }; const ctx: CommandContext = { jobs };
const { registry, session } = assembleRegistry(ctx); const { registry, session } = assembleRegistry(ctx);
chrome.tabs.onActivated.addListener(async ({ tabId }) => { api.tabs.onActivated.addListener(async ({ tabId }) => {
await session.activateLazyTab(tabId); await session.activateLazyTab(tabId);
}); });
+1 -1
View File
@@ -54,7 +54,7 @@ export interface DomEvalArgs { code?: string; tabId?: number; }
export interface DomWaitForArgs { selector?: string; timeout?: number; visible?: boolean; hidden?: boolean; tabId?: number; } export interface DomWaitForArgs { selector?: string; timeout?: number; visible?: boolean; hidden?: boolean; tabId?: number; }
export interface DomPollArgs { selector?: string; pattern?: string; attr?: string; timeout?: number; interval?: number; tabId?: number; } export interface DomPollArgs { selector?: string; pattern?: string; attr?: string; timeout?: number; interval?: number; tabId?: number; }
/** Arguments forwarded to the in-page content functions over chrome.scripting. */ /** Arguments forwarded to the in-page content functions over browser.scripting. */
export interface ContentArgs { export interface ContentArgs {
selector?: string; selector?: string;
text?: string; text?: string;
+1
View File
@@ -2,5 +2,6 @@ export * from './json';
export * from './jobs'; export * from './jobs';
export * from './session'; export * from './session';
export * from './tabs'; export * from './tabs';
export * from './webextension';
export * from './messages'; export * from './messages';
export * from './command-args'; export * from './command-args';
+41
View File
@@ -0,0 +1,41 @@
import type { Serializable } from './json';
export type RuntimePort = browser.runtime.Port;
export type Tab = browser.tabs.Tab & {
groupId?: number;
pendingUrl?: string;
};
export type TabUpdateInfo = Parameters<typeof browser.tabs.onUpdated.addListener>[0] extends (tabId: number, changeInfo: infer ChangeInfo, tab: browser.tabs.Tab) => void ? ChangeInfo : { url?: string };
export type BrowserWindow = browser.windows.Window & { tabs?: Tab[] };
export type WindowCreateData = browser.windows._CreateCreateData;
export type TabMoveProperties = browser.tabs._MoveMoveProperties;
export type TabGroupOptions = browser.tabs._GroupOptions;
export type TabGroup = browser.tabGroups.TabGroup;
export type TabGroupColor = browser.tabGroups.Color;
export type TabGroupQueryInfo = browser.tabGroups._QueryInfo;
export type TabGroupUpdateProperties = browser.tabGroups._UpdateProperties;
export type TabGroupMoveProperties = browser.tabGroups._MoveProperties;
export type BrowserEvent<TCallback extends (...args: never[]) => void> = {
addListener(cb: TCallback): void;
removeListener(cb: TCallback): void;
hasListener(cb: TCallback): boolean;
};
export type ScriptInjection<Args extends Serializable[]> = browser.scripting.ScriptInjection<Args>;
export type ScriptInjectionResult<Result> = browser.scripting.InjectionResult & { result?: Awaited<Result> };
export type StorageLocal = Omit<typeof browser.storage.local, "get"> & {
get<T extends object = { [key: string]: Serializable }>(keys?: string | string[] | object | null): Promise<T>;
};
export type WebExtensionApi = Omit<typeof browser, "tabs" | "windows" | "storage"> & {
tabs: Omit<typeof browser.tabs, "query" | "get" | "create" | "update" | "move"> & {
query(queryInfo: browser.tabs._QueryQueryInfo): Promise<Tab[]>;
get(tabId: number): Promise<Tab>;
create(createProperties: browser.tabs._CreateCreateProperties): Promise<Tab>;
update(tabId: number, updateProperties: browser.tabs._UpdateUpdateProperties): Promise<Tab>;
move(tabIds: number | number[], moveProperties: TabMoveProperties): Promise<Tab | Tab[]>;
};
windows: Omit<typeof browser.windows, "getAll" | "create"> & {
getAll(getInfo?: browser.windows._GetAllGetInfo): Promise<BrowserWindow[]>;
create(createData?: WindowCreateData): Promise<BrowserWindow>;
};
storage: Omit<typeof browser.storage, "local"> & { local: StorageLocal };
};
+43
View File
@@ -0,0 +1,43 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { webExtApi } from '../src/browser-api';
test('browser-api uses Firefox browser.* before Chromium chrome.*', () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const firefoxApi = { runtime: { id: 'firefox-api' } };
const chromiumApi = { runtime: { id: 'chromium-api' } };
try {
globalThis.chrome = chromiumApi;
globalThis.browser = firefoxApi;
assert.equal(webExtApi.runtime, firefoxApi.runtime);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
}
});
test('browser-api falls back to chrome.* in Chromium', () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const chromiumApi = { runtime: { id: 'chromium-api' } };
try {
globalThis.chrome = chromiumApi;
delete globalThis.browser;
assert.equal(webExtApi.runtime, chromiumApi.runtime);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
}
});
+86
View File
@@ -0,0 +1,86 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { SessionCommands } from '../src/commands/session';
import { JobManager } from '../src/classes/JobManager';
import { makeChromeMock } from './chrome-mock';
function makeSessionCommands() {
return new SessionCommands({ jobs: new JobManager() });
}
test('clients.list uses Firefox runtime.getBrowserInfo when available', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const chromeMock = makeChromeMock();
try {
delete globalThis.chrome;
globalThis.browser = {
...chromeMock,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
getBrowserInfo: async () => ({ name: 'Firefox', vendor: 'Mozilla', version: '149.0', buildID: 'test' }),
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Firefox/149.0' },
configurable: true,
});
const clients = await makeSessionCommands().commands['clients.list']({});
assert.equal(clients[0].name, 'Firefox');
assert.equal(clients[0].version, '149.0');
assert.equal(clients[0].extensionVersion, '0.15.1');
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
test('clients.list falls back to Chromium user-agent when getBrowserInfo is missing', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const chromeMock = makeChromeMock();
try {
delete globalThis.browser;
globalThis.chrome = {
...chromeMock,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Chrome/149.0.0.0 Safari/537.36' },
configurable: true,
});
const clients = await makeSessionCommands().commands['clients.list']({});
assert.equal(clients[0].name, 'Chrome');
assert.equal(clients[0].version, '149.0.0.0');
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
+66
View File
@@ -0,0 +1,66 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { NavigationCommands } from '../src/commands/navigation';
import { JobManager } from '../src/classes/JobManager';
import { makeChromeMock } from './chrome-mock';
function makeNavigationCommands() {
return new NavigationCommands({ jobs: new JobManager() });
}
test('navigate.open waits until Firefox updates about:blank to the requested URL', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const firefoxApi = makeChromeMock();
const targetUrl = 'https://example.com/?browser-cli-firefox-open-wait=1';
let getCalls = 0;
try {
delete globalThis.chrome;
globalThis.browser = {
...firefoxApi,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
getBrowserInfo: async () => ({ name: 'Firefox', vendor: 'Mozilla', version: '151.0.2', buildID: 'test' }),
},
tabs: {
...firefoxApi.tabs,
create: async () => ({ id: 123, windowId: 1, index: 0, active: true, groupId: -1, url: 'about:blank' }),
get: async () => {
getCalls += 1;
return {
id: 123,
windowId: 1,
index: 0,
active: true,
groupId: -1,
url: getCalls < 2 ? 'about:blank' : targetUrl,
};
},
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Firefox/151.0.2' },
configurable: true,
});
const result = await makeNavigationCommands().commands['navigate.open']({ url: targetUrl, focus: true });
assert.equal(result.id, 123);
assert.equal(result.url, targetUrl);
assert.ok(getCalls >= 2);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
+8
View File
@@ -7,6 +7,7 @@
"name": "browser-cli-extension-build", "name": "browser-cli-extension-build",
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.1.40", "@types/chrome": "^0.1.40",
"@types/firefox-webext-browser": "^143.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
@@ -481,6 +482,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/firefox-webext-browser": {
"version": "143.0.0",
"resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-143.0.0.tgz",
"integrity": "sha512-865dYKMOP0CllFyHmgXV4IQgVL51OSQQCwSoihQ17EwugePKFSAZRc0EI+y7Ly4q7j5KyURlA7LgRpFieO4JOw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": { "node_modules/@types/har-format": {
"version": "1.2.16", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
+3 -1
View File
@@ -8,10 +8,12 @@
"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",
"@types/firefox-webext-browser": "^143.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
+9 -2
View File
@@ -1,8 +1,10 @@
[project] [project]
name = "browser-cli" name = "real-browser-cli"
version = "0.14.2" version = "0.15.2"
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"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "Daniel Dolezal" }]
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"click>=8", "click>=8",
@@ -11,6 +13,11 @@ dependencies = [
"msgpack>=1", "msgpack>=1",
] ]
[project.urls]
Homepage = "https://git.yiprawr.dev/Automatisation/browser-cli"
Repository = "https://git.yiprawr.dev/Automatisation/browser-cli"
Issues = "https://git.yiprawr.dev/Automatisation/browser-cli/issues"
[project.optional-dependencies] [project.optional-dependencies]
# Better/faster remote response compression than the stdlib zlib/gzip fallback. # Better/faster remote response compression than the stdlib zlib/gzip fallback.
fast = ["zstandard>=0.22"] fast = ["zstandard>=0.22"]
+21 -10
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Package the Chrome extension. """Package the browser extension.
Default builds a testing/unpacked-style archive that keeps manifest.key so the Default builds a testing/unpacked-style archive that keeps manifest.key so the
extension ID stays stable for native messaging. ``--webstore`` writes the same Chromium extension ID stays stable for native messaging. ``--webstore`` writes
runtime files but strips ``key`` from manifest.json because the Chrome Web Store the same runtime files but strips ``key`` from manifest.json because the Chrome
rejects that field. Web Store rejects that field. ``--firefox`` writes a Firefox-friendly archive
with the Gecko extension ID and without Chromium-only manifest keys.
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,10 +27,17 @@ RUNTIME_FILES = (
) )
RUNTIME_DIRS = ("icons",) RUNTIME_DIRS = ("icons",)
def _read_manifest(webstore: bool) -> dict: def _read_manifest(webstore: bool, firefox: bool) -> dict:
manifest = json.loads((EXTENSION_DIR / "manifest.json").read_text(encoding="utf-8")) manifest = json.loads((EXTENSION_DIR / "manifest.json").read_text(encoding="utf-8"))
if webstore: if webstore or firefox:
manifest.pop("key", None) manifest.pop("key", None)
if firefox:
manifest["permissions"] = [p for p in manifest.get("permissions", []) if p != "windows"]
manifest["background"] = {"scripts": ["background.js"]}
gecko = manifest.setdefault("browser_specific_settings", {}).setdefault("gecko", {})
gecko["strict_min_version"] = "140.0"
manifest.setdefault("browser_specific_settings", {}).setdefault("gecko_android", {})["strict_min_version"] = "142.0"
gecko["data_collection_permissions"] = {"required": ["none"]}
return manifest return manifest
def _copy_tree(src: Path, dst: Path) -> None: def _copy_tree(src: Path, dst: Path) -> None:
@@ -37,10 +45,12 @@ def _copy_tree(src: Path, dst: Path) -> None:
shutil.rmtree(dst) shutil.rmtree(dst)
shutil.copytree(src, dst) shutil.copytree(src, dst)
def package_extension(*, webstore: bool = False, out: Path | None = None) -> Path: def package_extension(*, webstore: bool = False, firefox: bool = False, out: Path | None = None) -> Path:
manifest = _read_manifest(webstore) if webstore and firefox:
raise ValueError("--webstore and --firefox are mutually exclusive")
manifest = _read_manifest(webstore, firefox)
version = manifest["version"] version = manifest["version"]
suffix = "webstore" if webstore else "testing" suffix = "firefox" if firefox else "webstore" if webstore else "testing"
out = out or DIST_DIR / f"browser-cli-extension-{suffix}-v{version}.zip" out = out or DIST_DIR / f"browser-cli-extension-{suffix}-v{version}.zip"
staging = DIST_DIR / f"extension-package-{suffix}" staging = DIST_DIR / f"extension-package-{suffix}"
@@ -70,9 +80,10 @@ def package_extension(*, webstore: bool = False, out: Path | None = None) -> Pat
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Package browser-cli extension") parser = argparse.ArgumentParser(description="Package browser-cli extension")
parser.add_argument("--webstore", action="store_true", help="strip manifest.key for Chrome Web Store upload") parser.add_argument("--webstore", action="store_true", help="strip manifest.key for Chrome Web Store upload")
parser.add_argument("--firefox", action="store_true", help="build a Firefox-friendly extension zip")
parser.add_argument("--out", type=Path, default=None, help="output zip path") parser.add_argument("--out", type=Path, default=None, help="output zip path")
args = parser.parse_args() args = parser.parse_args()
print(package_extension(webstore=args.webstore, out=args.out)) print(package_extension(webstore=args.webstore, firefox=args.firefox, out=args.out))
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+30 -1
View File
@@ -87,7 +87,7 @@ def test_install_help_lists_supported_browsers():
result = CliRunner().invoke(main, ["install", "--help"]) result = CliRunner().invoke(main, ["install", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output assert "[chrome|chromium|brave|edge|vivaldi|firefox]" in result.output
def test_install_writes_testing_and_webstore_allowed_origins(tmp_path): def test_install_writes_testing_and_webstore_allowed_origins(tmp_path):
manifests = [] manifests = []
@@ -117,6 +117,35 @@ 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 "npm run package:extension:firefox" in result.output
assert "dist/extension-package-firefo" in result.output
assert "x/manifest.json" in result.output
assert "Do not select extension/manifest.json" 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 = []
+29 -5
View File
@@ -1,7 +1,15 @@
from pathlib import Path from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
def read_built_background() -> str:
background_path = ROOT / "extension" / "background.js"
if not background_path.exists():
pytest.skip("extension/background.js is a generated build artifact; run npm run build:extension")
return background_path.read_text()
def test_extension_retries_error_page_script_injection_before_failing(): def test_extension_retries_error_page_script_injection_before_failing():
# core.ts was split into a core/ subfolder during the structure refactor: # core.ts was split into a core/ subfolder during the structure refactor:
# the URL/error classifiers live in core/errors.ts and the executeScript # the URL/error classifiers live in core/errors.ts and the executeScript
@@ -66,7 +74,7 @@ def test_large_extension_operations_yield_between_batches():
assert "GENTLE_OPERATION_PAUSE_MS" in core assert "GENTLE_OPERATION_PAUSE_MS" in core
assert "itemCount >= 300" in core assert "itemCount >= 300" in core
assert "itemCount >= 100" in core assert "itemCount >= 100" in core
assert "chrome.tabs.query({ audible: true })" in core assert "api.tabs.query({ audible: true })" in core
# The centralized batch loop drives cancellation + progress + throttled yield. # The centralized batch loop drives cancellation + progress + throttled yield.
assert "processInBatches" in core assert "processInBatches" in core
assert "throwIfJobCancelled(progress.job)" in core assert "throwIfJobCancelled(progress.job)" in core
@@ -85,7 +93,7 @@ def test_large_extension_operations_yield_between_batches():
assert "yieldForLargeOperation(createdTabs.length" in session assert "yieldForLargeOperation(createdTabs.length" in session
assert "getLargeOperationThrottle" in session assert "getLargeOperationThrottle" in session
assert "runLargeOperation(\"session.load\"" in session assert "runLargeOperation(\"session.load\"" in session
assert "chrome.tabs.discard" in session assert "api.tabs.discard" in session
assert "lazyPlaceholderUrl" in session assert "lazyPlaceholderUrl" in session
assert "activateLazyTab" in session assert "activateLazyTab" in session
assert "lazySessionTabs" in session assert "lazySessionTabs" in session
@@ -122,6 +130,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 = read_built_background()
assert "chrome.tabGroups" not in background
assert "chrome.tabs.group" not in background
assert "chrome.tabs.ungroup" not in background
assert 'webExtApi["tabGroups"' in background
assert 'webExtApi.tabs["group"' in background
def test_built_extension_avoids_direct_eval_token_for_firefox_linter():
background = read_built_background()
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
@@ -140,9 +164,9 @@ def test_session_autosave_is_debounced_and_non_overlapping():
assert "autoSaveSignature" in autosave assert "autoSaveSignature" in autosave
# AutoSaveManager binds the handlers as instance fields (this.*), so the # AutoSaveManager binds the handlers as instance fields (this.*), so the
# add/removeListener references stay identity-stable across enable/disable. # add/removeListener references stay identity-stable across enable/disable.
assert "chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave assert "api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave
assert "chrome.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave assert "api.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave
assert "chrome.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave assert "api.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave
assert "if (!(\"url\" in changeInfo)) return;" in autosave assert "if (!(\"url\" in changeInfo)) return;" in autosave
assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave
assert "clearTimeout(this.autoSaveTimer)" in autosave assert "clearTimeout(this.autoSaveTimer)" in autosave
+21
View File
@@ -19,6 +19,9 @@ def _fake_extension(tmp_path: Path) -> Path:
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "1.2.3", "version": "1.2.3",
"permissions": ["tabs", "tabGroups", "windows", "nativeMessaging"],
"background": {"service_worker": "background.js"},
"browser_specific_settings": {"gecko": {"id": "browser-cli@yiprawr.dev"}},
"key": "test-key", "key": "test-key",
}), encoding="utf-8") }), encoding="utf-8")
for name in ("background.js", "content-dispatch.js", "content.js"): for name in ("background.js", "content-dispatch.js", "content.js"):
@@ -47,6 +50,24 @@ def test_webstore_package_strips_manifest_key(tmp_path):
assert "content.js" in names assert "content.js" in names
assert "icons/icon-128.png" in names assert "icons/icon-128.png" in names
def test_firefox_package_strips_chromium_key_and_firefox_incompatible_permission(tmp_path):
packager = _packager_with_fake_extension(tmp_path)
out = packager.package_extension(firefox=True, out=tmp_path / "firefox.zip")
with zipfile.ZipFile(out) as zf:
manifest = json.loads(zf.read("manifest.json"))
assert "key" not in manifest
assert manifest["browser_specific_settings"]["gecko"]["id"] == "browser-cli@yiprawr.dev"
assert "tabGroups" in manifest["permissions"]
assert "windows" not in manifest["permissions"]
assert "nativeMessaging" in manifest["permissions"]
assert "service_worker" not in manifest["background"]
assert manifest["background"]["scripts"] == ["background.js"]
assert manifest["browser_specific_settings"]["gecko"]["strict_min_version"] == "140.0"
assert manifest["browser_specific_settings"]["gecko_android"]["strict_min_version"] == "142.0"
assert manifest["browser_specific_settings"]["gecko"]["data_collection_permissions"] == {"required": ["none"]}
def test_local_package_keeps_manifest_key(tmp_path): def test_local_package_keeps_manifest_key(tmp_path):
packager = _packager_with_fake_extension(tmp_path) packager = _packager_with_fake_extension(tmp_path)
out = packager.package_extension(webstore=False, out=tmp_path / "local.zip") out = packager.package_extension(webstore=False, out=tmp_path / "local.zip")
+1 -1
View File
@@ -4,7 +4,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
"types": ["chrome"], "types": ["chrome", "firefox-webext-browser"],
"allowJs": false, "allowJs": false,
"strict": false, "strict": false,
"noImplicitAny": true, "noImplicitAny": true,
Generated
+40 -40
View File
@@ -2,46 +2,6 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.10" requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.14.2"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "cryptography" },
{ name = "msgpack" },
{ name = "rich" },
]
[package.optional-dependencies]
fast = [
{ name = "zstandard" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "zstandard" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8" },
{ name = "cryptography", specifier = ">=48" },
{ name = "msgpack", specifier = ">=1" },
{ name = "rich", specifier = ">=13" },
{ name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" },
]
provides-extras = ["fast"]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "zstandard", specifier = ">=0.22" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.0.0"
@@ -503,6 +463,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
] ]
[[package]]
name = "real-browser-cli"
version = "0.15.2"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "cryptography" },
{ name = "msgpack" },
{ name = "rich" },
]
[package.optional-dependencies]
fast = [
{ name = "zstandard" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "zstandard" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8" },
{ name = "cryptography", specifier = ">=48" },
{ name = "msgpack", specifier = ">=1" },
{ name = "rich", specifier = ">=13" },
{ name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" },
]
provides-extras = ["fast"]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "zstandard", specifier = ">=0.22" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "15.0.0" version = "15.0.0"