feat: add Firefox extension support
Testing / remote-protocol-compat (0.9.5) (push) Successful in 48s
Testing / test (push) Failing after 53s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s

- Add Firefox as an install target with native messaging manifest support.
- Generate Firefox-specific extension packages with Gecko metadata and AMO-compatible manifest transforms.
- Keep tab group commands available in Firefox through dynamic tab group API helpers.
- Avoid Firefox linter warnings for static tab group API references and direct eval tokens.
- Add Firefox packaging and installer regression coverage.
- Bump the package and extension version to 0.15.1.
This commit is contained in:
2026-06-14 17:19:25 +02:00
parent 9df5e1bd8f
commit 9b8cefcd72
21 changed files with 225 additions and 62 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
import { getSessions, runLargeOperation } from '../core';
import { getSessions, runLargeOperation, tabGroupsOnUpdated } from '../core';
import { captureCurrentSession } from './session-snapshot';
// Debounce window for autosave. A full-tab snapshot + storage write runs on
@@ -30,7 +30,7 @@ export class AutoSaveManager {
chrome.tabs.onAttached.removeListener(this.autoSaveHandler);
chrome.tabs.onDetached.removeListener(this.autoSaveHandler);
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(this.autoSaveHandler);
tabGroupsOnUpdated()?.removeListener(this.autoSaveHandler);
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = null;
this.autoSavePending = false;
@@ -41,7 +41,7 @@ export class AutoSaveManager {
chrome.tabs.onAttached.addListener(this.autoSaveHandler);
chrome.tabs.onDetached.addListener(this.autoSaveHandler);
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler);
tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
}
return { enabled };
}
+4 -1
View File
@@ -105,7 +105,10 @@ export class DomCommands extends CommandGroup {
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (c: string) => (0, eval)(c),
func: (c: string) => {
const evaluate = globalThis["eval" as keyof typeof globalThis] as (source: string) => unknown;
return evaluate(c);
},
args: [code],
});
return results[0]?.result ?? null;
+11 -11
View File
@@ -1,4 +1,4 @@
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, processInBatches, resolveGroupId, runLargeOperation, tabInfo } from '../core';
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';
import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types';
@@ -17,7 +17,7 @@ export class GroupsCommands extends CommandGroup {
};
private async groupList() {
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
const all = await chrome.tabs.query({});
return groups.map(g => ({
id: g.id,
@@ -35,13 +35,13 @@ export class GroupsCommands extends CommandGroup {
}
private async groupCount() {
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
return groups.length;
}
private async groupQuery({ search }: GroupQueryArgs) {
const q = search.toLowerCase();
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
}
@@ -51,15 +51,15 @@ export class GroupsCommands extends CommandGroup {
const groupTabs = tabs.filter(t => t.groupId === groupId);
const tabIds = groupTabs.map(t => t.id);
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
await processInBatches(tabIds, throttle, batch => chrome.tabs.ungroup(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
await processInBatches(tabIds, throttle, batch => ungroupTabs(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
});
}
private async groupOpen({ name }: GroupOpenArgs) {
const tab = await chrome.tabs.create({ active: true });
const groupId = await chrome.tabs.group({ tabIds: asTabIds([tab.id]) });
await chrome.tabGroups.update(groupId, { title: name });
const groupId = await groupTabs({ tabIds: asTabIds([tab.id]) });
await updateTabGroup(groupId, { title: name });
return { id: groupId, name };
}
@@ -67,7 +67,7 @@ export class GroupsCommands extends CommandGroup {
const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
await chrome.tabs.group({ tabIds: asTabIds([tab.id]), groupId });
await groupTabs({ tabIds: asTabIds([tab.id]), groupId });
// If a URL was provided, close any blank placeholder tabs left from group creation
if (url) {
const placeholders = existingTabs.filter(t =>
@@ -80,7 +80,7 @@ export class GroupsCommands extends CommandGroup {
private async groupMove({ group, forward, backward }: GroupMoveArgs) {
const groupId = await resolveGroupId(group);
const groupInfo = await chrome.tabGroups.get(groupId);
const groupInfo = await getTabGroup(groupId);
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index);
@@ -98,7 +98,7 @@ export class GroupsCommands extends CommandGroup {
nextBlock.groupId === null
? currentBlock.startIndex + 1
: nextBlock.endIndex - currentLength + 1;
await chrome.tabGroups.move(groupId, { index: targetIndex });
await moveTabGroup(groupId, { index: targetIndex });
} else if (backward) {
const previousBlock = blocks[currentIdx - 1];
if (!previousBlock) return { groupId, moved: false };
@@ -106,7 +106,7 @@ export class GroupsCommands extends CommandGroup {
previousBlock.groupId === null
? currentBlock.startIndex - 1
: previousBlock.startIndex;
await chrome.tabGroups.move(groupId, { index: targetIndex });
await moveTabGroup(groupId, { index: targetIndex });
}
return { groupId, moved: true };
+4 -4
View File
@@ -1,4 +1,4 @@
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core';
import { getActiveTab, getAliases, groupTabs as groupTabIds, isBrowserErrorUrl, resolveGroupId, tabInfo, updateTabGroup } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types';
@@ -37,13 +37,13 @@ export class NavigationCommands extends CommandGroup {
t.id !== tab.id &&
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
);
await chrome.tabs.group({ tabIds: [tab.id], groupId });
await groupTabIds({ tabIds: [tab.id], groupId });
if (placeholders.length) await chrome.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
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
groupId = await groupTabIds({ tabIds: [tab.id] });
await updateTabGroup(groupId, { title: String(groupNameOrId) });
}
}
return { id: tab.id, url: tab.url };
+2 -2
View File
@@ -1,4 +1,4 @@
import { normalizeGroupColor } from '../core';
import { normalizeGroupColor, queryTabGroups } from '../core';
import type { SessionTab, StoredSession } from '../types';
export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] {
@@ -28,7 +28,7 @@ export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tab
*/
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const session: StoredSession = {
+3 -3
View File
@@ -1,4 +1,4 @@
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, groupTabs, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, updateTabGroup, yieldForLargeOperation } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import { AutoSaveManager } from './autosave';
import { captureCurrentSession } from './session-snapshot';
@@ -91,8 +91,8 @@ export class SessionCommands extends CommandGroup {
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
for (const { meta, tabIds } of groups.values()) {
throwIfJobCancelled(__job);
const restoredGroupId = await chrome.tabs.group({ tabIds });
await chrome.tabGroups.update(restoredGroupId, {
const restoredGroupId = await groupTabs({ tabIds });
await updateTabGroup(restoredGroupId, {
title: meta.title || "",
color: normalizeGroupColor(meta.color),
collapsed: Boolean(meta.collapsed),
+2 -2
View File
@@ -1,4 +1,4 @@
import { asTabIds, getActiveTab, getLargeOperationThrottle, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
import { asTabIds, getActiveTab, getLargeOperationThrottle, groupTabs, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup';
import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types';
@@ -64,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);
if (groupId != null) {
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId });
await groupTabs({ tabIds: asTabIds([tabId]), groupId });
}
return { tabId };
}
+2 -1
View File
@@ -1,9 +1,10 @@
// Tab-group resolution and normalization helpers.
import { queryTabGroups } from './tab-groups';
export async function resolveGroupId(nameOrId: string | number): Promise<number> {
const asInt = parseInt(String(nameOrId));
if (!isNaN(asInt)) return asInt;
const groups = await chrome.tabGroups.query({});
const groups = await queryTabGroups({});
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
return match.id;
+1
View File
@@ -3,4 +3,5 @@ export * from './throttle';
export * from './scripting';
export * from './tab-helpers';
export * from './group-helpers';
export * from './tab-groups';
export * from './storage';
+55
View File
@@ -0,0 +1,55 @@
// 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
// references in commands that will fail gracefully at runtime.
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 tabsGroupApi(): typeof chrome.tabs.group {
const fn = chrome.tabs["group" as keyof typeof chrome.tabs] as typeof chrome.tabs.group | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(chrome.tabs);
}
function tabsUngroupApi(): typeof chrome.tabs.ungroup {
const fn = chrome.tabs["ungroup" as keyof typeof chrome.tabs] as typeof chrome.tabs.ungroup | undefined;
if (!fn) throw new Error(TAB_GROUPS_UNSUPPORTED);
return fn.bind(chrome.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 getTabGroup(groupId: number): Promise<chrome.tabGroups.TabGroup> {
return tabGroupsApi().get(groupId);
}
export async function updateTabGroup(groupId: number, updateProperties: chrome.tabGroups.UpdateProperties): Promise<chrome.tabGroups.TabGroup> {
return tabGroupsApi().update(groupId, updateProperties);
}
export async function moveTabGroup(groupId: number, moveProperties: chrome.tabGroups.MoveProperties): Promise<chrome.tabGroups.TabGroup> {
return tabGroupsApi().move(groupId, moveProperties);
}
export async function groupTabs(createProperties: chrome.tabs.GroupOptions): Promise<number> {
return tabsGroupApi()(createProperties);
}
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;
}