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