feat(extension): add Firefox WebExtension support
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 47s
Build & Publish Package / publish (push) Successful in 46s
Package Extension / package-extension (push) Successful in 59s
Testing / test (push) Failing after 50s

- Add a neutral WebExtension API adapter that uses Firefox browser.* or Chromium chrome.* without mutating globals.
- Switch extension runtime code to the adapter and add Firefox-specific typings for tabs, windows, tab groups, storage, scripting, and native messaging ports.
- Fix Firefox temporary add-on instructions to load the packaged manifest with background.scripts instead of the Chromium service worker manifest.
- Detect Firefox in clients.list via runtime.getBrowserInfo and keep Chromium user-agent fallback support.
- Make navigate.open wait briefly for Firefox to replace initial about:blank with the requested URL.
- Add JS coverage for API selection, clients.list browser detection, and Firefox navigate.open URL polling.
- Bump package and extension version to 0.15.2.
This commit is contained in:
2026-06-14 19:09:10 +02:00
parent 523108e442
commit 477a00db1a
37 changed files with 526 additions and 183 deletions
+2
View File
@@ -522,6 +522,8 @@ npm run package:extension:firefox # Firefox zip, strips manifest.key and Firef
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For Firefox, use the `*-firefox-*` zip. 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.
--- ---
## Limitations ## Limitations
+8 -3
View File
@@ -78,9 +78,14 @@ def cmd_install(browser):
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser") console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]") console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
if browser == "firefox": if browser == "firefox":
console.print(" 2. Click [bold]Load Temporary Add-on...[/bold]") repo_root = Path(__file__).parent.parent.parent
console.print(f" 3. Select: [cyan]{Path(__file__).parent.parent.parent / 'extension' / 'manifest.json'}[/cyan]") firefox_manifest = repo_root / "dist" / "extension-package-firefox" / "manifest.json"
console.print(f" 4. Firefox extension ID is [cyan]{FIREFOX_EXTENSION_ID}[/cyan]") 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") console.print(" Note: Firefox support is experimental; tab-group commands require browser tab group APIs.\n")
else: else:
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)") console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.15.1", "version": "0.15.2",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+31
View File
@@ -0,0 +1,31 @@
/**
* Cross-browser WebExtension API entry point.
*
* Firefox exposes the Promise-based WebExtension API as `browser.*`.
* Chromium exposes the same extension API as `chrome.*`.
* Runtime modules import this neutral adapter as `api`, so Firefox uses its
* native `browser` object and Chromium uses its native `chrome` object. No
* browser-specific global is faked or overwritten.
*/
import type { WebExtensionApi } from './types';
type WebExtensionGlobal = {
browser?: typeof browser;
chrome?: typeof chrome;
};
function currentApi(): typeof browser | typeof chrome {
const webExtensionGlobal = globalThis as object as WebExtensionGlobal;
const api = webExtensionGlobal.browser || webExtensionGlobal.chrome;
if (!api) {
throw new Error("WebExtension API is not available: expected browser.* or chrome.*");
}
return api;
}
export const webExtApi = new Proxy({}, {
get(_target: object, property: string | symbol) {
return currentApi()[property as keyof ReturnType<typeof currentApi>];
},
}) as object as WebExtensionApi;
+2 -1
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { CommandGroup } from './CommandGroup'; import { CommandGroup } from './CommandGroup';
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup'; import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
import { NavigationCommands } from '../commands/navigation'; import { NavigationCommands } from '../commands/navigation';
@@ -74,7 +75,7 @@ export class CommandRegistry {
/** /**
* Builds the registry and registers every command group. The SessionCommands * Builds the registry and registers every command group. The SessionCommands
* instance is returned alongside because index.ts wires its lifecycle methods * instance is returned alongside because index.ts wires its lifecycle methods
* (chrome.tabs.onActivated → activateLazyTab) and NativeConnection references it * (api.tabs.onActivated → activateLazyTab) and NativeConnection references it
* for the clients.rename_profile reconnect side-effect. * for the clients.rename_profile reconnect side-effect.
*/ */
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } { export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
+6 -5
View File
@@ -1,7 +1,8 @@
import { webExtApi as api } from '../browser-api';
/** /**
* Background-job retention helpers + the JobManager that owns the live job map. * Background-job retention helpers + the JobManager that owns the live job map.
* *
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of chrome.* / * `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of api.* /
* service-worker side effects so the retention logic (memory-leak guard) can be * service-worker side effects so the retention logic (memory-leak guard) can be
* unit-tested in isolation. * unit-tested in isolation.
*/ */
@@ -16,7 +17,7 @@ export const MAX_FINISHED_JOBS = 20;
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a // Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
// dead tab), finalize the job as an error so its persist interval stops instead // dead tab), finalize the job as an error so its persist interval stops instead
// of writing to chrome.storage.local every second forever. // of writing to api.storage.local every second forever.
export const JOB_TIMEOUT_MS = 5 * 60 * 1000; export const JOB_TIMEOUT_MS = 5 * 60 * 1000;
/** /**
@@ -65,11 +66,11 @@ export class JobManager {
const running = all.filter(job => job.status === "running"); const running = all.filter(job => job.status === "running");
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS); const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest); const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest);
await chrome.storage.local.set({ recentJobs }); await api.storage.local.set({ recentJobs });
} }
// Evict the oldest finished jobs once their count exceeds the retention cap. // Evict the oldest finished jobs once their count exceeds the retention cap.
// Recent finished jobs remain queryable via chrome.storage.local (persistJobs) // Recent finished jobs remain queryable via api.storage.local (persistJobs)
// even after eviction from the in-memory Map. // even after eviction from the in-memory Map.
private pruneJobs() { private pruneJobs() {
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS); pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
@@ -143,7 +144,7 @@ export class JobManager {
async status({ jobId }: { jobId?: string }) { async status({ jobId }: { jobId?: string }) {
const job = this.jobs.get(jobId); const job = this.jobs.get(jobId);
if (job) return { ...job }; if (job) return { ...job };
const { recentJobs } = await chrome.storage.local.get<{ recentJobs?: Job[] }>("recentJobs"); const { recentJobs } = await api.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
const stored = (recentJobs || []).find(entry => entry.id === jobId); const stored = (recentJobs || []).find(entry => entry.id === jobId);
if (!stored) throw new Error(`Job '${jobId}' not found`); if (!stored) throw new Error(`Job '${jobId}' not found`);
return stored; return stored;
+15 -14
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
/** /**
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound * Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
* message router that hands commands to the CommandRegistry. * message router that hands commands to the CommandRegistry.
@@ -6,7 +7,7 @@
import { getErrorMessage, getProfileAlias } from '../core'; import { getErrorMessage, getProfileAlias } from '../core';
import type { CommandRegistry } from './CommandRegistry'; import type { CommandRegistry } from './CommandRegistry';
import type { SessionCommands } from '../commands/session'; import type { SessionCommands } from '../commands/session';
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable } from '../types'; import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable, RuntimePort } from '../types';
const NATIVE_HOST = "com.browsercli.host"; const NATIVE_HOST = "com.browsercli.host";
const DEBUG_LOG = false; const DEBUG_LOG = false;
@@ -16,7 +17,7 @@ function debugLog(...args: Serializable[]) {
} }
export class NativeConnection { export class NativeConnection {
private port: chrome.runtime.Port | null = null; private port: RuntimePort | null = null;
private keepaliveEnabled = true; private keepaliveEnabled = true;
constructor( constructor(
@@ -26,17 +27,17 @@ export class NativeConnection {
/** Registers all runtime listeners and opens the initial connection. */ /** Registers all runtime listeners and opens the initial connection. */
start() { start() {
chrome.runtime.onInstalled.addListener(() => this.connect()); api.runtime.onInstalled.addListener(() => this.connect());
chrome.runtime.onStartup.addListener(() => this.connect()); api.runtime.onStartup.addListener(() => this.connect());
chrome.runtime.onSuspend.addListener(() => { api.runtime.onSuspend.addListener(() => {
this.disconnectPort({ sendBye: true }); this.disconnectPort({ sendBye: true });
}); });
chrome.windows.onCreated.addListener(() => { api.windows.onCreated.addListener(() => {
this.keepaliveEnabled = true; this.keepaliveEnabled = true;
if (!this.port) this.connect(); if (!this.port) this.connect();
}); });
chrome.windows.onRemoved.addListener(async () => { api.windows.onRemoved.addListener(async () => {
const windows = await chrome.windows.getAll({}); const windows = await api.windows.getAll({});
if (windows.length > 0) return; if (windows.length > 0) return;
this.keepaliveEnabled = false; this.keepaliveEnabled = false;
@@ -46,15 +47,15 @@ export class NativeConnection {
// Reconnect poll — wakes the worker to re-establish the native port if it // Reconnect poll — wakes the worker to re-establish the native port if it
// dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4) // dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4)
// are silently clamped and log a warning, so we set it explicitly. // are silently clamped and log a warning, so we set it explicitly.
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 }); api.alarms.create("keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => { api.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") { if (alarm.name === "keepalive") {
if (!this.port && this.keepaliveEnabled) this.connect(); if (!this.port && this.keepaliveEnabled) this.connect();
} }
}); });
} }
private sendControlMessage(targetPort: chrome.runtime.Port | null, message: ControlMessage) { private sendControlMessage(targetPort: RuntimePort | null, message: ControlMessage) {
if (!targetPort) return; if (!targetPort) return;
try { try {
targetPort.postMessage(message); targetPort.postMessage(message);
@@ -63,7 +64,7 @@ export class NativeConnection {
} }
} }
private sendResponse(targetPort: chrome.runtime.Port | null, message: ResponseMessage) { private sendResponse(targetPort: RuntimePort | null, message: ResponseMessage) {
if (!targetPort) return; if (!targetPort) return;
try { try {
targetPort.postMessage(message); targetPort.postMessage(message);
@@ -90,12 +91,12 @@ export class NativeConnection {
private async connect() { private async connect() {
if (this.port || !this.keepaliveEnabled) return; if (this.port || !this.keepaliveEnabled) return;
try { try {
const nativePort = chrome.runtime.connectNative(NATIVE_HOST); const nativePort = api.runtime.connectNative(NATIVE_HOST);
this.port = nativePort; this.port = nativePort;
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg)); nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
nativePort.onDisconnect.addListener(() => { nativePort.onDisconnect.addListener(() => {
if (this.port === nativePort) this.port = null; if (this.port === nativePort) this.port = null;
const err = chrome.runtime.lastError; const err = api.runtime.lastError;
if (err) console.warn("[browser-cli] Native host disconnected:", err.message); if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
}); });
// Send hello so native host knows which profile/alias this is // Send hello so native host knows which profile/alias this is
+20 -18
View File
@@ -1,3 +1,5 @@
import type { TabUpdateInfo } from '../types';
import { webExtApi as api } from '../browser-api';
import { getSessions, runLargeOperation, tabGroupsOnUpdated } from '../core'; import { getSessions, runLargeOperation, tabGroupsOnUpdated } from '../core';
import { captureCurrentSession } from './session-snapshot'; import { captureCurrentSession } from './session-snapshot';
@@ -16,31 +18,31 @@ export class AutoSaveManager {
readonly autoSaveHandler = async (): Promise<void> => { readonly autoSaveHandler = async (): Promise<void> => {
await this.scheduleAutoSave(); await this.scheduleAutoSave();
}; };
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise<void> => { readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: TabUpdateInfo = {}): Promise<void> => {
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure. // Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
if (!("url" in changeInfo)) return; if (!("url" in changeInfo)) return;
await this.scheduleAutoSave(); await this.scheduleAutoSave();
}; };
async setEnabled(enabled: boolean) { async setEnabled(enabled: boolean) {
await chrome.storage.local.set({ autoSave: enabled }); await api.storage.local.set({ autoSave: enabled });
chrome.tabs.onCreated.removeListener(this.autoSaveHandler); api.tabs.onCreated.removeListener(this.autoSaveHandler);
chrome.tabs.onRemoved.removeListener(this.autoSaveHandler); api.tabs.onRemoved.removeListener(this.autoSaveHandler);
chrome.tabs.onMoved.removeListener(this.autoSaveHandler); api.tabs.onMoved.removeListener(this.autoSaveHandler);
chrome.tabs.onAttached.removeListener(this.autoSaveHandler); api.tabs.onAttached.removeListener(this.autoSaveHandler);
chrome.tabs.onDetached.removeListener(this.autoSaveHandler); api.tabs.onDetached.removeListener(this.autoSaveHandler);
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler); api.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
tabGroupsOnUpdated()?.removeListener(this.autoSaveHandler); tabGroupsOnUpdated()?.removeListener(this.autoSaveHandler);
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer); if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = null; this.autoSaveTimer = null;
this.autoSavePending = false; this.autoSavePending = false;
if (enabled) { if (enabled) {
chrome.tabs.onCreated.addListener(this.autoSaveHandler); api.tabs.onCreated.addListener(this.autoSaveHandler);
chrome.tabs.onRemoved.addListener(this.autoSaveHandler); api.tabs.onRemoved.addListener(this.autoSaveHandler);
chrome.tabs.onMoved.addListener(this.autoSaveHandler); api.tabs.onMoved.addListener(this.autoSaveHandler);
chrome.tabs.onAttached.addListener(this.autoSaveHandler); api.tabs.onAttached.addListener(this.autoSaveHandler);
chrome.tabs.onDetached.addListener(this.autoSaveHandler); api.tabs.onDetached.addListener(this.autoSaveHandler);
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler); api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
tabGroupsOnUpdated()?.addListener(this.autoSaveHandler); tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
} }
return { enabled }; return { enabled };
@@ -48,12 +50,12 @@ export class AutoSaveManager {
private async saveAutoSessionIfChanged() { private async saveAutoSessionIfChanged() {
const { session, signature, tabCount } = await captureCurrentSession(); const { session, signature, tabCount } = await captureCurrentSession();
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature"); const { autoSaveSignature } = await api.storage.local.get("autoSaveSignature");
if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount }; if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount };
const sessions = await getSessions(); const sessions = await getSessions();
sessions.__auto__ = session; sessions.__auto__ = session;
await chrome.storage.local.set({ sessions, autoSaveSignature: signature }); await api.storage.local.set({ sessions, autoSaveSignature: signature });
return { skipped: false, tabs: tabCount }; return { skipped: false, tabs: tabCount };
} }
@@ -64,7 +66,7 @@ export class AutoSaveManager {
} }
this.autoSaveInFlight = true; this.autoSaveInFlight = true;
try { try {
const { autoSave } = await chrome.storage.local.get("autoSave"); const { autoSave } = await api.storage.local.get("autoSave");
if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged()); if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged());
} finally { } finally {
this.autoSaveInFlight = false; this.autoSaveInFlight = false;
@@ -76,7 +78,7 @@ export class AutoSaveManager {
} }
private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) { private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) {
const { autoSave } = await chrome.storage.local.get("autoSave"); const { autoSave } = await api.storage.local.get("autoSave");
if (!autoSave) return; if (!autoSave) return;
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer); if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs); this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs);
+3 -1
View File
@@ -1,9 +1,11 @@
import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core'; import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types'; import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types';
function fallbackForErrorPageDomOp(funcName: string, tab: chrome.tabs.Tab): Serializable { function fallbackForErrorPageDomOp(funcName: string, tab: Tab): Serializable {
switch (funcName) { switch (funcName) {
case "domExists": case "domExists":
return false; return false;
+4 -3
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -5,7 +6,7 @@ export class ExtensionCommands extends CommandGroup {
readonly namespace = "extension"; readonly namespace = "extension";
readonly commands: Record<string, CommandEntry> = { readonly commands: Record<string, CommandEntry> = {
"extension.reload": () => { "extension.reload": () => {
setTimeout(() => chrome.runtime.reload(), 200); setTimeout(() => api.runtime.reload(), 200);
return { reloading: true }; return { reloading: true };
}, },
"extension.info": () => this.extensionInfo(), "extension.info": () => this.extensionInfo(),
@@ -29,9 +30,9 @@ export class ExtensionCommands extends CommandGroup {
} }
private extensionInfo() { private extensionInfo() {
const manifest = chrome.runtime.getManifest(); const manifest = api.runtime.getManifest();
return { return {
id: chrome.runtime.id, id: api.runtime.id,
name: manifest.name, name: manifest.name,
version: manifest.version, version: manifest.version,
manifestVersion: manifest.manifest_version, manifestVersion: manifest.manifest_version,
+9 -8
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, getTabGroup, groupTabs, moveTabGroup, processInBatches, queryTabGroups, resolveGroupId, runLargeOperation, tabInfo, ungroupTabs, updateTabGroup } from '../core'; import { asTabIds, buildTabBlocks, getLargeOperationThrottle, getTabGroup, groupTabs, moveTabGroup, processInBatches, queryTabGroups, resolveGroupId, runLargeOperation, tabInfo, ungroupTabs, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -18,7 +19,7 @@ export class GroupsCommands extends CommandGroup {
private async groupList() { private async groupList() {
const groups = await queryTabGroups({}); const groups = await queryTabGroups({});
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return groups.map(g => ({ return groups.map(g => ({
id: g.id, id: g.id,
title: g.title, title: g.title,
@@ -30,7 +31,7 @@ export class GroupsCommands extends CommandGroup {
} }
private async groupTabs({ groupId }: GroupTabsArgs) { private async groupTabs({ groupId }: GroupTabsArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => t.groupId === groupId).map(tabInfo); return all.filter(t => t.groupId === groupId).map(tabInfo);
} }
@@ -47,7 +48,7 @@ export class GroupsCommands extends CommandGroup {
private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) { private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) {
return runLargeOperation("group.close", async () => { return runLargeOperation("group.close", async () => {
const tabs = await chrome.tabs.query({}); const tabs = await api.tabs.query({});
const groupTabs = tabs.filter(t => t.groupId === groupId); const groupTabs = tabs.filter(t => t.groupId === groupId);
const tabIds = groupTabs.map(t => t.id); const tabIds = groupTabs.map(t => t.id);
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode); const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
@@ -57,7 +58,7 @@ export class GroupsCommands extends CommandGroup {
} }
private async groupOpen({ name }: GroupOpenArgs) { private async groupOpen({ name }: GroupOpenArgs) {
const tab = await chrome.tabs.create({ active: true }); const tab = await api.tabs.create({ active: true });
const groupId = await groupTabs({ tabIds: asTabIds([tab.id]) }); const groupId = await groupTabs({ tabIds: asTabIds([tab.id]) });
await updateTabGroup(groupId, { title: name }); await updateTabGroup(groupId, { title: name });
return { id: groupId, name }; return { id: groupId, name };
@@ -65,15 +66,15 @@ export class GroupsCommands extends CommandGroup {
private async groupAddTab({ group, url }: GroupAddTabArgs) { private async groupAddTab({ group, url }: GroupAddTabArgs) {
const groupId = await resolveGroupId(group); const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId }); const existingTabs = await api.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true }); const tab = await api.tabs.create({ url: url || "chrome://newtab/", active: true });
await groupTabs({ tabIds: asTabIds([tab.id]), groupId }); await groupTabs({ tabIds: asTabIds([tab.id]), groupId });
// If a URL was provided, close any blank placeholder tabs left from group creation // If a URL was provided, close any blank placeholder tabs left from group creation
if (url) { if (url) {
const placeholders = existingTabs.filter(t => const placeholders = existingTabs.filter(t =>
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/" t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
); );
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
} }
return { tabId: tab.id, groupId }; return { tabId: tab.id, groupId };
} }
@@ -81,7 +82,7 @@ export class GroupsCommands extends CommandGroup {
private async groupMove({ group, forward, backward }: GroupMoveArgs) { private async groupMove({ group, forward, backward }: GroupMoveArgs) {
const groupId = await resolveGroupId(group); const groupId = await resolveGroupId(group);
const groupInfo = await getTabGroup(groupId); const groupInfo = await getTabGroup(groupId);
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId }); const allTabs = await api.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index); allTabs.sort((a, b) => a.index - b.index);
const blocks = buildTabBlocks(allTabs); const blocks = buildTabBlocks(allTabs);
+40 -15
View File
@@ -1,3 +1,5 @@
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 { getActiveTab, getAliases, groupTabs as groupTabIds, isBrowserErrorUrl, resolveGroupId, tabInfo, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -26,19 +28,19 @@ export class NavigationCommands extends CommandGroup {
const entry = Object.entries(aliases).find(([, v]) => v === windowName); const entry = Object.entries(aliases).find(([, v]) => v === windowName);
if (entry) windowId = parseInt(entry[0]); if (entry) windowId = parseInt(entry[0]);
} }
const tab = await chrome.tabs.create({ url, active: Boolean(focus) && !background, windowId }); const tab = await api.tabs.create({ url, active: Boolean(focus) && !background, windowId });
if (groupNameOrId != null) { if (groupNameOrId != null) {
let groupId; let groupId;
try { try {
groupId = await resolveGroupId(groupNameOrId); groupId = await resolveGroupId(groupNameOrId);
// Close any blank placeholder tabs that were created when the group was made // Close any blank placeholder tabs that were created when the group was made
const groupTabs = await chrome.tabs.query({ groupId }); const groupTabs = await api.tabs.query({ groupId });
const placeholders = groupTabs.filter(t => const placeholders = groupTabs.filter(t =>
t.id !== tab.id && t.id !== tab.id &&
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/") (t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
); );
await groupTabIds({ tabIds: [tab.id], groupId }); await groupTabIds({ tabIds: [tab.id], groupId });
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
} catch (e) { } catch (e) {
if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e; if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e;
// Group doesn't exist — create it with the tab already in it // Group doesn't exist — create it with the tab already in it
@@ -46,14 +48,37 @@ export class NavigationCommands extends CommandGroup {
await updateTabGroup(groupId, { title: String(groupNameOrId) }); await updateTabGroup(groupId, { title: String(groupNameOrId) });
} }
} }
return { id: tab.id, url: tab.url }; const loadedTab = await this.waitForOpenedTabUrl(tab.id, url, tab);
return { id: loadedTab.id, url: loadedTab.url || loadedTab.pendingUrl || url };
}
private async waitForOpenedTabUrl(tabId: number, targetUrl: string, initialTab: Tab): Promise<Tab> {
const initialUrl = initialTab.url || initialTab.pendingUrl || "";
if (this.isOpenedTabUrlReady(initialUrl, targetUrl)) return initialTab;
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
const current = await api.tabs.get(tabId);
const currentUrl = current.url || current.pendingUrl || "";
if (this.isOpenedTabUrlReady(currentUrl, targetUrl)) return current;
await new Promise(r => setTimeout(r, 50));
}
return api.tabs.get(tabId);
}
private isOpenedTabUrlReady(currentUrl: string, targetUrl: string): boolean {
if (!currentUrl) return false;
if (currentUrl === targetUrl || currentUrl.startsWith(targetUrl)) return true;
if (targetUrl === "about:blank" || targetUrl === "chrome://newtab/") return currentUrl === targetUrl;
return currentUrl !== "about:blank" && currentUrl !== "chrome://newtab/";
} }
private async navTo({ tabId, url }: NavToArgs) { private async navTo({ tabId, url }: NavToArgs) {
const tab = await chrome.tabs.update(tabId, { url }); const tab = await api.tabs.update(tabId, { url });
const deadline = Date.now() + 1000; const deadline = Date.now() + 1000;
while (tabId && Date.now() < deadline) { while (tabId && Date.now() < deadline) {
const current = await chrome.tabs.get(tabId); const current = await api.tabs.get(tabId);
const currentUrl = current.url || current.pendingUrl || ""; const currentUrl = current.url || current.pendingUrl || "";
if (currentUrl === url || currentUrl.startsWith(url)) { if (currentUrl === url || currentUrl.startsWith(url)) {
return { id: current.id, url: currentUrl }; return { id: current.id, url: currentUrl };
@@ -65,35 +90,35 @@ export class NavigationCommands extends CommandGroup {
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) { private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache }); await api.tabs.reload(tab.id, { bypassCache });
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navBack({ tabId }: NavTabArgs) { private async navBack({ tabId }: NavTabArgs) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goBack(tab.id); await api.tabs.goBack(tab.id);
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navForward({ tabId }: NavTabArgs) { private async navForward({ tabId }: NavTabArgs) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goForward(tab.id); await api.tabs.goForward(tab.id);
return { tabId: tab.id }; return { tabId: tab.id };
} }
private async navFocus({ pattern }: NavFocusArgs) { private async navFocus({ pattern }: NavFocusArgs) {
// If pattern is a plain integer, treat it as a tab ID // If pattern is a plain integer, treat it as a tab ID
const asInt = parseInt(pattern); const asInt = parseInt(pattern);
let match: chrome.tabs.Tab | undefined; let match: Tab | undefined;
if (!isNaN(asInt) && String(asInt) === String(pattern)) { if (!isNaN(asInt) && String(asInt) === String(pattern)) {
match = await chrome.tabs.get(asInt); match = await api.tabs.get(asInt);
} else { } else {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern))); match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
} }
if (!match) return null; if (!match) return null;
await chrome.windows.update(match.windowId, { focused: true }); await api.windows.update(match.windowId, { focused: true });
await chrome.tabs.update(match.id, { active: true }); await api.tabs.update(match.id, { active: true });
return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
} }
@@ -102,7 +127,7 @@ export class NavigationCommands extends CommandGroup {
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
const interval = 200; const interval = 200;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id); const t = await api.tabs.get(tab.id);
const currentUrl = t.url || t.pendingUrl || ""; const currentUrl = t.url || t.pendingUrl || "";
if (isBrowserErrorUrl(currentUrl)) { if (isBrowserErrorUrl(currentUrl)) {
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`); throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
+4 -2
View File
@@ -1,7 +1,9 @@
import { webExtApi as api } from '../browser-api';
import type { Tab, TabGroup } from '../types';
import { normalizeGroupColor, queryTabGroups } from '../core'; import { normalizeGroupColor, queryTabGroups } from '../core';
import type { SessionTab, StoredSession } from '../types'; import type { SessionTab, StoredSession } from '../types';
export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] { export function buildSessionSnapshot(tabs: Tab[], groups: TabGroup[]): SessionTab[] {
const groupById = new Map(groups.map(group => [group.id, group])); const groupById = new Map(groups.map(group => [group.id, group]));
return tabs return tabs
.filter(tab => Boolean(tab.url || tab.pendingUrl)) .filter(tab => Boolean(tab.url || tab.pendingUrl))
@@ -27,7 +29,7 @@ export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tab
* its change-detection signature. Shared by session.save and the autosave path. * its change-detection signature. Shared by session.save and the autosave path.
*/ */
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> { export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
const tabs = await chrome.tabs.query({}); const tabs = await api.tabs.query({});
const groups = await queryTabGroups({}); const groups = await queryTabGroups({});
const sessionTabs = buildSessionSnapshot(tabs, groups); const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs); const signature = sessionSignature(sessionTabs);
+19 -16
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, groupTabs, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, updateTabGroup, yieldForLargeOperation } from '../core'; import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, groupTabs, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, updateTabGroup, yieldForLargeOperation } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import { AutoSaveManager } from './autosave'; import { AutoSaveManager } from './autosave';
@@ -32,18 +33,18 @@ export class SessionCommands extends CommandGroup {
const { session, tabCount } = await captureCurrentSession(); const { session, tabCount } = await captureCurrentSession();
const sessions = await getSessions(); const sessions = await getSessions();
sessions[name] = session; sessions[name] = session;
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name, tabs: tabCount }; return { name, tabs: tabCount };
} }
// Public: invoked from index.ts on chrome.tabs.onActivated. // Public: invoked from index.ts on api.tabs.onActivated.
async activateLazyTab(tabId: number | string) { async activateLazyTab(tabId: number | string) {
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs"); const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
const entry = lazySessionTabs?.[tabId]; const entry = lazySessionTabs?.[tabId];
if (!entry?.url) return false; if (!entry?.url) return false;
delete lazySessionTabs[tabId]; delete lazySessionTabs[tabId];
await chrome.storage.local.set({ lazySessionTabs }); await api.storage.local.set({ lazySessionTabs });
await chrome.tabs.update(Number(tabId), { url: entry.url }); await api.tabs.update(Number(tabId), { url: entry.url });
return true; return true;
} }
@@ -58,24 +59,24 @@ export class SessionCommands extends CommandGroup {
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode); const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length; const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length;
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs"); const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
const lazyMap: LazySessionMap = lazySessionTabs || {}; const lazyMap: LazySessionMap = lazySessionTabs || {};
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length }); updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
for (const [idx, entry] of sessionTabs.entries()) { for (const [idx, entry] of sessionTabs.entries()) {
throwIfJobCancelled(__job); throwIfJobCancelled(__job);
const shouldLazy = lazy && idx >= eagerLimit; const shouldLazy = lazy && idx >= eagerLimit;
const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) }); const tab = await api.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) });
createdTabs.push({ tabId: tab.id, entry }); createdTabs.push({ tabId: tab.id, entry });
if (shouldLazy) { if (shouldLazy) {
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() }; lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
} else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) { } else if (discardBackgroundTabs && !entry.pinned && api.tabs.discard) {
try { await chrome.tabs.discard(tab.id); } catch (_) {} try { await api.tabs.discard(tab.id); } catch (_) {}
} }
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length }); updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs)); await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
} }
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap }); if (lazy) await api.storage.local.set({ lazySessionTabs: lazyMap });
const groups = new Map(); const groups = new Map();
for (const { tabId, entry } of createdTabs) { for (const { tabId, entry } of createdTabs) {
@@ -119,7 +120,7 @@ export class SessionCommands extends CommandGroup {
const sessions = await getSessions(); const sessions = await getSessions();
if (!(name in sessions)) throw new Error(`Session '${name}' not found`); if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
delete sessions[name]; delete sessions[name];
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name }; return { name };
} }
@@ -154,16 +155,18 @@ export class SessionCommands extends CommandGroup {
if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`); if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`);
const stored = session as object as StoredSession; const stored = session as object as StoredSession;
sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() }; sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() };
await chrome.storage.local.set({ sessions }); await api.storage.local.set({ sessions });
return { name, tabs: getSessionTabs(sessions[name]).length }; return { name, tabs: getSessionTabs(sessions[name]).length };
} }
private async clientsList() { private async clientsList() {
const manifest = chrome.runtime.getManifest(); const manifest = api.runtime.getManifest();
const alias = await getProfileAlias(); const alias = await getProfileAlias();
const browserInfo = api.runtime.getBrowserInfo ? await api.runtime.getBrowserInfo() : null;
const userAgent = navigator.userAgent;
return [{ return [{
name: "Chrome", name: browserInfo?.name || (userAgent.includes("Firefox/") ? "Firefox" : "Chrome"),
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", version: browserInfo?.version || userAgent.match(/(?:Chrome|Firefox)\/([\d.]+)/)?.[1] || "unknown",
platform: navigator.platform, platform: navigator.platform,
extensionVersion: manifest.version, extensionVersion: manifest.version,
profile: alias, profile: alias,
@@ -171,7 +174,7 @@ export class SessionCommands extends CommandGroup {
} }
private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) { private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) {
await chrome.storage.local.set({ profileAlias: alias }); await api.storage.local.set({ profileAlias: alias });
return { alias }; return { alias };
} }
} }
+9 -8
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core'; import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -17,7 +18,7 @@ export class TabsQueryCommands extends CommandGroup {
}; };
private async tabsList() { private async tabsList() {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const aliases = await getAliases(); const aliases = await getAliases();
const tabs = []; const tabs = [];
for (const w of windows) { for (const w of windows) {
@@ -34,7 +35,7 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) { private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) {
const activeTabs = await chrome.tabs.query({ windowId, active: true }); const activeTabs = await api.tabs.query({ windowId, active: true });
const tab = activeTabs[0]; const tab = activeTabs[0];
if (!tab) { if (!tab) {
throw new Error(`No active tab found for window ${windowId}`); throw new Error(`No active tab found for window ${windowId}`);
@@ -43,24 +44,24 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsStatus({ tabId }: TabIdArgs) { private async tabsStatus({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
return tabInfo(tab); return tabInfo(tab);
} }
private async tabsFilter({ pattern }: TabsPatternArgs) { private async tabsFilter({ pattern }: TabsPatternArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
} }
private async tabsCount({ pattern }: TabsPatternArgs) { private async tabsCount({ pattern }: TabsPatternArgs) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length; if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
return all.length; return all.length;
} }
private async tabsQuery({ search }: TabsQueryArgs) { private async tabsQuery({ search }: TabsQueryArgs) {
const q = search.toLowerCase(); const q = search.toLowerCase();
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
return all.filter(t => return all.filter(t =>
(t.url && t.url.toLowerCase().includes(q)) || (t.url && t.url.toLowerCase().includes(q)) ||
(t.title && t.title.toLowerCase().includes(q)) (t.title && t.title.toLowerCase().includes(q))
@@ -68,7 +69,7 @@ export class TabsQueryCommands extends CommandGroup {
} }
private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) { private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
const regex = new RegExp(pattern); const regex = new RegExp(pattern);
let lastUrl = tab.url || tab.pendingUrl || ""; let lastUrl = tab.url || tab.pendingUrl || "";
@@ -81,7 +82,7 @@ export class TabsQueryCommands extends CommandGroup {
if (matches(lastUrl)) return tabInfo(tab); if (matches(lastUrl)) return tabInfo(tab);
while (Date.now() < deadline) { while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id); const t = await api.tabs.get(tab.id);
lastUrl = t.url || t.pendingUrl || ""; lastUrl = t.url || t.pendingUrl || "";
lastStatus = t.status || "unknown"; lastStatus = t.status || "unknown";
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t); if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
+24 -22
View File
@@ -1,3 +1,5 @@
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 { asTabIds, getActiveTab, getLargeOperationThrottle, groupTabs, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -23,7 +25,7 @@ export class TabsMutationCommands extends CommandGroup {
return runLargeOperation("tabs.close", async () => { return runLargeOperation("tabs.close", async () => {
let toClose: number[] = []; let toClose: number[] = [];
if (duplicates) { if (duplicates) {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const seen = new Set<string>(); const seen = new Set<string>();
for (const w of windows) { for (const w of windows) {
for (const t of w.tabs || []) { for (const t of w.tabs || []) {
@@ -34,7 +36,7 @@ export class TabsMutationCommands extends CommandGroup {
} }
} }
} else if (inactive) { } else if (inactive) {
const all = await chrome.tabs.query({}); const all = await api.tabs.query({});
toClose = all.filter(t => !t.active).map(t => t.id); toClose = all.filter(t => !t.active).map(t => t.id);
} else if (tabIds?.length) { } else if (tabIds?.length) {
toClose = tabIds.filter(id => id != null); toClose = tabIds.filter(id => id != null);
@@ -42,17 +44,17 @@ export class TabsMutationCommands extends CommandGroup {
toClose = [tabId]; toClose = [tabId];
} }
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode); const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
await processInBatches(toClose, throttle, batch => chrome.tabs.remove(batch), { job: __job, phase: "closing tabs" }); await processInBatches(toClose, throttle, batch => api.tabs.remove(batch), { job: __job, phase: "closing tabs" });
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible }; return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
}); });
} }
private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) { private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) {
const moveProps: Partial<chrome.tabs.MoveProperties> = {}; const moveProps: Partial<TabMoveProperties> = {};
if (windowId != null) moveProps.windowId = windowId; if (windowId != null) moveProps.windowId = windowId;
if (forward || backward) { if (forward || backward) {
const tab = await chrome.tabs.get(tabId); const tab = await api.tabs.get(tabId);
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
else moveProps.index = Math.max(0, tab.index - 1); else moveProps.index = Math.max(0, tab.index - 1);
} else if (index != null) { } else if (index != null) {
@@ -62,7 +64,7 @@ export class TabsMutationCommands extends CommandGroup {
} }
// `index` is always assigned by one of the branches above before this call. // `index` is always assigned by one of the branches above before this call.
await chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties); await api.tabs.move(tabId, moveProps as TabMoveProperties);
if (groupId != null) { if (groupId != null) {
await groupTabs({ tabIds: asTabIds([tabId]), groupId }); await groupTabs({ tabIds: asTabIds([tabId]), groupId });
} }
@@ -70,7 +72,7 @@ export class TabsMutationCommands extends CommandGroup {
} }
private async tabsActive({ tabId }: TabIdArgs) { private async tabsActive({ tabId }: TabIdArgs) {
await chrome.tabs.update(tabId, { active: true }); await api.tabs.update(tabId, { active: true });
return { tabId }; return { tabId };
} }
@@ -80,7 +82,7 @@ export class TabsMutationCommands extends CommandGroup {
private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) { private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) {
return runLargeOperation("tabs.sort", async () => { return runLargeOperation("tabs.sort", async () => {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
let moved = 0; let moved = 0;
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0); const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs }); updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
@@ -98,7 +100,7 @@ export class TabsMutationCommands extends CommandGroup {
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
for (let i = 0; i < sorted.length; i++) { for (let i = 0; i < sorted.length; i++) {
throwIfJobCancelled(__job); throwIfJobCancelled(__job);
await chrome.tabs.move(sorted[i].id, { index: i }); await api.tabs.move(sorted[i].id, { index: i });
moved++; moved++;
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs }); updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs); await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
@@ -108,13 +110,13 @@ export class TabsMutationCommands extends CommandGroup {
}); });
} }
private windowHasAudibleTabs(window: chrome.windows.Window): boolean { private windowHasAudibleTabs(window: BrowserWindow): boolean {
return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted)); return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted));
} }
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) { private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
return runLargeOperation("tabs.merge_windows", async () => { return runLargeOperation("tabs.merge_windows", async () => {
const all = await chrome.windows.getAll({ populate: true }); const all = await api.windows.getAll({ populate: true });
const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w)); const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w));
const target = movableWindows.find(w => w.focused) || movableWindows[0]; const target = movableWindows.find(w => w.focused) || movableWindows[0];
if (!target) return { moved: 0, skippedAudibleWindows: all.length }; if (!target) return { moved: 0, skippedAudibleWindows: all.length };
@@ -127,7 +129,7 @@ export class TabsMutationCommands extends CommandGroup {
const ids = w.tabs.map(t => t.id); const ids = w.tabs.map(t => t.id);
const throttle = await getLargeOperationThrottle(ids.length, gentleMode); const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
moved = await processInBatches(ids, throttle, moved = await processInBatches(ids, throttle,
batch => chrome.tabs.move(batch, { windowId: target.id, index: -1 }), batch => api.tabs.move(batch, { windowId: target.id, index: -1 }),
{ job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved }); { job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved });
} }
return { moved, skippedAudibleWindows: all.length - movableWindows.length }; return { moved, skippedAudibleWindows: all.length - movableWindows.length };
@@ -135,42 +137,42 @@ export class TabsMutationCommands extends CommandGroup {
} }
private async tabsPin({ tabId }: TabIdArgs) { private async tabsPin({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: true }); await api.tabs.update(tab.id, { pinned: true });
return { tabId: tab.id, pinned: true }; return { tabId: tab.id, pinned: true };
} }
private async tabsUnpin({ tabId }: TabIdArgs) { private async tabsUnpin({ tabId }: TabIdArgs) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: false }); await api.tabs.update(tab.id, { pinned: false });
return { tabId: tab.id, pinned: false }; return { tabId: tab.id, pinned: false };
} }
private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) { private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) {
let windowId: number | undefined; let windowId: number | undefined;
if (tabId) { if (tabId) {
const tab = await chrome.tabs.get(tabId); const tab = await api.tabs.get(tabId);
await chrome.tabs.update(tabId, { active: true }); await api.tabs.update(tabId, { active: true });
windowId = tab.windowId; windowId = tab.windowId;
} else { } else {
const tab = await getActiveTab(); const tab = await getActiveTab();
windowId = tab.windowId; windowId = tab.windowId;
} }
const opts: chrome.extensionTypes.ImageDetails = { format: format as chrome.extensionTypes.ImageFormat }; const opts: browser.extensionTypes.ImageDetails = { format: format as browser.extensionTypes.ImageFormat };
if (format === "jpeg" && quality != null) opts.quality = quality; if (format === "jpeg" && quality != null) opts.quality = quality;
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts); const dataUrl = await api.tabs.captureVisibleTab(windowId, opts);
return { dataUrl, format }; return { dataUrl, format };
} }
private async tabsMute({ tabId }: TabIdArgs) { private async tabsMute({ tabId }: TabIdArgs) {
const tab = await resolveTabForDirectAction(tabId, "mute"); const tab = await resolveTabForDirectAction(tabId, "mute");
await chrome.tabs.update(tab.id, { muted: true }); await api.tabs.update(tab.id, { muted: true });
return { tabId: tab.id, muted: true }; return { tabId: tab.id, muted: true };
} }
private async tabsUnmute({ tabId }: TabIdArgs) { private async tabsUnmute({ tabId }: TabIdArgs) {
const tab = await resolveTabForDirectAction(tabId, "unmute"); const tab = await resolveTabForDirectAction(tabId, "unmute");
await chrome.tabs.update(tab.id, { muted: false }); await api.tabs.update(tab.id, { muted: false });
return { tabId: tab.id, muted: false }; return { tabId: tab.id, muted: false };
} }
} }
+7 -5
View File
@@ -1,3 +1,5 @@
import { webExtApi as api } from '../browser-api';
import type { WindowCreateData } from '../types';
import { getAliases } from '../core'; import { getAliases } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
@@ -13,7 +15,7 @@ export class WindowsCommands extends CommandGroup {
}; };
private async windowsList() { private async windowsList() {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const aliases = await getAliases(); const aliases = await getAliases();
return windows.map(w => ({ return windows.map(w => ({
id: w.id, id: w.id,
@@ -27,19 +29,19 @@ export class WindowsCommands extends CommandGroup {
private async windowsRename({ windowId, name }: WindowsRenameArgs) { private async windowsRename({ windowId, name }: WindowsRenameArgs) {
const aliases = await getAliases(); const aliases = await getAliases();
aliases[windowId] = name; aliases[windowId] = name;
await chrome.storage.local.set({ windowAliases: aliases }); await api.storage.local.set({ windowAliases: aliases });
return { windowId, name }; return { windowId, name };
} }
private async windowsClose({ windowId }: WindowsCloseArgs) { private async windowsClose({ windowId }: WindowsCloseArgs) {
await chrome.windows.remove(windowId); await api.windows.remove(windowId);
return { windowId }; return { windowId };
} }
private async windowsOpen({ url }: WindowsOpenArgs) { private async windowsOpen({ url }: WindowsOpenArgs) {
const createData: chrome.windows.CreateData = { focused: true }; const createData: WindowCreateData = { focused: true };
if (url) createData.url = url; if (url) createData.url = url;
const w = await chrome.windows.create(createData); const w = await api.windows.create(createData);
return { id: w.id }; return { id: w.id };
} }
} }
+4 -2
View File
@@ -1,3 +1,5 @@
import type { TabGroupColor } from '../types';
import { webExtApi as api } from '../browser-api';
// Tab-group resolution and normalization helpers. // Tab-group resolution and normalization helpers.
import { queryTabGroups } from './tab-groups'; import { queryTabGroups } from './tab-groups';
@@ -10,7 +12,7 @@ export async function resolveGroupId(nameOrId: string | number): Promise<number>
return match.id; return match.id;
} }
export function normalizeGroupColor(color: string | undefined): chrome.tabGroups.Color { export function normalizeGroupColor(color: string | undefined): TabGroupColor {
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
return (allowed.has(color as string) ? color : "grey") as chrome.tabGroups.Color; return (allowed.has(color as string) ? color : "grey") as TabGroupColor;
} }
+6 -4
View File
@@ -1,15 +1,17 @@
// chrome.scripting.executeScript wrapper with transient-error retry. import { webExtApi as api } from '../browser-api';
import type { ScriptInjection, ScriptInjectionResult } from '../types';
// api.scripting.executeScript wrapper with transient-error retry.
import { isTransientScriptError } from './errors'; import { isTransientScriptError } from './errors';
import { sleep } from './throttle'; import { sleep } from './throttle';
import type { Serializable } from '../types'; import type { Serializable } from '../types';
export async function executeScript<Args extends Serializable[], Result>( export async function executeScript<Args extends Serializable[], Result>(
options: chrome.scripting.ScriptInjection<Args, Result>, options: ScriptInjection<Args>,
retries = 3, retries = 3,
): Promise<chrome.scripting.InjectionResult<chrome.scripting.Awaited<Result>>[]> { ): Promise<ScriptInjectionResult<Result>[]> {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
return await chrome.scripting.executeScript(options); return await api.scripting.executeScript(options);
} catch (e) { } catch (e) {
if (i < retries - 1 && isTransientScriptError(e)) { if (i < retries - 1 && isTransientScriptError(e)) {
await sleep(300); await sleep(300);
+5 -4
View File
@@ -1,8 +1,9 @@
// chrome.storage.local accessors for profile alias, window aliases, and sessions. import { webExtApi as api } from '../browser-api';
// api.storage.local accessors for profile alias, window aliases, and sessions.
import type { SessionTab, StoredSession } from '../types'; import type { SessionTab, StoredSession } from '../types';
export async function getProfileAlias(): Promise<string> { export async function getProfileAlias(): Promise<string> {
const { profileAlias } = await chrome.storage.local.get<{ profileAlias?: string }>("profileAlias"); const { profileAlias } = await api.storage.local.get<{ profileAlias?: string }>("profileAlias");
return profileAlias || "default"; return profileAlias || "default";
} }
@@ -20,11 +21,11 @@ export function getSessionTabs(session: StoredSession | undefined | null): Sessi
} }
export async function getAliases(): Promise<Record<string, string>> { export async function getAliases(): Promise<Record<string, string>> {
const { windowAliases } = await chrome.storage.local.get<{ windowAliases?: Record<string, string> }>("windowAliases"); const { windowAliases } = await api.storage.local.get<{ windowAliases?: Record<string, string> }>("windowAliases");
return windowAliases || {}; return windowAliases || {};
} }
export async function getSessions(): Promise<Record<string, StoredSession>> { export async function getSessions(): Promise<Record<string, StoredSession>> {
const { sessions } = await chrome.storage.local.get<{ sessions?: Record<string, StoredSession> }>("sessions"); const { sessions } = await api.storage.local.get<{ sessions?: Record<string, StoredSession> }>("sessions");
return sessions || {}; return sessions || {};
} }
+23 -21
View File
@@ -1,3 +1,5 @@
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 // Optional tab-group API accessors. Firefox currently does not implement the
// Chromium tabGroups/tabs.group APIs, so keep runtime checks in one place and // Chromium tabGroups/tabs.group APIs, so keep runtime checks in one place and
// use bracket access to avoid Firefox package validation flagging static API // use bracket access to avoid Firefox package validation flagging static API
@@ -5,43 +7,43 @@
const TAB_GROUPS_UNSUPPORTED = "Tab groups are not supported by this browser"; const TAB_GROUPS_UNSUPPORTED = "Tab groups are not supported by this browser";
function tabGroupsApi(): typeof chrome.tabGroups { function tabGroupsApi(): typeof api.tabGroups {
const api = chrome["tabGroups" as keyof typeof chrome] as typeof chrome.tabGroups | undefined; const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
if (!api) throw new Error(TAB_GROUPS_UNSUPPORTED); if (!tabGroups) throw new Error(TAB_GROUPS_UNSUPPORTED);
return api; return tabGroups;
} }
function tabsGroupApi(): typeof chrome.tabs.group { function tabsGroupApi(): typeof api.tabs.group {
const fn = chrome.tabs["group" as keyof typeof chrome.tabs] as typeof chrome.tabs.group | undefined; const fn = api.tabs["group" as keyof typeof api.tabs] as typeof api.tabs.group | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED); if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(chrome.tabs); return fn.bind(api.tabs);
} }
function tabsUngroupApi(): typeof chrome.tabs.ungroup { function tabsUngroupApi(): typeof api.tabs.ungroup {
const fn = chrome.tabs["ungroup" as keyof typeof chrome.tabs] as typeof chrome.tabs.ungroup | undefined; const fn = api.tabs["ungroup" as keyof typeof api.tabs] as typeof api.tabs.ungroup | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED); if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(chrome.tabs); return fn.bind(api.tabs);
} }
export async function queryTabGroups(queryInfo: chrome.tabGroups.QueryInfo = {}): Promise<chrome.tabGroups.TabGroup[]> { export async function queryTabGroups(queryInfo: TabGroupQueryInfo = {}): Promise<TabGroup[]> {
const api = chrome["tabGroups" as keyof typeof chrome] as typeof chrome.tabGroups | undefined; const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
if (!api) return []; if (!tabGroups) return [];
return api.query(queryInfo); return tabGroups.query(queryInfo);
} }
export async function getTabGroup(groupId: number): Promise<chrome.tabGroups.TabGroup> { export async function getTabGroup(groupId: number): Promise<TabGroup> {
return tabGroupsApi().get(groupId); return tabGroupsApi().get(groupId);
} }
export async function updateTabGroup(groupId: number, updateProperties: chrome.tabGroups.UpdateProperties): Promise<chrome.tabGroups.TabGroup> { export async function updateTabGroup(groupId: number, updateProperties: TabGroupUpdateProperties): Promise<TabGroup> {
return tabGroupsApi().update(groupId, updateProperties); return tabGroupsApi().update(groupId, updateProperties);
} }
export async function moveTabGroup(groupId: number, moveProperties: chrome.tabGroups.MoveProperties): Promise<chrome.tabGroups.TabGroup> { export async function moveTabGroup(groupId: number, moveProperties: TabGroupMoveProperties): Promise<TabGroup> {
return tabGroupsApi().move(groupId, moveProperties); return tabGroupsApi().move(groupId, moveProperties);
} }
export async function groupTabs(createProperties: chrome.tabs.GroupOptions): Promise<number> { export async function groupTabs(createProperties: TabGroupOptions): Promise<number> {
return tabsGroupApi()(createProperties); return tabsGroupApi()(createProperties);
} }
@@ -49,7 +51,7 @@ export async function ungroupTabs(tabIds: [number, ...number[]]): Promise<void>
return tabsUngroupApi()(tabIds); return tabsUngroupApi()(tabIds);
} }
export function tabGroupsOnUpdated(): chrome.events.Event<(group: chrome.tabGroups.TabGroup) => void> | undefined { export function tabGroupsOnUpdated(): BrowserEvent<(group: TabGroup) => void> | undefined {
const api = chrome["tabGroups" as keyof typeof chrome] as typeof chrome.tabGroups | undefined; const tabGroups = api["tabGroups" as keyof typeof api] as typeof api.tabGroups | undefined;
return api?.onUpdated; return tabGroups?.onUpdated;
} }
+17 -15
View File
@@ -1,3 +1,5 @@
import { webExtApi as api } from '../browser-api';
import type { Tab } from '../types';
// Tab-related shared helpers: info shaping, scriptable-url checks, active-tab // Tab-related shared helpers: info shaping, scriptable-url checks, active-tab
// resolution, and HTML fetching. // resolution, and HTML fetching.
import { isBrowserErrorUrl, isErrorPageScriptError } from './errors'; import { isBrowserErrorUrl, isErrorPageScriptError } from './errors';
@@ -5,8 +7,8 @@ import { executeScript } from './scripting';
import type { TabBlock } from '../types'; import type { TabBlock } from '../types';
/** /**
* Narrow a plain id array to the non-empty-tuple shape that chrome.tabs.group / * Narrow a plain id array to the non-empty-tuple shape that api.tabs.group /
* chrome.tabs.ungroup declare. The runtime happily accepts any array (including * api.tabs.ungroup declare. The runtime happily accepts any array (including
* a single element); the published @types/chrome just over-constrain the param * a single element); the published @types/chrome just over-constrain the param
* to `[number, ...number[]]`. Callers guarantee non-emptiness before calling. * to `[number, ...number[]]`. Callers guarantee non-emptiness before calling.
*/ */
@@ -14,7 +16,7 @@ export function asTabIds(ids: number[]): [number, ...number[]] {
return ids as [number, ...number[]]; return ids as [number, ...number[]];
} }
export function tabInfo(t: chrome.tabs.Tab) { export function tabInfo(t: Tab) {
return { return {
id: t.id, id: t.id,
windowId: t.windowId, windowId: t.windowId,
@@ -36,16 +38,16 @@ export function isScriptableUrl(url: string | undefined | null): boolean {
} }
export async function getActiveTab() { export async function getActiveTab() {
const activeTabs = await chrome.tabs.query({ active: true }); const activeTabs = await api.tabs.query({ active: true });
if (!activeTabs.length) throw new Error("No active tab found"); if (!activeTabs.length) throw new Error("No active tab found");
const windows = await chrome.windows.getAll({ populate: false }); const windows = await api.windows.getAll({ populate: false });
const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id)); const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id));
const chooseTab = (predicate: (tab: chrome.tabs.Tab) => boolean) => activeTabs.find(predicate); const chooseTab = (predicate: (tab: Tab) => boolean) => activeTabs.find(predicate);
const byFocusAndScriptable = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || ""); const byFocusAndScriptable = (tab: Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
const byScriptable = (tab: chrome.tabs.Tab) => isScriptableUrl(tab.url || tab.pendingUrl || ""); const byScriptable = (tab: Tab) => isScriptableUrl(tab.url || tab.pendingUrl || "");
const byFocus = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId); const byFocus = (tab: Tab) => focusedWindowIds.has(tab.windowId);
return chooseTab(byFocusAndScriptable) return chooseTab(byFocusAndScriptable)
|| chooseTab(byScriptable) || chooseTab(byScriptable)
@@ -54,8 +56,8 @@ export async function getActiveTab() {
} }
/** Resolve the target tab (explicit id or the active tab) and its current URL. */ /** Resolve the target tab (explicit id or the active tab) and its current URL. */
export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: chrome.tabs.Tab; url: string }> { export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: Tab; url: string }> {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
return { tab, url: tab.url || tab.pendingUrl || "" }; return { tab, url: tab.url || tab.pendingUrl || "" };
} }
@@ -70,11 +72,11 @@ export function assertScriptableUrl(url: string, action: string): void {
} }
} }
export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise<chrome.tabs.Tab> { export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise<Tab> {
if (tabId != null) { if (tabId != null) {
return chrome.tabs.get(tabId); return api.tabs.get(tabId);
} }
const allTabs = await chrome.tabs.query({}); const allTabs = await api.tabs.query({});
if (allTabs.length !== 1) { if (allTabs.length !== 1) {
throw new Error( throw new Error(
`Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open` `Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open`
@@ -83,7 +85,7 @@ export async function resolveTabForDirectAction(tabId: number | undefined | null
return allTabs[0]; return allTabs[0];
} }
export function buildTabBlocks(tabs: chrome.tabs.Tab[]): TabBlock[] { export function buildTabBlocks(tabs: Tab[]): TabBlock[] {
const blocks: TabBlock[] = []; const blocks: TabBlock[] = [];
for (const tab of tabs) { for (const tab of tabs) {
const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null; const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null;
+4 -3
View File
@@ -1,3 +1,4 @@
import { webExtApi as api } from '../browser-api';
// Large-operation throttling, performance profile, and job-progress helpers. // Large-operation throttling, performance profile, and job-progress helpers.
import type { Job, JobProgressUpdate } from '../types'; import type { Job, JobProgressUpdate } from '../types';
@@ -16,7 +17,7 @@ function debugLargeOperation(message: string) {
} }
export async function hasAudibleTabs() { export async function hasAudibleTabs() {
const audibleTabs = await chrome.tabs.query({ audible: true }); const audibleTabs = await api.tabs.query({ audible: true });
return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted)); return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted));
} }
@@ -36,14 +37,14 @@ export async function runLargeOperation<T>(name: string, fn: () => Promise<T>):
} }
export async function getPerformanceProfile() { export async function getPerformanceProfile() {
const { performanceProfile } = await chrome.storage.local.get<{ performanceProfile?: string }>("performanceProfile"); const { performanceProfile } = await api.storage.local.get<{ performanceProfile?: string }>("performanceProfile");
return performanceProfile || "auto"; return performanceProfile || "auto";
} }
export async function setPerformanceProfile(profile: string) { export async function setPerformanceProfile(profile: string) {
const allowed = new Set(["auto", "normal", "gentle", "ultra"]); const allowed = new Set(["auto", "normal", "gentle", "ultra"]);
const performanceProfile = allowed.has(profile) ? profile : "auto"; const performanceProfile = allowed.has(profile) ? profile : "auto";
await chrome.storage.local.set({ performanceProfile }); await api.storage.local.set({ performanceProfile });
return { performanceProfile }; return { performanceProfile };
} }
+2 -1
View File
@@ -6,6 +6,7 @@
* the native connection. * the native connection.
*/ */
import { webExtApi as api } from './browser-api';
import { JobManager } from './classes/JobManager'; import { JobManager } from './classes/JobManager';
import { assembleRegistry } from './classes/CommandRegistry'; import { assembleRegistry } from './classes/CommandRegistry';
import { NativeConnection } from './classes/NativeConnection'; import { NativeConnection } from './classes/NativeConnection';
@@ -15,7 +16,7 @@ const jobs = new JobManager();
const ctx: CommandContext = { jobs }; const ctx: CommandContext = { jobs };
const { registry, session } = assembleRegistry(ctx); const { registry, session } = assembleRegistry(ctx);
chrome.tabs.onActivated.addListener(async ({ tabId }) => { api.tabs.onActivated.addListener(async ({ tabId }) => {
await session.activateLazyTab(tabId); await session.activateLazyTab(tabId);
}); });
+1 -1
View File
@@ -54,7 +54,7 @@ export interface DomEvalArgs { code?: string; tabId?: number; }
export interface DomWaitForArgs { selector?: string; timeout?: number; visible?: boolean; hidden?: boolean; tabId?: number; } export interface DomWaitForArgs { selector?: string; timeout?: number; visible?: boolean; hidden?: boolean; tabId?: number; }
export interface DomPollArgs { selector?: string; pattern?: string; attr?: string; timeout?: number; interval?: number; tabId?: number; } export interface DomPollArgs { selector?: string; pattern?: string; attr?: string; timeout?: number; interval?: number; tabId?: number; }
/** Arguments forwarded to the in-page content functions over chrome.scripting. */ /** Arguments forwarded to the in-page content functions over browser.scripting. */
export interface ContentArgs { export interface ContentArgs {
selector?: string; selector?: string;
text?: string; text?: string;
+1
View File
@@ -2,5 +2,6 @@ export * from './json';
export * from './jobs'; export * from './jobs';
export * from './session'; export * from './session';
export * from './tabs'; export * from './tabs';
export * from './webextension';
export * from './messages'; export * from './messages';
export * from './command-args'; export * from './command-args';
+41
View File
@@ -0,0 +1,41 @@
import type { Serializable } from './json';
export type RuntimePort = browser.runtime.Port;
export type Tab = browser.tabs.Tab & {
groupId?: number;
pendingUrl?: string;
};
export type TabUpdateInfo = Parameters<typeof browser.tabs.onUpdated.addListener>[0] extends (tabId: number, changeInfo: infer ChangeInfo, tab: browser.tabs.Tab) => void ? ChangeInfo : { url?: string };
export type BrowserWindow = browser.windows.Window & { tabs?: Tab[] };
export type WindowCreateData = browser.windows._CreateCreateData;
export type TabMoveProperties = browser.tabs._MoveMoveProperties;
export type TabGroupOptions = browser.tabs._GroupOptions;
export type TabGroup = browser.tabGroups.TabGroup;
export type TabGroupColor = browser.tabGroups.Color;
export type TabGroupQueryInfo = browser.tabGroups._QueryInfo;
export type TabGroupUpdateProperties = browser.tabGroups._UpdateProperties;
export type TabGroupMoveProperties = browser.tabGroups._MoveProperties;
export type BrowserEvent<TCallback extends (...args: never[]) => void> = {
addListener(cb: TCallback): void;
removeListener(cb: TCallback): void;
hasListener(cb: TCallback): boolean;
};
export type ScriptInjection<Args extends Serializable[]> = browser.scripting.ScriptInjection<Args>;
export type ScriptInjectionResult<Result> = browser.scripting.InjectionResult & { result?: Awaited<Result> };
export type StorageLocal = Omit<typeof browser.storage.local, "get"> & {
get<T extends object = { [key: string]: Serializable }>(keys?: string | string[] | object | null): Promise<T>;
};
export type WebExtensionApi = Omit<typeof browser, "tabs" | "windows" | "storage"> & {
tabs: Omit<typeof browser.tabs, "query" | "get" | "create" | "update" | "move"> & {
query(queryInfo: browser.tabs._QueryQueryInfo): Promise<Tab[]>;
get(tabId: number): Promise<Tab>;
create(createProperties: browser.tabs._CreateCreateProperties): Promise<Tab>;
update(tabId: number, updateProperties: browser.tabs._UpdateUpdateProperties): Promise<Tab>;
move(tabIds: number | number[], moveProperties: TabMoveProperties): Promise<Tab | Tab[]>;
};
windows: Omit<typeof browser.windows, "getAll" | "create"> & {
getAll(getInfo?: browser.windows._GetAllGetInfo): Promise<BrowserWindow[]>;
create(createData?: WindowCreateData): Promise<BrowserWindow>;
};
storage: Omit<typeof browser.storage, "local"> & { local: StorageLocal };
};
+43
View File
@@ -0,0 +1,43 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { webExtApi } from '../src/browser-api';
test('browser-api uses Firefox browser.* before Chromium chrome.*', () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const firefoxApi = { runtime: { id: 'firefox-api' } };
const chromiumApi = { runtime: { id: 'chromium-api' } };
try {
globalThis.chrome = chromiumApi;
globalThis.browser = firefoxApi;
assert.equal(webExtApi.runtime, firefoxApi.runtime);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
}
});
test('browser-api falls back to chrome.* in Chromium', () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const chromiumApi = { runtime: { id: 'chromium-api' } };
try {
globalThis.chrome = chromiumApi;
delete globalThis.browser;
assert.equal(webExtApi.runtime, chromiumApi.runtime);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
}
});
+86
View File
@@ -0,0 +1,86 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { SessionCommands } from '../src/commands/session';
import { JobManager } from '../src/classes/JobManager';
import { makeChromeMock } from './chrome-mock';
function makeSessionCommands() {
return new SessionCommands({ jobs: new JobManager() });
}
test('clients.list uses Firefox runtime.getBrowserInfo when available', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const chromeMock = makeChromeMock();
try {
delete globalThis.chrome;
globalThis.browser = {
...chromeMock,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
getBrowserInfo: async () => ({ name: 'Firefox', vendor: 'Mozilla', version: '149.0', buildID: 'test' }),
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Firefox/149.0' },
configurable: true,
});
const clients = await makeSessionCommands().commands['clients.list']({});
assert.equal(clients[0].name, 'Firefox');
assert.equal(clients[0].version, '149.0');
assert.equal(clients[0].extensionVersion, '0.15.1');
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
test('clients.list falls back to Chromium user-agent when getBrowserInfo is missing', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const chromeMock = makeChromeMock();
try {
delete globalThis.browser;
globalThis.chrome = {
...chromeMock,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Chrome/149.0.0.0 Safari/537.36' },
configurable: true,
});
const clients = await makeSessionCommands().commands['clients.list']({});
assert.equal(clients[0].name, 'Chrome');
assert.equal(clients[0].version, '149.0.0.0');
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
+66
View File
@@ -0,0 +1,66 @@
// @ts-nocheck
import test from 'node:test';
import assert from 'node:assert/strict';
import { NavigationCommands } from '../src/commands/navigation';
import { JobManager } from '../src/classes/JobManager';
import { makeChromeMock } from './chrome-mock';
function makeNavigationCommands() {
return new NavigationCommands({ jobs: new JobManager() });
}
test('navigate.open waits until Firefox updates about:blank to the requested URL', async () => {
const originalChrome = globalThis.chrome;
const originalBrowser = globalThis.browser;
const originalNavigator = globalThis.navigator;
const firefoxApi = makeChromeMock();
const targetUrl = 'https://example.com/?browser-cli-firefox-open-wait=1';
let getCalls = 0;
try {
delete globalThis.chrome;
globalThis.browser = {
...firefoxApi,
runtime: {
getManifest: () => ({ version: '0.15.1' }),
getBrowserInfo: async () => ({ name: 'Firefox', vendor: 'Mozilla', version: '151.0.2', buildID: 'test' }),
},
tabs: {
...firefoxApi.tabs,
create: async () => ({ id: 123, windowId: 1, index: 0, active: true, groupId: -1, url: 'about:blank' }),
get: async () => {
getCalls += 1;
return {
id: 123,
windowId: 1,
index: 0,
active: true,
groupId: -1,
url: getCalls < 2 ? 'about:blank' : targetUrl,
};
},
},
};
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Firefox/151.0.2' },
configurable: true,
});
const result = await makeNavigationCommands().commands['navigate.open']({ url: targetUrl, focus: true });
assert.equal(result.id, 123);
assert.equal(result.url, targetUrl);
assert.ok(getCalls >= 2);
} finally {
if (originalChrome === undefined) delete globalThis.chrome;
else globalThis.chrome = originalChrome;
if (originalBrowser === undefined) delete globalThis.browser;
else globalThis.browser = originalBrowser;
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
}
});
+8
View File
@@ -7,6 +7,7 @@
"name": "browser-cli-extension-build", "name": "browser-cli-extension-build",
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.1.40", "@types/chrome": "^0.1.40",
"@types/firefox-webext-browser": "^143.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
@@ -481,6 +482,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/firefox-webext-browser": {
"version": "143.0.0",
"resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-143.0.0.tgz",
"integrity": "sha512-865dYKMOP0CllFyHmgXV4IQgVL51OSQQCwSoihQ17EwugePKFSAZRc0EI+y7Ly4q7j5KyURlA7LgRpFieO4JOw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": { "node_modules/@types/har-format": {
"version": "1.2.16", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
+1
View File
@@ -13,6 +13,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.1.40", "@types/chrome": "^0.1.40",
"@types/firefox-webext-browser": "^143.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.1" version = "0.15.2"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+4
View File
@@ -140,6 +140,10 @@ def test_install_writes_firefox_allowed_extensions(tmp_path):
} }
] ]
assert "about:debugging#/runtime/this-firefox" in result.output 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 assert "Firefox extension ID" in result.output
def test_install_windows_registers_native_host(tmp_path): def test_install_windows_registers_native_host(tmp_path):
+7 -7
View File
@@ -74,7 +74,7 @@ def test_large_extension_operations_yield_between_batches():
assert "GENTLE_OPERATION_PAUSE_MS" in core assert "GENTLE_OPERATION_PAUSE_MS" in core
assert "itemCount >= 300" in core assert "itemCount >= 300" in core
assert "itemCount >= 100" in core assert "itemCount >= 100" in core
assert "chrome.tabs.query({ audible: true })" in core assert "api.tabs.query({ audible: true })" in core
# The centralized batch loop drives cancellation + progress + throttled yield. # The centralized batch loop drives cancellation + progress + throttled yield.
assert "processInBatches" in core assert "processInBatches" in core
assert "throwIfJobCancelled(progress.job)" in core assert "throwIfJobCancelled(progress.job)" in core
@@ -93,7 +93,7 @@ def test_large_extension_operations_yield_between_batches():
assert "yieldForLargeOperation(createdTabs.length" in session assert "yieldForLargeOperation(createdTabs.length" in session
assert "getLargeOperationThrottle" in session assert "getLargeOperationThrottle" in session
assert "runLargeOperation(\"session.load\"" in session assert "runLargeOperation(\"session.load\"" in session
assert "chrome.tabs.discard" in session assert "api.tabs.discard" in session
assert "lazyPlaceholderUrl" in session assert "lazyPlaceholderUrl" in session
assert "activateLazyTab" in session assert "activateLazyTab" in session
assert "lazySessionTabs" in session assert "lazySessionTabs" in session
@@ -136,8 +136,8 @@ def test_built_extension_avoids_static_firefox_unsupported_tab_group_api_refs():
assert "chrome.tabGroups" not in background assert "chrome.tabGroups" not in background
assert "chrome.tabs.group" not in background assert "chrome.tabs.group" not in background
assert "chrome.tabs.ungroup" not in background assert "chrome.tabs.ungroup" not in background
assert 'chrome["tabGroups"' in background assert 'webExtApi["tabGroups"' in background
assert 'chrome.tabs["group"' in background assert 'webExtApi.tabs["group"' in background
def test_built_extension_avoids_direct_eval_token_for_firefox_linter(): def test_built_extension_avoids_direct_eval_token_for_firefox_linter():
background = read_built_background() background = read_built_background()
@@ -164,9 +164,9 @@ def test_session_autosave_is_debounced_and_non_overlapping():
assert "autoSaveSignature" in autosave assert "autoSaveSignature" in autosave
# AutoSaveManager binds the handlers as instance fields (this.*), so the # AutoSaveManager binds the handlers as instance fields (this.*), so the
# add/removeListener references stay identity-stable across enable/disable. # add/removeListener references stay identity-stable across enable/disable.
assert "chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave assert "api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave
assert "chrome.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave assert "api.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave
assert "chrome.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave assert "api.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave
assert "if (!(\"url\" in changeInfo)) return;" in autosave assert "if (!(\"url\" in changeInfo)) return;" in autosave
assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave
assert "clearTimeout(this.autoSaveTimer)" in autosave assert "clearTimeout(this.autoSaveTimer)" in autosave
+1 -1
View File
@@ -4,7 +4,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
"types": ["chrome"], "types": ["chrome", "firefox-webext-browser"],
"allowJs": false, "allowJs": false,
"strict": false, "strict": false,
"noImplicitAny": true, "noImplicitAny": true,
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.1" version = "0.15.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },