Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
477a00db1a
|
|||
|
523108e442
|
|||
|
9b8cefcd72
|
|||
|
9df5e1bd8f
|
@@ -50,18 +50,42 @@ Every response:
|
||||
|
||||
## Installation
|
||||
|
||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi
|
||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
|
||||
|
||||
### Install with uv
|
||||
Once published on PyPI, install the CLI as a uv tool:
|
||||
|
||||
```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
|
||||
git clone <repo>
|
||||
cd browser-cli
|
||||
uv sync
|
||||
uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi
|
||||
uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
|
||||
```
|
||||
|
||||
The `install` command will:
|
||||
1. Ask you to load the browser-specific extension package
|
||||
2. For Chromium-family browsers, ask you to paste the extension ID shown on the extension card
|
||||
2. Show the stable extension ID used by that browser family
|
||||
3. Write the native messaging manifest to your OS so the browser can find the host
|
||||
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
|
||||
|
||||
@@ -491,11 +515,14 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
|
||||
Packaging:
|
||||
|
||||
```bash
|
||||
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable native-messaging ID
|
||||
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
|
||||
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
|
||||
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
|
||||
```
|
||||
|
||||
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`.
|
||||
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For Firefox, use the `*-firefox-*` zip.
|
||||
|
||||
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.
|
||||
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
|
||||
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
|
||||
- **Linux and macOS only** — Windows native messaging paths are not yet handled.
|
||||
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
|
||||
- **Firefox support is experimental**. Basic tab/window/navigation/native-messaging support is wired, including tab-group APIs on supported Firefox versions.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -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.workspace import workspace_group
|
||||
from browser_cli.commands.raw import cmd_command
|
||||
from browser_cli.constants import PYPI_PACKAGE_NAME
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -63,7 +64,7 @@ def _project_version() -> str:
|
||||
pass
|
||||
|
||||
try:
|
||||
return package_version("browser-cli")
|
||||
return package_version(PYPI_PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from rich.table import Table
|
||||
|
||||
from browser_cli.commands import handle_errors, client_from_ctx
|
||||
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
|
||||
|
||||
console = Console()
|
||||
@@ -26,7 +26,7 @@ def _project_version() -> str:
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
return package_version("browser-cli")
|
||||
return package_version(PYPI_PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from rich.console import Console
|
||||
from browser_cli.constants import (
|
||||
ALLOWED_EXTENSION_IDS,
|
||||
EXTENSION_ID,
|
||||
FIREFOX_EXTENSION_ID,
|
||||
NATIVE_HOST_DIRS,
|
||||
NATIVE_HOST_NAME,
|
||||
SUPPORTED_BROWSERS,
|
||||
@@ -72,21 +73,27 @@ def cmd_install(browser):
|
||||
"brave": "brave://extensions",
|
||||
"edge": "edge://extensions",
|
||||
"vivaldi": "vivaldi://extensions",
|
||||
"firefox": "about:debugging#/runtime/this-firefox",
|
||||
}[browser]
|
||||
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
|
||||
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
||||
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
|
||||
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
|
||||
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
|
||||
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
|
||||
if browser == "firefox":
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
firefox_manifest = repo_root / "dist" / "extension-package-firefox" / "manifest.json"
|
||||
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 = {
|
||||
"name": NATIVE_HOST_NAME,
|
||||
"description": "browser-cli native messaging host",
|
||||
"path": str(host_exe),
|
||||
"type": "stdio",
|
||||
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
|
||||
}
|
||||
manifest = _native_host_manifest(browser, host_exe)
|
||||
installed = _install_manifest(browser, host_exe, manifest)
|
||||
if not installed:
|
||||
console.print("[red]Failed to install native host manifest[/red]")
|
||||
@@ -100,6 +107,20 @@ def cmd_install(browser):
|
||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||
|
||||
def _native_host_manifest(browser: str, host_exe: Path) -> dict:
|
||||
base = {
|
||||
"name": NATIVE_HOST_NAME,
|
||||
"description": "browser-cli native messaging host",
|
||||
"path": str(host_exe),
|
||||
"type": "stdio",
|
||||
}
|
||||
if browser == "firefox":
|
||||
return {**base, "allowed_extensions": [FIREFOX_EXTENSION_ID]}
|
||||
return {
|
||||
**base,
|
||||
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
|
||||
}
|
||||
|
||||
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
|
||||
if is_windows():
|
||||
manifest_dir = host_exe.parent
|
||||
|
||||
@@ -9,14 +9,16 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
APP_NAME = "browser-cli"
|
||||
PYPI_PACKAGE_NAME = "real-browser-cli"
|
||||
RUNTIME_DIRNAME = ".browser_cli"
|
||||
DEFAULT_ALIAS = "default"
|
||||
|
||||
NATIVE_HOST_NAME = "com.browsercli.host"
|
||||
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
|
||||
WEBSTORE_EXTENSION_ID = "hekaebjhbhhdbmakimmaklbblbmccahp"
|
||||
FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
|
||||
ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
|
||||
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi"]
|
||||
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi", "firefox"]
|
||||
|
||||
PROTOCOL_MIN_CLIENT = "0.9.0"
|
||||
MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||
@@ -65,6 +67,10 @@ NATIVE_HOST_DIRS = {
|
||||
"linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"],
|
||||
"darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"],
|
||||
},
|
||||
"firefox": {
|
||||
"linux": [Path.home() / ".mozilla/native-messaging-hosts"],
|
||||
"darwin": [Path.home() / "Library/Application Support/Mozilla/NativeMessagingHosts"],
|
||||
},
|
||||
}
|
||||
|
||||
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
|
||||
@@ -73,6 +79,7 @@ WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
|
||||
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
|
||||
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
|
||||
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
|
||||
"firefox": [r"Software\Mozilla\NativeMessagingHosts"],
|
||||
}
|
||||
|
||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / APP_NAME
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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, ...]:
|
||||
try:
|
||||
@@ -10,7 +10,7 @@ def parse_version(v: str) -> tuple[int, ...]:
|
||||
|
||||
def get_installed_version() -> str:
|
||||
try:
|
||||
return _pkg_version("browser-cli")
|
||||
return _pkg_version(PYPI_PACKAGE_NAME)
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.14.2",
|
||||
"version": "0.15.2",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "browser-cli@yiprawr.dev",
|
||||
"strict_min_version": "120.0"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"tabGroups",
|
||||
|
||||
@@ -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;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
import { CommandGroup } from './CommandGroup';
|
||||
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
|
||||
import { NavigationCommands } from '../commands/navigation';
|
||||
@@ -74,7 +75,7 @@ export class CommandRegistry {
|
||||
/**
|
||||
* Builds the registry and registers every command group. The SessionCommands
|
||||
* 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.
|
||||
*/
|
||||
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
// 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;
|
||||
|
||||
/**
|
||||
@@ -65,11 +66,11 @@ export class JobManager {
|
||||
const running = all.filter(job => job.status === "running");
|
||||
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
|
||||
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.
|
||||
// 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.
|
||||
private pruneJobs() {
|
||||
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
|
||||
@@ -143,7 +144,7 @@ export class JobManager {
|
||||
async status({ jobId }: { jobId?: string }) {
|
||||
const job = this.jobs.get(jobId);
|
||||
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);
|
||||
if (!stored) throw new Error(`Job '${jobId}' not found`);
|
||||
return stored;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
/**
|
||||
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
|
||||
* message router that hands commands to the CommandRegistry.
|
||||
@@ -6,7 +7,7 @@
|
||||
import { getErrorMessage, getProfileAlias } from '../core';
|
||||
import type { CommandRegistry } from './CommandRegistry';
|
||||
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 DEBUG_LOG = false;
|
||||
@@ -16,7 +17,7 @@ function debugLog(...args: Serializable[]) {
|
||||
}
|
||||
|
||||
export class NativeConnection {
|
||||
private port: chrome.runtime.Port | null = null;
|
||||
private port: RuntimePort | null = null;
|
||||
private keepaliveEnabled = true;
|
||||
|
||||
constructor(
|
||||
@@ -26,17 +27,17 @@ export class NativeConnection {
|
||||
|
||||
/** Registers all runtime listeners and opens the initial connection. */
|
||||
start() {
|
||||
chrome.runtime.onInstalled.addListener(() => this.connect());
|
||||
chrome.runtime.onStartup.addListener(() => this.connect());
|
||||
chrome.runtime.onSuspend.addListener(() => {
|
||||
api.runtime.onInstalled.addListener(() => this.connect());
|
||||
api.runtime.onStartup.addListener(() => this.connect());
|
||||
api.runtime.onSuspend.addListener(() => {
|
||||
this.disconnectPort({ sendBye: true });
|
||||
});
|
||||
chrome.windows.onCreated.addListener(() => {
|
||||
api.windows.onCreated.addListener(() => {
|
||||
this.keepaliveEnabled = true;
|
||||
if (!this.port) this.connect();
|
||||
});
|
||||
chrome.windows.onRemoved.addListener(async () => {
|
||||
const windows = await chrome.windows.getAll({});
|
||||
api.windows.onRemoved.addListener(async () => {
|
||||
const windows = await api.windows.getAll({});
|
||||
if (windows.length > 0) return;
|
||||
|
||||
this.keepaliveEnabled = false;
|
||||
@@ -46,15 +47,15 @@ export class NativeConnection {
|
||||
// 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)
|
||||
// are silently clamped and log a warning, so we set it explicitly.
|
||||
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
api.alarms.create("keepalive", { periodInMinutes: 0.5 });
|
||||
api.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "keepalive") {
|
||||
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;
|
||||
try {
|
||||
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;
|
||||
try {
|
||||
targetPort.postMessage(message);
|
||||
@@ -90,12 +91,12 @@ export class NativeConnection {
|
||||
private async connect() {
|
||||
if (this.port || !this.keepaliveEnabled) return;
|
||||
try {
|
||||
const nativePort = chrome.runtime.connectNative(NATIVE_HOST);
|
||||
const nativePort = api.runtime.connectNative(NATIVE_HOST);
|
||||
this.port = nativePort;
|
||||
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
|
||||
nativePort.onDisconnect.addListener(() => {
|
||||
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);
|
||||
});
|
||||
// Send hello so native host knows which profile/alias this is
|
||||
|
||||
@@ -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';
|
||||
|
||||
// Debounce window for autosave. A full-tab snapshot + storage write runs on
|
||||
@@ -16,44 +18,44 @@ export class AutoSaveManager {
|
||||
readonly autoSaveHandler = async (): Promise<void> => {
|
||||
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.
|
||||
if (!("url" in changeInfo)) return;
|
||||
await this.scheduleAutoSave();
|
||||
};
|
||||
|
||||
async setEnabled(enabled: boolean) {
|
||||
await chrome.storage.local.set({ autoSave: enabled });
|
||||
chrome.tabs.onCreated.removeListener(this.autoSaveHandler);
|
||||
chrome.tabs.onRemoved.removeListener(this.autoSaveHandler);
|
||||
chrome.tabs.onMoved.removeListener(this.autoSaveHandler);
|
||||
chrome.tabs.onAttached.removeListener(this.autoSaveHandler);
|
||||
chrome.tabs.onDetached.removeListener(this.autoSaveHandler);
|
||||
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
|
||||
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(this.autoSaveHandler);
|
||||
await api.storage.local.set({ autoSave: enabled });
|
||||
api.tabs.onCreated.removeListener(this.autoSaveHandler);
|
||||
api.tabs.onRemoved.removeListener(this.autoSaveHandler);
|
||||
api.tabs.onMoved.removeListener(this.autoSaveHandler);
|
||||
api.tabs.onAttached.removeListener(this.autoSaveHandler);
|
||||
api.tabs.onDetached.removeListener(this.autoSaveHandler);
|
||||
api.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
|
||||
tabGroupsOnUpdated()?.removeListener(this.autoSaveHandler);
|
||||
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
||||
this.autoSaveTimer = null;
|
||||
this.autoSavePending = false;
|
||||
if (enabled) {
|
||||
chrome.tabs.onCreated.addListener(this.autoSaveHandler);
|
||||
chrome.tabs.onRemoved.addListener(this.autoSaveHandler);
|
||||
chrome.tabs.onMoved.addListener(this.autoSaveHandler);
|
||||
chrome.tabs.onAttached.addListener(this.autoSaveHandler);
|
||||
chrome.tabs.onDetached.addListener(this.autoSaveHandler);
|
||||
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
|
||||
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler);
|
||||
api.tabs.onCreated.addListener(this.autoSaveHandler);
|
||||
api.tabs.onRemoved.addListener(this.autoSaveHandler);
|
||||
api.tabs.onMoved.addListener(this.autoSaveHandler);
|
||||
api.tabs.onAttached.addListener(this.autoSaveHandler);
|
||||
api.tabs.onDetached.addListener(this.autoSaveHandler);
|
||||
api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
|
||||
tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
|
||||
}
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
private async saveAutoSessionIfChanged() {
|
||||
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 };
|
||||
|
||||
const sessions = await getSessions();
|
||||
sessions.__auto__ = session;
|
||||
await chrome.storage.local.set({ sessions, autoSaveSignature: signature });
|
||||
await api.storage.local.set({ sessions, autoSaveSignature: signature });
|
||||
return { skipped: false, tabs: tabCount };
|
||||
}
|
||||
|
||||
@@ -64,7 +66,7 @@ export class AutoSaveManager {
|
||||
}
|
||||
this.autoSaveInFlight = true;
|
||||
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());
|
||||
} finally {
|
||||
this.autoSaveInFlight = false;
|
||||
@@ -76,7 +78,7 @@ export class AutoSaveManager {
|
||||
}
|
||||
|
||||
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 (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
||||
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs);
|
||||
|
||||
@@ -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 { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
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) {
|
||||
case "domExists":
|
||||
return false;
|
||||
@@ -105,7 +107,10 @@ export class DomCommands extends CommandGroup {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (c: string) => (0, eval)(c),
|
||||
func: (c: string) => {
|
||||
const evaluate = globalThis["eval" as keyof typeof globalThis] as (source: string) => unknown;
|
||||
return evaluate(c);
|
||||
},
|
||||
args: [code],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
|
||||
@@ -5,7 +6,7 @@ export class ExtensionCommands extends CommandGroup {
|
||||
readonly namespace = "extension";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"extension.reload": () => {
|
||||
setTimeout(() => chrome.runtime.reload(), 200);
|
||||
setTimeout(() => api.runtime.reload(), 200);
|
||||
return { reloading: true };
|
||||
},
|
||||
"extension.info": () => this.extensionInfo(),
|
||||
@@ -29,9 +30,9 @@ export class ExtensionCommands extends CommandGroup {
|
||||
}
|
||||
|
||||
private extensionInfo() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const manifest = api.runtime.getManifest();
|
||||
return {
|
||||
id: chrome.runtime.id,
|
||||
id: api.runtime.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
manifestVersion: manifest.manifest_version,
|
||||
|
||||
@@ -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 type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types';
|
||||
@@ -17,8 +18,8 @@ export class GroupsCommands extends CommandGroup {
|
||||
};
|
||||
|
||||
private async groupList() {
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const all = await chrome.tabs.query({});
|
||||
const groups = await queryTabGroups({});
|
||||
const all = await api.tabs.query({});
|
||||
return groups.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
@@ -30,58 +31,58 @@ export class GroupsCommands extends CommandGroup {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private async groupCount() {
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const groups = await queryTabGroups({});
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
private async groupQuery({ search }: GroupQueryArgs) {
|
||||
const q = search.toLowerCase();
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const groups = await queryTabGroups({});
|
||||
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) {
|
||||
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 tabIds = groupTabs.map(t => t.id);
|
||||
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
|
||||
await processInBatches(tabIds, throttle, batch => chrome.tabs.ungroup(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
|
||||
await processInBatches(tabIds, throttle, batch => ungroupTabs(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
|
||||
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
|
||||
});
|
||||
}
|
||||
|
||||
private async groupOpen({ name }: GroupOpenArgs) {
|
||||
const tab = await chrome.tabs.create({ active: true });
|
||||
const groupId = await chrome.tabs.group({ tabIds: asTabIds([tab.id]) });
|
||||
await chrome.tabGroups.update(groupId, { title: name });
|
||||
const tab = await api.tabs.create({ active: true });
|
||||
const groupId = await groupTabs({ tabIds: asTabIds([tab.id]) });
|
||||
await updateTabGroup(groupId, { title: name });
|
||||
return { id: groupId, name };
|
||||
}
|
||||
|
||||
private async groupAddTab({ group, url }: GroupAddTabArgs) {
|
||||
const groupId = await resolveGroupId(group);
|
||||
const existingTabs = await chrome.tabs.query({ groupId });
|
||||
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
|
||||
await chrome.tabs.group({ tabIds: asTabIds([tab.id]), groupId });
|
||||
const existingTabs = await api.tabs.query({ groupId });
|
||||
const tab = await api.tabs.create({ url: url || "chrome://newtab/", active: true });
|
||||
await groupTabs({ tabIds: asTabIds([tab.id]), groupId });
|
||||
// If a URL was provided, close any blank placeholder tabs left from group creation
|
||||
if (url) {
|
||||
const placeholders = existingTabs.filter(t =>
|
||||
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 };
|
||||
}
|
||||
|
||||
private async groupMove({ group, forward, backward }: GroupMoveArgs) {
|
||||
const groupId = await resolveGroupId(group);
|
||||
const groupInfo = await chrome.tabGroups.get(groupId);
|
||||
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
|
||||
const groupInfo = await getTabGroup(groupId);
|
||||
const allTabs = await api.tabs.query({ windowId: groupInfo.windowId });
|
||||
allTabs.sort((a, b) => a.index - b.index);
|
||||
|
||||
const blocks = buildTabBlocks(allTabs);
|
||||
@@ -98,7 +99,7 @@ export class GroupsCommands extends CommandGroup {
|
||||
nextBlock.groupId === null
|
||||
? currentBlock.startIndex + 1
|
||||
: nextBlock.endIndex - currentLength + 1;
|
||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
||||
await moveTabGroup(groupId, { index: targetIndex });
|
||||
} else if (backward) {
|
||||
const previousBlock = blocks[currentIdx - 1];
|
||||
if (!previousBlock) return { groupId, moved: false };
|
||||
@@ -106,7 +107,7 @@ export class GroupsCommands extends CommandGroup {
|
||||
previousBlock.groupId === null
|
||||
? currentBlock.startIndex - 1
|
||||
: previousBlock.startIndex;
|
||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
||||
await moveTabGroup(groupId, { index: targetIndex });
|
||||
}
|
||||
|
||||
return { groupId, moved: true };
|
||||
|
||||
@@ -1,4 +1,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 type { CommandEntry } from '../classes/CommandGroup';
|
||||
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);
|
||||
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) {
|
||||
let groupId;
|
||||
try {
|
||||
groupId = await resolveGroupId(groupNameOrId);
|
||||
// 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 =>
|
||||
t.id !== tab.id &&
|
||||
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
|
||||
);
|
||||
await chrome.tabs.group({ tabIds: [tab.id], groupId });
|
||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
||||
await groupTabIds({ tabIds: [tab.id], groupId });
|
||||
if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e;
|
||||
// Group doesn't exist — create it with the tab already in it
|
||||
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
||||
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
|
||||
groupId = await groupTabIds({ tabIds: [tab.id] });
|
||||
await updateTabGroup(groupId, { title: String(groupNameOrId) });
|
||||
}
|
||||
}
|
||||
return { id: tab.id, url: tab.url };
|
||||
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) {
|
||||
const tab = await chrome.tabs.update(tabId, { url });
|
||||
const tab = await api.tabs.update(tabId, { url });
|
||||
const deadline = Date.now() + 1000;
|
||||
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 || "";
|
||||
if (currentUrl === url || currentUrl.startsWith(url)) {
|
||||
return { id: current.id, url: currentUrl };
|
||||
@@ -65,35 +90,35 @@ export class NavigationCommands extends CommandGroup {
|
||||
|
||||
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
|
||||
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 };
|
||||
}
|
||||
|
||||
private async navBack({ tabId }: NavTabArgs) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.goBack(tab.id);
|
||||
await api.tabs.goBack(tab.id);
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
private async navForward({ tabId }: NavTabArgs) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.goForward(tab.id);
|
||||
await api.tabs.goForward(tab.id);
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
private async navFocus({ pattern }: NavFocusArgs) {
|
||||
// If pattern is a plain integer, treat it as a tab ID
|
||||
const asInt = parseInt(pattern);
|
||||
let match: chrome.tabs.Tab | undefined;
|
||||
let match: Tab | undefined;
|
||||
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
|
||||
match = await chrome.tabs.get(asInt);
|
||||
match = await api.tabs.get(asInt);
|
||||
} 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)));
|
||||
}
|
||||
if (!match) return null;
|
||||
await chrome.windows.update(match.windowId, { focused: true });
|
||||
await chrome.tabs.update(match.id, { active: true });
|
||||
await api.windows.update(match.windowId, { focused: true });
|
||||
await api.tabs.update(match.id, { active: true });
|
||||
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 interval = 200;
|
||||
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 || "";
|
||||
if (isBrowserErrorUrl(currentUrl)) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
|
||||
|
||||
@@ -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';
|
||||
|
||||
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]));
|
||||
return tabs
|
||||
.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.
|
||||
*/
|
||||
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const tabs = await api.tabs.query({});
|
||||
const groups = await queryTabGroups({});
|
||||
const sessionTabs = buildSessionSnapshot(tabs, groups);
|
||||
const signature = sessionSignature(sessionTabs);
|
||||
const session: StoredSession = {
|
||||
|
||||
@@ -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 { AutoSaveManager } from './autosave';
|
||||
import { captureCurrentSession } from './session-snapshot';
|
||||
@@ -32,18 +33,18 @@ export class SessionCommands extends CommandGroup {
|
||||
const { session, tabCount } = await captureCurrentSession();
|
||||
const sessions = await getSessions();
|
||||
sessions[name] = session;
|
||||
await chrome.storage.local.set({ sessions });
|
||||
await api.storage.local.set({ sessions });
|
||||
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) {
|
||||
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
|
||||
const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
|
||||
const entry = lazySessionTabs?.[tabId];
|
||||
if (!entry?.url) return false;
|
||||
delete lazySessionTabs[tabId];
|
||||
await chrome.storage.local.set({ lazySessionTabs });
|
||||
await chrome.tabs.update(Number(tabId), { url: entry.url });
|
||||
await api.storage.local.set({ lazySessionTabs });
|
||||
await api.tabs.update(Number(tabId), { url: entry.url });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -58,24 +59,24 @@ export class SessionCommands extends CommandGroup {
|
||||
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
|
||||
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
||||
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 || {};
|
||||
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
|
||||
|
||||
for (const [idx, entry] of sessionTabs.entries()) {
|
||||
throwIfJobCancelled(__job);
|
||||
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 });
|
||||
if (shouldLazy) {
|
||||
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
|
||||
} else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) {
|
||||
try { await chrome.tabs.discard(tab.id); } catch (_) {}
|
||||
} else if (discardBackgroundTabs && !entry.pinned && api.tabs.discard) {
|
||||
try { await api.tabs.discard(tab.id); } catch (_) {}
|
||||
}
|
||||
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
|
||||
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();
|
||||
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 });
|
||||
for (const { meta, tabIds } of groups.values()) {
|
||||
throwIfJobCancelled(__job);
|
||||
const restoredGroupId = await chrome.tabs.group({ tabIds });
|
||||
await chrome.tabGroups.update(restoredGroupId, {
|
||||
const restoredGroupId = await groupTabs({ tabIds });
|
||||
await updateTabGroup(restoredGroupId, {
|
||||
title: meta.title || "",
|
||||
color: normalizeGroupColor(meta.color),
|
||||
collapsed: Boolean(meta.collapsed),
|
||||
@@ -119,7 +120,7 @@ export class SessionCommands extends CommandGroup {
|
||||
const sessions = await getSessions();
|
||||
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
|
||||
delete sessions[name];
|
||||
await chrome.storage.local.set({ sessions });
|
||||
await api.storage.local.set({ sessions });
|
||||
return { name };
|
||||
}
|
||||
|
||||
@@ -154,16 +155,18 @@ export class SessionCommands extends CommandGroup {
|
||||
if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`);
|
||||
const stored = session as object as StoredSession;
|
||||
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 };
|
||||
}
|
||||
|
||||
private async clientsList() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const manifest = api.runtime.getManifest();
|
||||
const alias = await getProfileAlias();
|
||||
const browserInfo = api.runtime.getBrowserInfo ? await api.runtime.getBrowserInfo() : null;
|
||||
const userAgent = navigator.userAgent;
|
||||
return [{
|
||||
name: "Chrome",
|
||||
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown",
|
||||
name: browserInfo?.name || (userAgent.includes("Firefox/") ? "Firefox" : "Chrome"),
|
||||
version: browserInfo?.version || userAgent.match(/(?:Chrome|Firefox)\/([\d.]+)/)?.[1] || "unknown",
|
||||
platform: navigator.platform,
|
||||
extensionVersion: manifest.version,
|
||||
profile: alias,
|
||||
@@ -171,7 +174,7 @@ export class SessionCommands extends CommandGroup {
|
||||
}
|
||||
|
||||
private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) {
|
||||
await chrome.storage.local.set({ profileAlias: alias });
|
||||
await api.storage.local.set({ profileAlias: alias });
|
||||
return { alias };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
@@ -17,7 +18,7 @@ export class TabsQueryCommands extends CommandGroup {
|
||||
};
|
||||
|
||||
private async tabsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const windows = await api.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
const tabs = [];
|
||||
for (const w of windows) {
|
||||
@@ -34,7 +35,7 @@ export class TabsQueryCommands extends CommandGroup {
|
||||
}
|
||||
|
||||
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];
|
||||
if (!tab) {
|
||||
throw new Error(`No active tab found for window ${windowId}`);
|
||||
@@ -43,24 +44,24 @@ export class TabsQueryCommands extends CommandGroup {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
return all.length;
|
||||
}
|
||||
|
||||
private async tabsQuery({ search }: TabsQueryArgs) {
|
||||
const q = search.toLowerCase();
|
||||
const all = await chrome.tabs.query({});
|
||||
const all = await api.tabs.query({});
|
||||
return all.filter(t =>
|
||||
(t.url && t.url.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 = {}) {
|
||||
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 regex = new RegExp(pattern);
|
||||
let lastUrl = tab.url || tab.pendingUrl || "";
|
||||
@@ -81,7 +82,7 @@ export class TabsQueryCommands extends CommandGroup {
|
||||
if (matches(lastUrl)) return tabInfo(tab);
|
||||
|
||||
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 || "";
|
||||
lastStatus = t.status || "unknown";
|
||||
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
|
||||
|
||||
@@ -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 type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types';
|
||||
@@ -23,7 +25,7 @@ export class TabsMutationCommands extends CommandGroup {
|
||||
return runLargeOperation("tabs.close", async () => {
|
||||
let toClose: number[] = [];
|
||||
if (duplicates) {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const windows = await api.windows.getAll({ populate: true });
|
||||
const seen = new Set<string>();
|
||||
for (const w of windows) {
|
||||
for (const t of w.tabs || []) {
|
||||
@@ -34,7 +36,7 @@ export class TabsMutationCommands extends CommandGroup {
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
} else if (tabIds?.length) {
|
||||
toClose = tabIds.filter(id => id != null);
|
||||
@@ -42,17 +44,17 @@ export class TabsMutationCommands extends CommandGroup {
|
||||
toClose = [tabId];
|
||||
}
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 (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
|
||||
else moveProps.index = Math.max(0, tab.index - 1);
|
||||
} 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.
|
||||
await chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties);
|
||||
await api.tabs.move(tabId, moveProps as TabMoveProperties);
|
||||
if (groupId != null) {
|
||||
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId });
|
||||
await groupTabs({ tabIds: asTabIds([tabId]), groupId });
|
||||
}
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
private async tabsActive({ tabId }: TabIdArgs) {
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
await api.tabs.update(tabId, { active: true });
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ export class TabsMutationCommands extends CommandGroup {
|
||||
|
||||
private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) {
|
||||
return runLargeOperation("tabs.sort", async () => {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const windows = await api.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
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));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
||||
await api.tabs.move(sorted[i].id, { index: i });
|
||||
moved++;
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
|
||||
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));
|
||||
}
|
||||
|
||||
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
|
||||
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 target = movableWindows.find(w => w.focused) || movableWindows[0];
|
||||
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 throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
||||
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 });
|
||||
}
|
||||
return { moved, skippedAudibleWindows: all.length - movableWindows.length };
|
||||
@@ -135,42 +137,42 @@ export class TabsMutationCommands extends CommandGroup {
|
||||
}
|
||||
|
||||
private async tabsPin({ tabId }: TabIdArgs) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: true });
|
||||
const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
|
||||
await api.tabs.update(tab.id, { pinned: true });
|
||||
return { tabId: tab.id, pinned: true };
|
||||
}
|
||||
|
||||
private async tabsUnpin({ tabId }: TabIdArgs) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: false });
|
||||
const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
|
||||
await api.tabs.update(tab.id, { pinned: false });
|
||||
return { tabId: tab.id, pinned: false };
|
||||
}
|
||||
|
||||
private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) {
|
||||
let windowId: number | undefined;
|
||||
if (tabId) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
const tab = await api.tabs.get(tabId);
|
||||
await api.tabs.update(tabId, { active: true });
|
||||
windowId = tab.windowId;
|
||||
} else {
|
||||
const tab = await getActiveTab();
|
||||
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;
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
|
||||
const dataUrl = await api.tabs.captureVisibleTab(windowId, opts);
|
||||
return { dataUrl, format };
|
||||
}
|
||||
|
||||
private async tabsMute({ tabId }: TabIdArgs) {
|
||||
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 };
|
||||
}
|
||||
|
||||
private async tabsUnmute({ tabId }: TabIdArgs) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
import type { WindowCreateData } from '../types';
|
||||
import { getAliases } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
@@ -13,7 +15,7 @@ export class WindowsCommands extends CommandGroup {
|
||||
};
|
||||
|
||||
private async windowsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const windows = await api.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
return windows.map(w => ({
|
||||
id: w.id,
|
||||
@@ -27,19 +29,19 @@ export class WindowsCommands extends CommandGroup {
|
||||
private async windowsRename({ windowId, name }: WindowsRenameArgs) {
|
||||
const aliases = await getAliases();
|
||||
aliases[windowId] = name;
|
||||
await chrome.storage.local.set({ windowAliases: aliases });
|
||||
await api.storage.local.set({ windowAliases: aliases });
|
||||
return { windowId, name };
|
||||
}
|
||||
|
||||
private async windowsClose({ windowId }: WindowsCloseArgs) {
|
||||
await chrome.windows.remove(windowId);
|
||||
await api.windows.remove(windowId);
|
||||
return { windowId };
|
||||
}
|
||||
|
||||
private async windowsOpen({ url }: WindowsOpenArgs) {
|
||||
const createData: chrome.windows.CreateData = { focused: true };
|
||||
const createData: WindowCreateData = { focused: true };
|
||||
if (url) createData.url = url;
|
||||
const w = await chrome.windows.create(createData);
|
||||
const w = await api.windows.create(createData);
|
||||
return { id: w.id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { TabGroupColor } from '../types';
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
// Tab-group resolution and normalization helpers.
|
||||
import { queryTabGroups } from './tab-groups';
|
||||
|
||||
export async function resolveGroupId(nameOrId: string | number): Promise<number> {
|
||||
const asInt = parseInt(String(nameOrId));
|
||||
if (!isNaN(asInt)) return asInt;
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const groups = await queryTabGroups({});
|
||||
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
|
||||
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
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"]);
|
||||
return (allowed.has(color as string) ? color : "grey") as chrome.tabGroups.Color;
|
||||
return (allowed.has(color as string) ? color : "grey") as TabGroupColor;
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from './throttle';
|
||||
export * from './scripting';
|
||||
export * from './tab-helpers';
|
||||
export * from './group-helpers';
|
||||
export * from './tab-groups';
|
||||
export * from './storage';
|
||||
|
||||
@@ -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 { sleep } from './throttle';
|
||||
import type { Serializable } from '../types';
|
||||
|
||||
export async function executeScript<Args extends Serializable[], Result>(
|
||||
options: chrome.scripting.ScriptInjection<Args, Result>,
|
||||
options: ScriptInjection<Args>,
|
||||
retries = 3,
|
||||
): Promise<chrome.scripting.InjectionResult<chrome.scripting.Awaited<Result>>[]> {
|
||||
): Promise<ScriptInjectionResult<Result>[]> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await chrome.scripting.executeScript(options);
|
||||
return await api.scripting.executeScript(options);
|
||||
} catch (e) {
|
||||
if (i < retries - 1 && isTransientScriptError(e)) {
|
||||
await sleep(300);
|
||||
|
||||
@@ -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';
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -20,11 +21,11 @@ export function getSessionTabs(session: StoredSession | undefined | null): Sessi
|
||||
}
|
||||
|
||||
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 || {};
|
||||
}
|
||||
|
||||
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 || {};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
// resolution, and HTML fetching.
|
||||
import { isBrowserErrorUrl, isErrorPageScriptError } from './errors';
|
||||
@@ -5,8 +7,8 @@ import { executeScript } from './scripting';
|
||||
import type { TabBlock } from '../types';
|
||||
|
||||
/**
|
||||
* Narrow a plain id array to the non-empty-tuple shape that chrome.tabs.group /
|
||||
* chrome.tabs.ungroup declare. The runtime happily accepts any array (including
|
||||
* Narrow a plain id array to the non-empty-tuple shape that api.tabs.group /
|
||||
* api.tabs.ungroup declare. The runtime happily accepts any array (including
|
||||
* a single element); the published @types/chrome just over-constrain the param
|
||||
* 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[]];
|
||||
}
|
||||
|
||||
export function tabInfo(t: chrome.tabs.Tab) {
|
||||
export function tabInfo(t: Tab) {
|
||||
return {
|
||||
id: t.id,
|
||||
windowId: t.windowId,
|
||||
@@ -36,16 +38,16 @@ export function isScriptableUrl(url: string | undefined | null): boolean {
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
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 chooseTab = (predicate: (tab: chrome.tabs.Tab) => boolean) => activeTabs.find(predicate);
|
||||
const byFocusAndScriptable = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
|
||||
const byScriptable = (tab: chrome.tabs.Tab) => isScriptableUrl(tab.url || tab.pendingUrl || "");
|
||||
const byFocus = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId);
|
||||
const chooseTab = (predicate: (tab: Tab) => boolean) => activeTabs.find(predicate);
|
||||
const byFocusAndScriptable = (tab: Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
|
||||
const byScriptable = (tab: Tab) => isScriptableUrl(tab.url || tab.pendingUrl || "");
|
||||
const byFocus = (tab: Tab) => focusedWindowIds.has(tab.windowId);
|
||||
|
||||
return chooseTab(byFocusAndScriptable)
|
||||
|| chooseTab(byScriptable)
|
||||
@@ -54,8 +56,8 @@ export async function getActiveTab() {
|
||||
}
|
||||
|
||||
/** 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 }> {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: Tab; url: string }> {
|
||||
const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
|
||||
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) {
|
||||
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) {
|
||||
throw new Error(
|
||||
`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];
|
||||
}
|
||||
|
||||
export function buildTabBlocks(tabs: chrome.tabs.Tab[]): TabBlock[] {
|
||||
export function buildTabBlocks(tabs: Tab[]): TabBlock[] {
|
||||
const blocks: TabBlock[] = [];
|
||||
for (const tab of tabs) {
|
||||
const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
// Large-operation throttling, performance profile, and job-progress helpers.
|
||||
import type { Job, JobProgressUpdate } from '../types';
|
||||
|
||||
@@ -16,7 +17,7 @@ function debugLargeOperation(message: string) {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -36,14 +37,14 @@ export async function runLargeOperation<T>(name: string, fn: () => Promise<T>):
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
export async function setPerformanceProfile(profile: string) {
|
||||
const allowed = new Set(["auto", "normal", "gentle", "ultra"]);
|
||||
const performanceProfile = allowed.has(profile) ? profile : "auto";
|
||||
await chrome.storage.local.set({ performanceProfile });
|
||||
await api.storage.local.set({ performanceProfile });
|
||||
return { performanceProfile };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* the native connection.
|
||||
*/
|
||||
|
||||
import { webExtApi as api } from './browser-api';
|
||||
import { JobManager } from './classes/JobManager';
|
||||
import { assembleRegistry } from './classes/CommandRegistry';
|
||||
import { NativeConnection } from './classes/NativeConnection';
|
||||
@@ -15,7 +16,7 @@ const jobs = new JobManager();
|
||||
const ctx: CommandContext = { jobs };
|
||||
const { registry, session } = assembleRegistry(ctx);
|
||||
|
||||
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
|
||||
api.tabs.onActivated.addListener(async ({ tabId }) => {
|
||||
await session.activateLazyTab(tabId);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 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 {
|
||||
selector?: string;
|
||||
text?: string;
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from './json';
|
||||
export * from './jobs';
|
||||
export * from './session';
|
||||
export * from './tabs';
|
||||
export * from './webextension';
|
||||
export * from './messages';
|
||||
export * from './command-args';
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
Generated
+8
@@ -7,6 +7,7 @@
|
||||
"name": "browser-cli-extension-build",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"@types/firefox-webext-browser": "^143.0.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
@@ -481,6 +482,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
|
||||
+3
-1
@@ -8,10 +8,12 @@
|
||||
"test:extension": "npm run build:tests && node --disable-warning=ExperimentalWarning --test extension/test-dist/*.test.mjs",
|
||||
"check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension",
|
||||
"package:extension": "npm run build:extension && python scripts/package_extension.py",
|
||||
"package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore"
|
||||
"package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore",
|
||||
"package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"@types/firefox-webext-browser": "^143.0.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
+9
-2
@@ -1,8 +1,10 @@
|
||||
[project]
|
||||
name = "browser-cli"
|
||||
version = "0.14.2"
|
||||
name = "real-browser-cli"
|
||||
version = "0.15.2"
|
||||
description = "Control your real running browser from the terminal or Python SDK"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "Daniel Dolezal" }]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"click>=8",
|
||||
@@ -11,6 +13,11 @@ dependencies = [
|
||||
"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]
|
||||
# Better/faster remote response compression than the stdlib zlib/gzip fallback.
|
||||
fast = ["zstandard>=0.22"]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package the Chrome extension.
|
||||
"""Package the browser extension.
|
||||
|
||||
Default builds a testing/unpacked-style archive that keeps manifest.key so the
|
||||
extension ID stays stable for native messaging. ``--webstore`` writes the same
|
||||
runtime files but strips ``key`` from manifest.json because the Chrome Web Store
|
||||
rejects that field.
|
||||
Chromium extension ID stays stable for native messaging. ``--webstore`` writes
|
||||
the same runtime files but strips ``key`` from manifest.json because the Chrome
|
||||
Web Store rejects that field. ``--firefox`` writes a Firefox-friendly archive
|
||||
with the Gecko extension ID and without Chromium-only manifest keys.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -26,10 +27,17 @@ RUNTIME_FILES = (
|
||||
)
|
||||
RUNTIME_DIRS = ("icons",)
|
||||
|
||||
def _read_manifest(webstore: bool) -> dict:
|
||||
def _read_manifest(webstore: bool, firefox: bool) -> dict:
|
||||
manifest = json.loads((EXTENSION_DIR / "manifest.json").read_text(encoding="utf-8"))
|
||||
if webstore:
|
||||
if webstore or firefox:
|
||||
manifest.pop("key", None)
|
||||
if firefox:
|
||||
manifest["permissions"] = [p for p in manifest.get("permissions", []) if p != "windows"]
|
||||
manifest["background"] = {"scripts": ["background.js"]}
|
||||
gecko = manifest.setdefault("browser_specific_settings", {}).setdefault("gecko", {})
|
||||
gecko["strict_min_version"] = "140.0"
|
||||
manifest.setdefault("browser_specific_settings", {}).setdefault("gecko_android", {})["strict_min_version"] = "142.0"
|
||||
gecko["data_collection_permissions"] = {"required": ["none"]}
|
||||
return manifest
|
||||
|
||||
def _copy_tree(src: Path, dst: Path) -> None:
|
||||
@@ -37,10 +45,12 @@ def _copy_tree(src: Path, dst: Path) -> None:
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
|
||||
def package_extension(*, webstore: bool = False, out: Path | None = None) -> Path:
|
||||
manifest = _read_manifest(webstore)
|
||||
def package_extension(*, webstore: bool = False, firefox: bool = False, out: Path | None = None) -> Path:
|
||||
if webstore and firefox:
|
||||
raise ValueError("--webstore and --firefox are mutually exclusive")
|
||||
manifest = _read_manifest(webstore, firefox)
|
||||
version = manifest["version"]
|
||||
suffix = "webstore" if webstore else "testing"
|
||||
suffix = "firefox" if firefox else "webstore" if webstore else "testing"
|
||||
out = out or DIST_DIR / f"browser-cli-extension-{suffix}-v{version}.zip"
|
||||
staging = DIST_DIR / f"extension-package-{suffix}"
|
||||
|
||||
@@ -70,9 +80,10 @@ def package_extension(*, webstore: bool = False, out: Path | None = None) -> Pat
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Package browser-cli extension")
|
||||
parser.add_argument("--webstore", action="store_true", help="strip manifest.key for Chrome Web Store upload")
|
||||
parser.add_argument("--firefox", action="store_true", help="build a Firefox-friendly extension zip")
|
||||
parser.add_argument("--out", type=Path, default=None, help="output zip path")
|
||||
args = parser.parse_args()
|
||||
print(package_extension(webstore=args.webstore, out=args.out))
|
||||
print(package_extension(webstore=args.webstore, firefox=args.firefox, out=args.out))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+30
-1
@@ -87,7 +87,7 @@ def test_install_help_lists_supported_browsers():
|
||||
result = CliRunner().invoke(main, ["install", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
|
||||
assert "[chrome|chromium|brave|edge|vivaldi|firefox]" in result.output
|
||||
|
||||
def test_install_writes_testing_and_webstore_allowed_origins(tmp_path):
|
||||
manifests = []
|
||||
@@ -117,6 +117,35 @@ def test_install_writes_testing_and_webstore_allowed_origins(tmp_path):
|
||||
assert "Testing extension ID" in result.output
|
||||
assert "Chrome Web Store extension ID" in result.output
|
||||
|
||||
def test_install_writes_firefox_allowed_extensions(tmp_path):
|
||||
manifests = []
|
||||
|
||||
def fake_install_manifest(_browser, _host_exe, manifest):
|
||||
manifests.append(manifest)
|
||||
return [tmp_path / "com.browsercli.host.json"]
|
||||
|
||||
with patch("browser_cli.commands.install.native_host_exe", return_value=tmp_path / "browser-cli-native-host"), patch(
|
||||
"browser_cli.commands.install.write_native_host_exe"
|
||||
), patch("browser_cli.commands.install._install_manifest", side_effect=fake_install_manifest):
|
||||
result = CliRunner().invoke(main, ["install", "firefox"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert manifests == [
|
||||
{
|
||||
"name": "com.browsercli.host",
|
||||
"description": "browser-cli native messaging host",
|
||||
"path": str(tmp_path / "browser-cli-native-host"),
|
||||
"type": "stdio",
|
||||
"allowed_extensions": ["browser-cli@yiprawr.dev"],
|
||||
}
|
||||
]
|
||||
assert "about:debugging#/runtime/this-firefox" in result.output
|
||||
assert "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):
|
||||
writes = []
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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():
|
||||
# core.ts was split into a core/ subfolder during the structure refactor:
|
||||
# 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 "itemCount >= 300" 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.
|
||||
assert "processInBatches" 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 "getLargeOperationThrottle" 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 "activateLazyTab" 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 "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():
|
||||
# The autosave lifecycle moved out of session.ts into a dedicated
|
||||
# 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
|
||||
# AutoSaveManager binds the handlers as instance fields (this.*), so the
|
||||
# add/removeListener references stay identity-stable across enable/disable.
|
||||
assert "chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave
|
||||
assert "chrome.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave
|
||||
assert "chrome.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave
|
||||
assert "api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave
|
||||
assert "api.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave
|
||||
assert "api.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave
|
||||
assert "if (!(\"url\" in changeInfo)) return;" in autosave
|
||||
assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave
|
||||
assert "clearTimeout(this.autoSaveTimer)" in autosave
|
||||
|
||||
@@ -19,6 +19,9 @@ def _fake_extension(tmp_path: Path) -> Path:
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "1.2.3",
|
||||
"permissions": ["tabs", "tabGroups", "windows", "nativeMessaging"],
|
||||
"background": {"service_worker": "background.js"},
|
||||
"browser_specific_settings": {"gecko": {"id": "browser-cli@yiprawr.dev"}},
|
||||
"key": "test-key",
|
||||
}), encoding="utf-8")
|
||||
for name in ("background.js", "content-dispatch.js", "content.js"):
|
||||
@@ -47,6 +50,24 @@ def test_webstore_package_strips_manifest_key(tmp_path):
|
||||
assert "content.js" in names
|
||||
assert "icons/icon-128.png" in names
|
||||
|
||||
def test_firefox_package_strips_chromium_key_and_firefox_incompatible_permission(tmp_path):
|
||||
packager = _packager_with_fake_extension(tmp_path)
|
||||
out = packager.package_extension(firefox=True, out=tmp_path / "firefox.zip")
|
||||
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
|
||||
assert "key" not in manifest
|
||||
assert manifest["browser_specific_settings"]["gecko"]["id"] == "browser-cli@yiprawr.dev"
|
||||
assert "tabGroups" in manifest["permissions"]
|
||||
assert "windows" not in manifest["permissions"]
|
||||
assert "nativeMessaging" in manifest["permissions"]
|
||||
assert "service_worker" not in manifest["background"]
|
||||
assert manifest["background"]["scripts"] == ["background.js"]
|
||||
assert manifest["browser_specific_settings"]["gecko"]["strict_min_version"] == "140.0"
|
||||
assert manifest["browser_specific_settings"]["gecko_android"]["strict_min_version"] == "142.0"
|
||||
assert manifest["browser_specific_settings"]["gecko"]["data_collection_permissions"] == {"required": ["none"]}
|
||||
|
||||
def test_local_package_keeps_manifest_key(tmp_path):
|
||||
packager = _packager_with_fake_extension(tmp_path)
|
||||
out = packager.package_extension(webstore=False, out=tmp_path / "local.zip")
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["chrome"],
|
||||
"types": ["chrome", "firefox-webext-browser"],
|
||||
"allowJs": false,
|
||||
"strict": false,
|
||||
"noImplicitAny": true,
|
||||
|
||||
@@ -2,46 +2,6 @@ version = 1
|
||||
revision = 3
|
||||
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]]
|
||||
name = "cffi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
|
||||
Reference in New Issue
Block a user