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
+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 };
}