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
+20 -18
View File
@@ -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<void> => {
await this.scheduleAutoSave();
};
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise<void> => {
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: TabUpdateInfo = {}): Promise<void> => {
// 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);
+3 -1
View File
@@ -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;
+4 -3
View File
@@ -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<string, CommandEntry> = {
"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,
+9 -8
View File
@@ -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);
+40 -15
View File
@@ -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<Tab> {
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})`);
+4 -2
View File
@@ -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);
+19 -16
View File
@@ -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 };
}
}
+9 -8
View File
@@ -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);
+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 };
}
}
+7 -5
View File
@@ -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 };
}
}