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