477a00db1a
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.
148 lines
6.6 KiB
TypeScript
148 lines
6.6 KiB
TypeScript
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';
|
|
import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types';
|
|
|
|
export class NavigationCommands extends CommandGroup {
|
|
readonly namespace = "navigate";
|
|
readonly commands: Record<string, CommandEntry> = {
|
|
"navigate.open": (a: NavOpenArgs) => this.navOpen(a),
|
|
"navigate.to": (a: NavToArgs) => this.navTo(a),
|
|
"navigate.reload": (a: NavTabArgs) => this.navReload(a, false),
|
|
"navigate.hard_reload": (a: NavTabArgs) => this.navReload(a, true),
|
|
"navigate.back": (a: NavTabArgs) => this.navBack(a),
|
|
"navigate.forward": (a: NavTabArgs) => this.navForward(a),
|
|
"navigate.focus": (a: NavFocusArgs) => this.navFocus(a),
|
|
"navigate.wait": (a: NavWaitArgs) => this.navWait(a),
|
|
"navigate.open_wait": (a: NavOpenWaitArgs) => this.navOpenWait(a),
|
|
};
|
|
|
|
private async navOpen({ url, background, focus, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) {
|
|
let windowId: number | undefined;
|
|
if (explicitWindowId != null) {
|
|
windowId = explicitWindowId;
|
|
} else if (windowName) {
|
|
const aliases = await getAliases();
|
|
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
|
|
if (entry) windowId = parseInt(entry[0]);
|
|
}
|
|
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 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 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
|
|
groupId = await groupTabIds({ tabIds: [tab.id] });
|
|
await updateTabGroup(groupId, { title: String(groupNameOrId) });
|
|
}
|
|
}
|
|
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 api.tabs.update(tabId, { url });
|
|
const deadline = Date.now() + 1000;
|
|
while (tabId && Date.now() < deadline) {
|
|
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 };
|
|
}
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
return { id: tab.id, url: tab.url || tab.pendingUrl || url };
|
|
}
|
|
|
|
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
|
|
const tab = tabId ? { id: tabId } : await getActiveTab();
|
|
await api.tabs.reload(tab.id, { bypassCache });
|
|
return { tabId: tab.id };
|
|
}
|
|
|
|
private async navBack({ tabId }: NavTabArgs) {
|
|
const tab = tabId ? { id: tabId } : await getActiveTab();
|
|
await api.tabs.goBack(tab.id);
|
|
return { tabId: tab.id };
|
|
}
|
|
|
|
private async navForward({ tabId }: NavTabArgs) {
|
|
const tab = tabId ? { id: tabId } : await getActiveTab();
|
|
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: Tab | undefined;
|
|
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
|
|
match = await api.tabs.get(asInt);
|
|
} else {
|
|
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 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 };
|
|
}
|
|
|
|
private async navWait({ tabId, timeout = 30000, readyState = "complete" }: NavWaitArgs = {}) {
|
|
const tab = tabId ? { id: tabId } : await getActiveTab();
|
|
const deadline = Date.now() + timeout;
|
|
const interval = 200;
|
|
while (Date.now() < deadline) {
|
|
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})`);
|
|
}
|
|
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
|
|
return tabInfo(t);
|
|
}
|
|
await new Promise(r => setTimeout(r, interval));
|
|
}
|
|
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
|
|
}
|
|
|
|
private async navOpenWait({ url, timeout = 30000, background, focus, window: windowName, group }: NavOpenWaitArgs = {}) {
|
|
const opened = await this.navOpen({ url, background, focus, window: windowName, group });
|
|
return await this.navWait({ tabId: opened.id, timeout });
|
|
}
|
|
}
|