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
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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.15.1",
|
||||
"version": "0.15.2",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Cross-browser WebExtension API entry point.
|
||||
*
|
||||
* Firefox exposes the Promise-based WebExtension API as `browser.*`.
|
||||
* Chromium exposes the same extension API as `chrome.*`.
|
||||
* Runtime modules import this neutral adapter as `api`, so Firefox uses its
|
||||
* native `browser` object and Chromium uses its native `chrome` object. No
|
||||
* browser-specific global is faked or overwritten.
|
||||
*/
|
||||
|
||||
import type { WebExtensionApi } from './types';
|
||||
|
||||
type WebExtensionGlobal = {
|
||||
browser?: typeof browser;
|
||||
chrome?: typeof chrome;
|
||||
};
|
||||
|
||||
function currentApi(): typeof browser | typeof chrome {
|
||||
const webExtensionGlobal = globalThis as object as WebExtensionGlobal;
|
||||
const api = webExtensionGlobal.browser || webExtensionGlobal.chrome;
|
||||
if (!api) {
|
||||
throw new Error("WebExtension API is not available: expected browser.* or chrome.*");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
export const webExtApi = new Proxy({}, {
|
||||
get(_target: object, property: string | symbol) {
|
||||
return currentApi()[property as keyof ReturnType<typeof currentApi>];
|
||||
},
|
||||
}) as object as WebExtensionApi;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
import { CommandGroup } from './CommandGroup';
|
||||
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
|
||||
import { NavigationCommands } from '../commands/navigation';
|
||||
@@ -74,7 +75,7 @@ export class CommandRegistry {
|
||||
/**
|
||||
* Builds the registry and registers every command group. The SessionCommands
|
||||
* instance is returned alongside because index.ts wires its lifecycle methods
|
||||
* (chrome.tabs.onActivated → activateLazyTab) and NativeConnection references it
|
||||
* (api.tabs.onActivated → activateLazyTab) and NativeConnection references it
|
||||
* for the clients.rename_profile reconnect side-effect.
|
||||
*/
|
||||
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
/**
|
||||
* Background-job retention helpers + the JobManager that owns the live job map.
|
||||
*
|
||||
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of chrome.* /
|
||||
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of api.* /
|
||||
* service-worker side effects so the retention logic (memory-leak guard) can be
|
||||
* unit-tested in isolation.
|
||||
*/
|
||||
@@ -16,7 +17,7 @@ export const MAX_FINISHED_JOBS = 20;
|
||||
|
||||
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
|
||||
// dead tab), finalize the job as an error so its persist interval stops instead
|
||||
// of writing to chrome.storage.local every second forever.
|
||||
// of writing to api.storage.local every second forever.
|
||||
export const JOB_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
@@ -65,11 +66,11 @@ export class JobManager {
|
||||
const running = all.filter(job => job.status === "running");
|
||||
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
|
||||
const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest);
|
||||
await chrome.storage.local.set({ recentJobs });
|
||||
await api.storage.local.set({ recentJobs });
|
||||
}
|
||||
|
||||
// Evict the oldest finished jobs once their count exceeds the retention cap.
|
||||
// Recent finished jobs remain queryable via chrome.storage.local (persistJobs)
|
||||
// Recent finished jobs remain queryable via api.storage.local (persistJobs)
|
||||
// even after eviction from the in-memory Map.
|
||||
private pruneJobs() {
|
||||
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
|
||||
@@ -143,7 +144,7 @@ export class JobManager {
|
||||
async status({ jobId }: { jobId?: string }) {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) return { ...job };
|
||||
const { recentJobs } = await chrome.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
|
||||
const { recentJobs } = await api.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
|
||||
const stored = (recentJobs || []).find(entry => entry.id === jobId);
|
||||
if (!stored) throw new Error(`Job '${jobId}' not found`);
|
||||
return stored;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { webExtApi as api } from '../browser-api';
|
||||
/**
|
||||
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
|
||||
* message router that hands commands to the CommandRegistry.
|
||||
@@ -6,7 +7,7 @@
|
||||
import { getErrorMessage, getProfileAlias } from '../core';
|
||||
import type { CommandRegistry } from './CommandRegistry';
|
||||
import type { SessionCommands } from '../commands/session';
|
||||
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable } from '../types';
|
||||
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable, RuntimePort } from '../types';
|
||||
|
||||
const NATIVE_HOST = "com.browsercli.host";
|
||||
const DEBUG_LOG = false;
|
||||
@@ -16,7 +17,7 @@ function debugLog(...args: Serializable[]) {
|
||||
}
|
||||
|
||||
export class NativeConnection {
|
||||
private port: chrome.runtime.Port | null = null;
|
||||
private port: RuntimePort | null = null;
|
||||
private keepaliveEnabled = true;
|
||||
|
||||
constructor(
|
||||
@@ -26,17 +27,17 @@ export class NativeConnection {
|
||||
|
||||
/** Registers all runtime listeners and opens the initial connection. */
|
||||
start() {
|
||||
chrome.runtime.onInstalled.addListener(() => this.connect());
|
||||
chrome.runtime.onStartup.addListener(() => this.connect());
|
||||
chrome.runtime.onSuspend.addListener(() => {
|
||||
api.runtime.onInstalled.addListener(() => this.connect());
|
||||
api.runtime.onStartup.addListener(() => this.connect());
|
||||
api.runtime.onSuspend.addListener(() => {
|
||||
this.disconnectPort({ sendBye: true });
|
||||
});
|
||||
chrome.windows.onCreated.addListener(() => {
|
||||
api.windows.onCreated.addListener(() => {
|
||||
this.keepaliveEnabled = true;
|
||||
if (!this.port) this.connect();
|
||||
});
|
||||
chrome.windows.onRemoved.addListener(async () => {
|
||||
const windows = await chrome.windows.getAll({});
|
||||
api.windows.onRemoved.addListener(async () => {
|
||||
const windows = await api.windows.getAll({});
|
||||
if (windows.length > 0) return;
|
||||
|
||||
this.keepaliveEnabled = false;
|
||||
@@ -46,15 +47,15 @@ export class NativeConnection {
|
||||
// Reconnect poll — wakes the worker to re-establish the native port if it
|
||||
// dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4)
|
||||
// are silently clamped and log a warning, so we set it explicitly.
|
||||
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
api.alarms.create("keepalive", { periodInMinutes: 0.5 });
|
||||
api.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "keepalive") {
|
||||
if (!this.port && this.keepaliveEnabled) this.connect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sendControlMessage(targetPort: chrome.runtime.Port | null, message: ControlMessage) {
|
||||
private sendControlMessage(targetPort: RuntimePort | null, message: ControlMessage) {
|
||||
if (!targetPort) return;
|
||||
try {
|
||||
targetPort.postMessage(message);
|
||||
@@ -63,7 +64,7 @@ export class NativeConnection {
|
||||
}
|
||||
}
|
||||
|
||||
private sendResponse(targetPort: chrome.runtime.Port | null, message: ResponseMessage) {
|
||||
private sendResponse(targetPort: RuntimePort | null, message: ResponseMessage) {
|
||||
if (!targetPort) return;
|
||||
try {
|
||||
targetPort.postMessage(message);
|
||||
@@ -90,12 +91,12 @@ export class NativeConnection {
|
||||
private async connect() {
|
||||
if (this.port || !this.keepaliveEnabled) return;
|
||||
try {
|
||||
const nativePort = chrome.runtime.connectNative(NATIVE_HOST);
|
||||
const nativePort = api.runtime.connectNative(NATIVE_HOST);
|
||||
this.port = nativePort;
|
||||
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
|
||||
nativePort.onDisconnect.addListener(() => {
|
||||
if (this.port === nativePort) this.port = null;
|
||||
const err = chrome.runtime.lastError;
|
||||
const err = api.runtime.lastError;
|
||||
if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
|
||||
});
|
||||
// Send hello so native host knows which profile/alias this is
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || {};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* the native connection.
|
||||
*/
|
||||
|
||||
import { webExtApi as api } from './browser-api';
|
||||
import { JobManager } from './classes/JobManager';
|
||||
import { assembleRegistry } from './classes/CommandRegistry';
|
||||
import { NativeConnection } from './classes/NativeConnection';
|
||||
@@ -15,7 +16,7 @@ const jobs = new JobManager();
|
||||
const ctx: CommandContext = { jobs };
|
||||
const { registry, session } = assembleRegistry(ctx);
|
||||
|
||||
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
|
||||
api.tabs.onActivated.addListener(async ({ tabId }) => {
|
||||
await session.activateLazyTab(tabId);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface DomEvalArgs { code?: string; tabId?: number; }
|
||||
export interface DomWaitForArgs { selector?: string; timeout?: number; visible?: boolean; hidden?: boolean; tabId?: number; }
|
||||
export interface DomPollArgs { selector?: string; pattern?: string; attr?: string; timeout?: number; interval?: number; tabId?: number; }
|
||||
|
||||
/** Arguments forwarded to the in-page content functions over chrome.scripting. */
|
||||
/** Arguments forwarded to the in-page content functions over browser.scripting. */
|
||||
export interface ContentArgs {
|
||||
selector?: string;
|
||||
text?: string;
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from './json';
|
||||
export * from './jobs';
|
||||
export * from './session';
|
||||
export * from './tabs';
|
||||
export * from './webextension';
|
||||
export * from './messages';
|
||||
export * from './command-args';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Serializable } from './json';
|
||||
|
||||
export type RuntimePort = browser.runtime.Port;
|
||||
export type Tab = browser.tabs.Tab & {
|
||||
groupId?: number;
|
||||
pendingUrl?: string;
|
||||
};
|
||||
export type TabUpdateInfo = Parameters<typeof browser.tabs.onUpdated.addListener>[0] extends (tabId: number, changeInfo: infer ChangeInfo, tab: browser.tabs.Tab) => void ? ChangeInfo : { url?: string };
|
||||
export type BrowserWindow = browser.windows.Window & { tabs?: Tab[] };
|
||||
export type WindowCreateData = browser.windows._CreateCreateData;
|
||||
export type TabMoveProperties = browser.tabs._MoveMoveProperties;
|
||||
export type TabGroupOptions = browser.tabs._GroupOptions;
|
||||
export type TabGroup = browser.tabGroups.TabGroup;
|
||||
export type TabGroupColor = browser.tabGroups.Color;
|
||||
export type TabGroupQueryInfo = browser.tabGroups._QueryInfo;
|
||||
export type TabGroupUpdateProperties = browser.tabGroups._UpdateProperties;
|
||||
export type TabGroupMoveProperties = browser.tabGroups._MoveProperties;
|
||||
export type BrowserEvent<TCallback extends (...args: never[]) => void> = {
|
||||
addListener(cb: TCallback): void;
|
||||
removeListener(cb: TCallback): void;
|
||||
hasListener(cb: TCallback): boolean;
|
||||
};
|
||||
export type ScriptInjection<Args extends Serializable[]> = browser.scripting.ScriptInjection<Args>;
|
||||
export type ScriptInjectionResult<Result> = browser.scripting.InjectionResult & { result?: Awaited<Result> };
|
||||
export type StorageLocal = Omit<typeof browser.storage.local, "get"> & {
|
||||
get<T extends object = { [key: string]: Serializable }>(keys?: string | string[] | object | null): Promise<T>;
|
||||
};
|
||||
export type WebExtensionApi = Omit<typeof browser, "tabs" | "windows" | "storage"> & {
|
||||
tabs: Omit<typeof browser.tabs, "query" | "get" | "create" | "update" | "move"> & {
|
||||
query(queryInfo: browser.tabs._QueryQueryInfo): Promise<Tab[]>;
|
||||
get(tabId: number): Promise<Tab>;
|
||||
create(createProperties: browser.tabs._CreateCreateProperties): Promise<Tab>;
|
||||
update(tabId: number, updateProperties: browser.tabs._UpdateUpdateProperties): Promise<Tab>;
|
||||
move(tabIds: number | number[], moveProperties: TabMoveProperties): Promise<Tab | Tab[]>;
|
||||
};
|
||||
windows: Omit<typeof browser.windows, "getAll" | "create"> & {
|
||||
getAll(getInfo?: browser.windows._GetAllGetInfo): Promise<BrowserWindow[]>;
|
||||
create(createData?: WindowCreateData): Promise<BrowserWindow>;
|
||||
};
|
||||
storage: Omit<typeof browser.storage, "local"> & { local: StorageLocal };
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// @ts-nocheck
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { webExtApi } from '../src/browser-api';
|
||||
|
||||
test('browser-api uses Firefox browser.* before Chromium chrome.*', () => {
|
||||
const originalChrome = globalThis.chrome;
|
||||
const originalBrowser = globalThis.browser;
|
||||
const firefoxApi = { runtime: { id: 'firefox-api' } };
|
||||
const chromiumApi = { runtime: { id: 'chromium-api' } };
|
||||
|
||||
try {
|
||||
globalThis.chrome = chromiumApi;
|
||||
globalThis.browser = firefoxApi;
|
||||
|
||||
assert.equal(webExtApi.runtime, firefoxApi.runtime);
|
||||
} finally {
|
||||
if (originalChrome === undefined) delete globalThis.chrome;
|
||||
else globalThis.chrome = originalChrome;
|
||||
|
||||
if (originalBrowser === undefined) delete globalThis.browser;
|
||||
else globalThis.browser = originalBrowser;
|
||||
}
|
||||
});
|
||||
|
||||
test('browser-api falls back to chrome.* in Chromium', () => {
|
||||
const originalChrome = globalThis.chrome;
|
||||
const originalBrowser = globalThis.browser;
|
||||
const chromiumApi = { runtime: { id: 'chromium-api' } };
|
||||
|
||||
try {
|
||||
globalThis.chrome = chromiumApi;
|
||||
delete globalThis.browser;
|
||||
|
||||
assert.equal(webExtApi.runtime, chromiumApi.runtime);
|
||||
} finally {
|
||||
if (originalChrome === undefined) delete globalThis.chrome;
|
||||
else globalThis.chrome = originalChrome;
|
||||
|
||||
if (originalBrowser === undefined) delete globalThis.browser;
|
||||
else globalThis.browser = originalBrowser;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// @ts-nocheck
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SessionCommands } from '../src/commands/session';
|
||||
import { JobManager } from '../src/classes/JobManager';
|
||||
import { makeChromeMock } from './chrome-mock';
|
||||
|
||||
function makeSessionCommands() {
|
||||
return new SessionCommands({ jobs: new JobManager() });
|
||||
}
|
||||
|
||||
test('clients.list uses Firefox runtime.getBrowserInfo when available', async () => {
|
||||
const originalChrome = globalThis.chrome;
|
||||
const originalBrowser = globalThis.browser;
|
||||
const originalNavigator = globalThis.navigator;
|
||||
const chromeMock = makeChromeMock();
|
||||
|
||||
try {
|
||||
delete globalThis.chrome;
|
||||
globalThis.browser = {
|
||||
...chromeMock,
|
||||
runtime: {
|
||||
getManifest: () => ({ version: '0.15.1' }),
|
||||
getBrowserInfo: async () => ({ name: 'Firefox', vendor: 'Mozilla', version: '149.0', buildID: 'test' }),
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Firefox/149.0' },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const clients = await makeSessionCommands().commands['clients.list']({});
|
||||
|
||||
assert.equal(clients[0].name, 'Firefox');
|
||||
assert.equal(clients[0].version, '149.0');
|
||||
assert.equal(clients[0].extensionVersion, '0.15.1');
|
||||
} finally {
|
||||
if (originalChrome === undefined) delete globalThis.chrome;
|
||||
else globalThis.chrome = originalChrome;
|
||||
|
||||
if (originalBrowser === undefined) delete globalThis.browser;
|
||||
else globalThis.browser = originalBrowser;
|
||||
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('clients.list falls back to Chromium user-agent when getBrowserInfo is missing', async () => {
|
||||
const originalChrome = globalThis.chrome;
|
||||
const originalBrowser = globalThis.browser;
|
||||
const originalNavigator = globalThis.navigator;
|
||||
const chromeMock = makeChromeMock();
|
||||
|
||||
try {
|
||||
delete globalThis.browser;
|
||||
globalThis.chrome = {
|
||||
...chromeMock,
|
||||
runtime: {
|
||||
getManifest: () => ({ version: '0.15.1' }),
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Chrome/149.0.0.0 Safari/537.36' },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const clients = await makeSessionCommands().commands['clients.list']({});
|
||||
|
||||
assert.equal(clients[0].name, 'Chrome');
|
||||
assert.equal(clients[0].version, '149.0.0.0');
|
||||
} finally {
|
||||
if (originalChrome === undefined) delete globalThis.chrome;
|
||||
else globalThis.chrome = originalChrome;
|
||||
|
||||
if (originalBrowser === undefined) delete globalThis.browser;
|
||||
else globalThis.browser = originalBrowser;
|
||||
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// @ts-nocheck
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { NavigationCommands } from '../src/commands/navigation';
|
||||
import { JobManager } from '../src/classes/JobManager';
|
||||
import { makeChromeMock } from './chrome-mock';
|
||||
|
||||
function makeNavigationCommands() {
|
||||
return new NavigationCommands({ jobs: new JobManager() });
|
||||
}
|
||||
|
||||
test('navigate.open waits until Firefox updates about:blank to the requested URL', async () => {
|
||||
const originalChrome = globalThis.chrome;
|
||||
const originalBrowser = globalThis.browser;
|
||||
const originalNavigator = globalThis.navigator;
|
||||
const firefoxApi = makeChromeMock();
|
||||
const targetUrl = 'https://example.com/?browser-cli-firefox-open-wait=1';
|
||||
let getCalls = 0;
|
||||
|
||||
try {
|
||||
delete globalThis.chrome;
|
||||
globalThis.browser = {
|
||||
...firefoxApi,
|
||||
runtime: {
|
||||
getManifest: () => ({ version: '0.15.1' }),
|
||||
getBrowserInfo: async () => ({ name: 'Firefox', vendor: 'Mozilla', version: '151.0.2', buildID: 'test' }),
|
||||
},
|
||||
tabs: {
|
||||
...firefoxApi.tabs,
|
||||
create: async () => ({ id: 123, windowId: 1, index: 0, active: true, groupId: -1, url: 'about:blank' }),
|
||||
get: async () => {
|
||||
getCalls += 1;
|
||||
return {
|
||||
id: 123,
|
||||
windowId: 1,
|
||||
index: 0,
|
||||
active: true,
|
||||
groupId: -1,
|
||||
url: getCalls < 2 ? 'about:blank' : targetUrl,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { platform: 'test-platform', userAgent: 'Mozilla/5.0 Firefox/151.0.2' },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = await makeNavigationCommands().commands['navigate.open']({ url: targetUrl, focus: true });
|
||||
|
||||
assert.equal(result.id, 123);
|
||||
assert.equal(result.url, targetUrl);
|
||||
assert.ok(getCalls >= 2);
|
||||
} finally {
|
||||
if (originalChrome === undefined) delete globalThis.chrome;
|
||||
else globalThis.chrome = originalChrome;
|
||||
|
||||
if (originalBrowser === undefined) delete globalThis.browser;
|
||||
else globalThis.browser = originalBrowser;
|
||||
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user