feat: add Firefox extension support
- 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:
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.14.3",
|
||||
"version": "0.15.1",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "browser-cli@yiprawr.dev",
|
||||
"strict_min_version": "120.0"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"tabGroups",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user