From 477a00db1af069a73f6bc5ba8957ffc0720eec37 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Sun, 14 Jun 2026 19:09:10 +0200 Subject: [PATCH] feat(extension): add Firefox WebExtension support - 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. --- README.md | 2 + browser_cli/commands/install.py | 11 ++- extension/manifest.json | 2 +- extension/src/browser-api.ts | 31 ++++++++ extension/src/classes/CommandRegistry.ts | 3 +- extension/src/classes/JobManager.ts | 11 +-- extension/src/classes/NativeConnection.ts | 29 +++---- extension/src/commands/autosave.ts | 38 ++++----- extension/src/commands/dom.ts | 4 +- extension/src/commands/extension.ts | 7 +- extension/src/commands/groups.ts | 17 ++-- extension/src/commands/navigation.ts | 55 +++++++++---- extension/src/commands/session-snapshot.ts | 6 +- extension/src/commands/session.ts | 35 +++++---- extension/src/commands/tabs-query.ts | 17 ++-- extension/src/commands/tabs.ts | 46 +++++------ extension/src/commands/windows.ts | 12 +-- extension/src/core/group-helpers.ts | 6 +- extension/src/core/scripting.ts | 10 ++- extension/src/core/storage.ts | 9 ++- extension/src/core/tab-groups.ts | 44 ++++++----- extension/src/core/tab-helpers.ts | 32 ++++---- extension/src/core/throttle.ts | 7 +- extension/src/index.ts | 3 +- extension/src/types/command-args.ts | 2 +- extension/src/types/index.ts | 1 + extension/src/types/webextension.ts | 41 ++++++++++ extension/test/browser-api.test.ts | 43 +++++++++++ extension/test/clients.test.ts | 86 +++++++++++++++++++++ extension/test/navigation.test.ts | 66 ++++++++++++++++ package-lock.json | 8 ++ package.json | 1 + pyproject.toml | 2 +- tests/test_cli.py | 4 + tests/test_extension_error_page_handling.py | 14 ++-- tsconfig.json | 2 +- uv.lock | 2 +- 37 files changed, 526 insertions(+), 183 deletions(-) create mode 100644 extension/src/browser-api.ts create mode 100644 extension/src/types/webextension.ts create mode 100644 extension/test/browser-api.test.ts create mode 100644 extension/test/clients.test.ts create mode 100644 extension/test/navigation.test.ts diff --git a/README.md b/README.md index 4aaad90..5a0c3d2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/browser_cli/commands/install.py b/browser_cli/commands/install.py index 259922e..1d86779 100644 --- a/browser_cli/commands/install.py +++ b/browser_cli/commands/install.py @@ -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)") diff --git a/extension/manifest.json b/extension/manifest.json index 8b557c2..6448cf7 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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": { diff --git a/extension/src/browser-api.ts b/extension/src/browser-api.ts new file mode 100644 index 0000000..c8e898b --- /dev/null +++ b/extension/src/browser-api.ts @@ -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]; + }, +}) as object as WebExtensionApi; diff --git a/extension/src/classes/CommandRegistry.ts b/extension/src/classes/CommandRegistry.ts index 6466ad6..d85a62e 100644 --- a/extension/src/classes/CommandRegistry.ts +++ b/extension/src/classes/CommandRegistry.ts @@ -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 } { diff --git a/extension/src/classes/JobManager.ts b/extension/src/classes/JobManager.ts index 860cebf..b4517c0 100644 --- a/extension/src/classes/JobManager.ts +++ b/extension/src/classes/JobManager.ts @@ -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; diff --git a/extension/src/classes/NativeConnection.ts b/extension/src/classes/NativeConnection.ts index 433f36e..a446ede 100644 --- a/extension/src/classes/NativeConnection.ts +++ b/extension/src/classes/NativeConnection.ts @@ -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 diff --git a/extension/src/commands/autosave.ts b/extension/src/commands/autosave.ts index cedf2ae..1343350 100644 --- a/extension/src/commands/autosave.ts +++ b/extension/src/commands/autosave.ts @@ -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 => { await this.scheduleAutoSave(); }; - readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise => { + readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: TabUpdateInfo = {}): Promise => { // 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); diff --git a/extension/src/commands/dom.ts b/extension/src/commands/dom.ts index ec324f8..b605acb 100644 --- a/extension/src/commands/dom.ts +++ b/extension/src/commands/dom.ts @@ -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; diff --git a/extension/src/commands/extension.ts b/extension/src/commands/extension.ts index 9fad573..f94c58e 100644 --- a/extension/src/commands/extension.ts +++ b/extension/src/commands/extension.ts @@ -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 = { "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, diff --git a/extension/src/commands/groups.ts b/extension/src/commands/groups.ts index b088916..ecdb36e 100644 --- a/extension/src/commands/groups.ts +++ b/extension/src/commands/groups.ts @@ -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); diff --git a/extension/src/commands/navigation.ts b/extension/src/commands/navigation.ts index 73eb90f..54271e3 100644 --- a/extension/src/commands/navigation.ts +++ b/extension/src/commands/navigation.ts @@ -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 { + 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})`); diff --git a/extension/src/commands/session-snapshot.ts b/extension/src/commands/session-snapshot.ts index 5340745..154206a 100644 --- a/extension/src/commands/session-snapshot.ts +++ b/extension/src/commands/session-snapshot.ts @@ -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); diff --git a/extension/src/commands/session.ts b/extension/src/commands/session.ts index 52f0b34..62aa15d 100644 --- a/extension/src/commands/session.ts +++ b/extension/src/commands/session.ts @@ -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 }; } } diff --git a/extension/src/commands/tabs-query.ts b/extension/src/commands/tabs-query.ts index 1203c58..874cf7b 100644 --- a/extension/src/commands/tabs-query.ts +++ b/extension/src/commands/tabs-query.ts @@ -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); diff --git a/extension/src/commands/tabs.ts b/extension/src/commands/tabs.ts index 435178e..a3215e5 100644 --- a/extension/src/commands/tabs.ts +++ b/extension/src/commands/tabs.ts @@ -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(); 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 = {}; + const moveProps: Partial = {}; 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 }; } } diff --git a/extension/src/commands/windows.ts b/extension/src/commands/windows.ts index a2cbbcb..761b66d 100644 --- a/extension/src/commands/windows.ts +++ b/extension/src/commands/windows.ts @@ -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 }; } } diff --git a/extension/src/core/group-helpers.ts b/extension/src/core/group-helpers.ts index 5782dac..e270847 100644 --- a/extension/src/core/group-helpers.ts +++ b/extension/src/core/group-helpers.ts @@ -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 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; } diff --git a/extension/src/core/scripting.ts b/extension/src/core/scripting.ts index c5f73e2..024fb22 100644 --- a/extension/src/core/scripting.ts +++ b/extension/src/core/scripting.ts @@ -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( - options: chrome.scripting.ScriptInjection, + options: ScriptInjection, retries = 3, -): Promise>[]> { +): Promise[]> { 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); diff --git a/extension/src/core/storage.ts b/extension/src/core/storage.ts index e1a9fc5..609fa62 100644 --- a/extension/src/core/storage.ts +++ b/extension/src/core/storage.ts @@ -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 { - 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> { - const { windowAliases } = await chrome.storage.local.get<{ windowAliases?: Record }>("windowAliases"); + const { windowAliases } = await api.storage.local.get<{ windowAliases?: Record }>("windowAliases"); return windowAliases || {}; } export async function getSessions(): Promise> { - const { sessions } = await chrome.storage.local.get<{ sessions?: Record }>("sessions"); + const { sessions } = await api.storage.local.get<{ sessions?: Record }>("sessions"); return sessions || {}; } diff --git a/extension/src/core/tab-groups.ts b/extension/src/core/tab-groups.ts index 544cd45..792f374 100644 --- a/extension/src/core/tab-groups.ts +++ b/extension/src/core/tab-groups.ts @@ -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 { - 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 { + 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 { +export async function getTabGroup(groupId: number): Promise { return tabGroupsApi().get(groupId); } -export async function updateTabGroup(groupId: number, updateProperties: chrome.tabGroups.UpdateProperties): Promise { +export async function updateTabGroup(groupId: number, updateProperties: TabGroupUpdateProperties): Promise { return tabGroupsApi().update(groupId, updateProperties); } -export async function moveTabGroup(groupId: number, moveProperties: chrome.tabGroups.MoveProperties): Promise { +export async function moveTabGroup(groupId: number, moveProperties: TabGroupMoveProperties): Promise { return tabGroupsApi().move(groupId, moveProperties); } -export async function groupTabs(createProperties: chrome.tabs.GroupOptions): Promise { +export async function groupTabs(createProperties: TabGroupOptions): Promise { return tabsGroupApi()(createProperties); } @@ -49,7 +51,7 @@ export async function ungroupTabs(tabIds: [number, ...number[]]): Promise 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; } diff --git a/extension/src/core/tab-helpers.ts b/extension/src/core/tab-helpers.ts index 5cca827..f961d19 100644 --- a/extension/src/core/tab-helpers.ts +++ b/extension/src/core/tab-helpers.ts @@ -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 { +export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise { 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; diff --git a/extension/src/core/throttle.ts b/extension/src/core/throttle.ts index 45db217..1cb9aaa 100644 --- a/extension/src/core/throttle.ts +++ b/extension/src/core/throttle.ts @@ -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(name: string, fn: () => Promise): } 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 }; } diff --git a/extension/src/index.ts b/extension/src/index.ts index 36f5c13..02d082c 100644 --- a/extension/src/index.ts +++ b/extension/src/index.ts @@ -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); }); diff --git a/extension/src/types/command-args.ts b/extension/src/types/command-args.ts index f8bfddc..a3f9d32 100644 --- a/extension/src/types/command-args.ts +++ b/extension/src/types/command-args.ts @@ -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; diff --git a/extension/src/types/index.ts b/extension/src/types/index.ts index 6564de8..b1fcec3 100644 --- a/extension/src/types/index.ts +++ b/extension/src/types/index.ts @@ -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'; diff --git a/extension/src/types/webextension.ts b/extension/src/types/webextension.ts new file mode 100644 index 0000000..fc1c430 --- /dev/null +++ b/extension/src/types/webextension.ts @@ -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[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 void> = { + addListener(cb: TCallback): void; + removeListener(cb: TCallback): void; + hasListener(cb: TCallback): boolean; +}; +export type ScriptInjection = browser.scripting.ScriptInjection; +export type ScriptInjectionResult = browser.scripting.InjectionResult & { result?: Awaited }; +export type StorageLocal = Omit & { + get(keys?: string | string[] | object | null): Promise; +}; +export type WebExtensionApi = Omit & { + tabs: Omit & { + query(queryInfo: browser.tabs._QueryQueryInfo): Promise; + get(tabId: number): Promise; + create(createProperties: browser.tabs._CreateCreateProperties): Promise; + update(tabId: number, updateProperties: browser.tabs._UpdateUpdateProperties): Promise; + move(tabIds: number | number[], moveProperties: TabMoveProperties): Promise; + }; + windows: Omit & { + getAll(getInfo?: browser.windows._GetAllGetInfo): Promise; + create(createData?: WindowCreateData): Promise; + }; + storage: Omit & { local: StorageLocal }; +}; diff --git a/extension/test/browser-api.test.ts b/extension/test/browser-api.test.ts new file mode 100644 index 0000000..7c811de --- /dev/null +++ b/extension/test/browser-api.test.ts @@ -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; + } +}); diff --git a/extension/test/clients.test.ts b/extension/test/clients.test.ts new file mode 100644 index 0000000..2cdcea3 --- /dev/null +++ b/extension/test/clients.test.ts @@ -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, + }); + } +}); diff --git a/extension/test/navigation.test.ts b/extension/test/navigation.test.ts new file mode 100644 index 0000000..ae02d8f --- /dev/null +++ b/extension/test/navigation.test.ts @@ -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, + }); + } +}); diff --git a/package-lock.json b/package-lock.json index 815de9e..a0b7192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fd731b0..69d67f6 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/pyproject.toml b/pyproject.toml index f967b9b..44dbe5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_cli.py b/tests/test_cli.py index 5bee1cf..ec1e5fb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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): diff --git a/tests/test_extension_error_page_handling.py b/tests/test_extension_error_page_handling.py index 3012029..9a50fd9 100644 --- a/tests/test_extension_error_page_handling.py +++ b/tests/test_extension_error_page_handling.py @@ -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 diff --git a/tsconfig.json b/tsconfig.json index b5e6610..740b6d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2022", "DOM"], - "types": ["chrome"], + "types": ["chrome", "firefox-webext-browser"], "allowJs": false, "strict": false, "noImplicitAny": true, diff --git a/uv.lock b/uv.lock index cb59c8d..efc59c8 100644 --- a/uv.lock +++ b/uv.lock @@ -465,7 +465,7 @@ wheels = [ [[package]] name = "real-browser-cli" -version = "0.15.1" +version = "0.15.2" source = { editable = "." } dependencies = [ { name = "click" },