Files
browser-cli/extension/background.js
T

696 lines
26 KiB
JavaScript

/**
* browser-cli Extension — Background Service Worker
*
* Connects to the native host (com.browsercli.host) via Native Messaging.
* The native host relays commands from the CLI Unix socket to this extension,
* and relays responses back.
*/
const NATIVE_HOST = "com.browsercli.host";
let port = null;
// ── Connection management ─────────────────────────────────────────────────────
async function getProfileAlias() {
const { profileAlias } = await chrome.storage.local.get("profileAlias");
return profileAlias || "default";
}
async function connect() {
if (port) return;
try {
port = chrome.runtime.connectNative(NATIVE_HOST);
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(() => {
port = null;
const err = chrome.runtime.lastError;
if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
});
// Send hello so native host knows which profile/alias this is
const alias = await getProfileAlias();
port.postMessage({ type: "hello", alias });
console.log("[browser-cli] Connected to native host as profile:", alias);
} catch (e) {
port = null;
console.error("[browser-cli] Failed to connect:", e);
}
}
chrome.runtime.onInstalled.addListener(connect);
chrome.runtime.onStartup.addListener(connect);
// Keepalive alarm — prevents service worker suspension and reconnects if needed
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") {
if (!port) connect();
}
});
// ── Message dispatcher ────────────────────────────────────────────────────────
async function onMessage(msg) {
const { id, command, args } = msg;
if (!id || !command) return;
console.log("[browser-cli] ←", command, args);
let data, error;
try {
data = await dispatch(command, args || {});
} catch (e) {
error = e.message || String(e);
}
if (error !== undefined) {
console.log("[browser-cli] → ERROR", command, error);
port.postMessage({ id, success: false, error });
} else {
console.log("[browser-cli] →", command, data);
port.postMessage({ id, success: true, data });
}
}
async function dispatch(command, args) {
switch (command) {
// ── Navigation ────────────────────────────────────────────────────────
case "navigate.open": return navOpen(args);
case "navigate.reload": return navReload(args, false);
case "navigate.hard_reload": return navReload(args, true);
case "navigate.back": return navBack(args);
case "navigate.forward": return navForward(args);
case "navigate.focus": return navFocus(args);
// ── Tabs ──────────────────────────────────────────────────────────────
case "tabs.list": return tabsList();
case "tabs.close": return tabsClose(args);
case "tabs.move": return tabsMove(args);
case "tabs.active": return tabsActive(args);
case "tabs.filter": return tabsFilter(args);
case "tabs.count": return tabsCount(args);
case "tabs.query": return tabsQuery(args);
case "tabs.html": return tabsHtml(args);
case "tabs.dedupe": return tabsDedupe();
case "tabs.sort": return tabsSort(args);
case "tabs.merge_windows": return tabsMergeWindows();
// ── Groups ────────────────────────────────────────────────────────────
case "group.list": return groupList();
case "group.tabs": return groupTabs(args);
case "group.count": return groupCount();
case "group.query": return groupQuery(args);
case "group.close": return groupClose(args);
case "group.open": return groupOpen(args);
case "group.add_tab": return groupAddTab(args);
case "group.move": return groupMove(args);
// ── Windows ───────────────────────────────────────────────────────────
case "windows.list": return windowsList();
case "windows.rename": return windowsRename(args);
case "windows.close": return windowsClose(args);
case "windows.open": return windowsOpen(args);
// ── DOM ───────────────────────────────────────────────────────────────
case "dom.query": return domOp("domQuery", args);
case "dom.click": return domOp("domClick", args);
case "dom.type": return domOp("domType", args);
case "dom.attr": return domOp("domAttr", args);
case "dom.text": return domOp("domText", args);
case "dom.exists": return domOp("domExists", args);
// ── Extract ───────────────────────────────────────────────────────────
case "extract.links": return domOp("extractLinks", args);
case "extract.images": return domOp("extractImages", args);
case "extract.text": return domOp("extractText", args);
case "extract.json": return domOp("extractJson", args);
case "extract.html": return tabsHtml({});
// ── Session ───────────────────────────────────────────────────────────
case "session.save": return sessionSave(args);
case "session.load": return sessionLoad(args);
case "session.list": return sessionList();
case "session.remove": return sessionRemove(args);
case "session.diff": return sessionDiff(args);
case "session.auto_save": return sessionAutoSave(args);
// ── Misc ──────────────────────────────────────────────────────────────
case "clients.list": return clientsList();
case "clients.rename_profile": return clientsRenameProfile(args);
default:
throw new Error(`Unknown command: ${command}`);
}
}
// ── Navigation ────────────────────────────────────────────────────────────────
async function navOpen({ url, background, window: windowName, group: groupNameOrId }) {
let windowId;
if (windowName) {
const aliases = await getAliases();
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
if (entry) windowId = parseInt(entry[0]);
}
const tab = await chrome.tabs.create({ url, active: !background, windowId });
if (groupNameOrId != null) {
const 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 placeholders = groupTabs.filter(t =>
t.id !== tab.id &&
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
);
await chrome.tabs.group({ tabIds: [tab.id], groupId });
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
}
return { id: tab.id, url: tab.url };
}
async function navReload({ tabId }, bypassCache) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache });
return { tabId: tab.id };
}
async function navBack({ tabId }) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goBack(tab.id);
return { tabId: tab.id };
}
async function navForward({ tabId }) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goForward(tab.id);
return { tabId: tab.id };
}
async function navFocus({ pattern }) {
// If pattern is a plain integer, treat it as a tab ID
const asInt = parseInt(pattern);
let match;
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
match = await chrome.tabs.get(asInt);
} else {
const all = await chrome.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 });
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
async function tabsList() {
const windows = await chrome.windows.getAll({ populate: true });
const aliases = await getAliases();
const tabs = [];
for (const w of windows) {
for (const t of w.tabs) {
tabs.push({
id: t.id,
windowId: t.windowId,
windowAlias: aliases[t.windowId] || null,
active: t.active,
pinned: t.pinned,
title: t.title,
url: t.url,
favIconUrl: t.favIconUrl,
groupId: t.groupId >= 0 ? t.groupId : null,
});
}
}
return tabs;
}
async function tabsClose({ tabId, inactive, duplicates }) {
let toClose = [];
if (duplicates) {
const all = await chrome.tabs.query({});
const seen = new Set();
for (const t of all) {
if (seen.has(t.url)) toClose.push(t.id);
else seen.add(t.url);
}
} else if (inactive) {
const all = await chrome.tabs.query({});
toClose = all.filter(t => !t.active).map(t => t.id);
} else if (tabId) {
toClose = [tabId];
}
if (toClose.length) await chrome.tabs.remove(toClose);
return { closed: toClose.length };
}
async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) {
const moveProps = {};
if (windowId != null) moveProps.windowId = windowId;
if (forward || backward) {
const tab = await chrome.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) {
moveProps.index = index;
} else {
moveProps.index = -1;
}
await chrome.tabs.move(tabId, moveProps);
if (groupId != null) {
await chrome.tabs.group({ tabIds: [tabId], groupId });
}
return { tabId };
}
async function tabsActive({ tabId }) {
const tab = await chrome.tabs.get(tabId);
await chrome.windows.update(tab.windowId, { focused: true });
await chrome.tabs.update(tabId, { active: true });
return { tabId };
}
async function tabsFilter({ pattern }) {
const all = await chrome.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
}
async function tabsCount({ pattern }) {
const all = await chrome.tabs.query({});
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
return all.length;
}
async function tabsQuery({ search }) {
const q = search.toLowerCase();
const all = await chrome.tabs.query({});
return all.filter(t =>
(t.url && t.url.toLowerCase().includes(q)) ||
(t.title && t.title.toLowerCase().includes(q))
).map(tabInfo);
}
async function executeScript(options, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await chrome.scripting.executeScript(options);
} catch (e) {
if (i < retries - 1 && e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page")) {
await new Promise(r => setTimeout(r, 300));
continue;
}
throw e;
}
}
}
async function tabsHtml({ tabId }) {
for (let i = 0; i < 3; i++) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url || tab.pendingUrl || "")) {
throw new Error(`Cannot get HTML of ${tab.url || tab.pendingUrl} — navigate to a regular web page first`);
}
try {
const results = await executeScript({
target: { tabId: tab.id },
func: () => document.documentElement.outerHTML,
});
return results[0]?.result || "";
} catch (e) {
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page");
if (i < 2 && transient) {
await new Promise(r => setTimeout(r, 300));
continue;
}
throw e;
}
}
}
async function tabsDedupe() {
return tabsClose({ duplicates: true });
}
async function tabsSort({ by }) {
const windows = await chrome.windows.getAll({ populate: true });
let moved = 0;
for (const w of windows) {
const sorted = [...w.tabs].sort((a, b) => {
if (by === "title") return (a.title || "").localeCompare(b.title || "");
if (by === "time") return a.id - b.id; // lower id = opened earlier
// domain (default)
const da = new URL(a.url || "about:blank").hostname;
const db = new URL(b.url || "about:blank").hostname;
return da.localeCompare(db);
});
for (let i = 0; i < sorted.length; i++) {
await chrome.tabs.move(sorted[i].id, { index: i });
moved++;
}
}
return { moved };
}
async function tabsMergeWindows() {
const [focused] = await chrome.windows.getAll({ populate: false });
const current = await chrome.windows.getCurrent();
const all = await chrome.windows.getAll({ populate: true });
let moved = 0;
for (const w of all) {
if (w.id === current.id) continue;
const ids = w.tabs.map(t => t.id);
await chrome.tabs.move(ids, { windowId: current.id, index: -1 });
moved += ids.length;
}
return { moved };
}
function tabInfo(t) {
return { id: t.id, windowId: t.windowId, active: t.active, title: t.title, url: t.url };
}
// ── Groups ────────────────────────────────────────────────────────────────────
async function groupList() {
const groups = await chrome.tabGroups.query({});
const all = await chrome.tabs.query({});
return groups.map(g => ({
id: g.id,
title: g.title,
color: g.color,
collapsed: g.collapsed,
windowId: g.windowId,
tabCount: all.filter(t => t.groupId === g.id).length,
}));
}
async function groupTabs({ groupId }) {
const all = await chrome.tabs.query({});
return all.filter(t => t.groupId === groupId).map(tabInfo);
}
async function groupCount() {
const groups = await chrome.tabGroups.query({});
return groups.length;
}
async function groupQuery({ search }) {
const q = search.toLowerCase();
const groups = await chrome.tabGroups.query({});
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
}
async function groupClose({ groupId }) {
const tabs = await chrome.tabs.query({});
const groupTabs = tabs.filter(t => t.groupId === groupId);
await chrome.tabs.ungroup(groupTabs.map(t => t.id));
return { groupId };
}
async function groupOpen({ name }) {
const tab = await chrome.tabs.create({ active: true });
const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
await chrome.tabGroups.update(groupId, { title: name });
return { id: groupId, name };
}
async function groupAddTab({ group, url }) {
const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
await chrome.tabs.group({ tabIds: [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));
}
return { tabId: tab.id, groupId };
}
async function groupMove({ group, forward, backward }) {
const groupId = await resolveGroupId(group);
const groupInfo = await chrome.tabGroups.get(groupId);
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index);
const groupTabs = allTabs.filter(t => t.groupId === groupId);
if (!groupTabs.length) throw new Error(`No tabs found in group '${group}'`);
const firstIdx = groupTabs[0].index;
const lastIdx = groupTabs[groupTabs.length - 1].index;
if (forward) {
const tabAfter = allTabs.find(t => t.index > lastIdx && t.groupId !== groupId);
if (!tabAfter) return { groupId, moved: false };
await chrome.tabs.move(groupTabs.map(t => t.id), { index: tabAfter.index + 1 });
} else if (backward) {
const tabsBefore = allTabs.filter(t => t.index < firstIdx && t.groupId !== groupId);
const tabBefore = tabsBefore[tabsBefore.length - 1];
if (!tabBefore) return { groupId, moved: false };
await chrome.tabs.move(groupTabs.map(t => t.id), { index: tabBefore.index });
}
return { groupId, moved: true };
}
// ── Windows ───────────────────────────────────────────────────────────────────
async function windowsList() {
const windows = await chrome.windows.getAll({ populate: true });
const aliases = await getAliases();
return windows.map(w => ({
id: w.id,
alias: aliases[w.id] || null,
focused: w.focused,
state: w.state,
tabCount: (w.tabs || []).length,
}));
}
async function windowsRename({ windowId, name }) {
const aliases = await getAliases();
aliases[windowId] = name;
await chrome.storage.local.set({ windowAliases: aliases });
return { windowId, name };
}
async function windowsClose({ windowId }) {
await chrome.windows.remove(windowId);
return { windowId };
}
async function windowsOpen({ profile }) {
// profile support requires launching Chrome with --profile-directory which
// isn't possible from within an extension — we open a plain new window.
const w = await chrome.windows.create({ focused: true });
return { id: w.id };
}
// ── DOM / Extract ─────────────────────────────────────────────────────────────
function isScriptableUrl(url) {
if (!url) return false;
return !url.startsWith("chrome://") &&
!url.startsWith("brave://") &&
!url.startsWith("about:") &&
!url.startsWith("edge://") &&
!url.startsWith("chrome-extension://");
}
async function domOp(funcName, args) {
const tab = await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
func: contentDispatch,
args: [funcName, args],
});
return results[0]?.result;
}
// This function is serialized and injected into the page by chrome.scripting
function contentDispatch(funcName, args) {
function domQuery({ selector }) {
return Array.from(document.querySelectorAll(selector)).map(el => ({
tag: el.tagName.toLowerCase(),
text: el.textContent.trim().slice(0, 200),
attrs: Object.fromEntries(Array.from(el.attributes).map(a => [a.name, a.value])),
}));
}
function domClick({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.click();
return true;
}
function domType({ selector, text }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.focus();
el.value = text;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function domAttr({ selector, attr }) {
return Array.from(document.querySelectorAll(selector))
.map(el => el.getAttribute(attr))
.filter(v => v !== null);
}
function domText({ selector }) {
return Array.from(document.querySelectorAll(selector))
.map(el => el.textContent.trim())
.filter(Boolean);
}
function domExists({ selector }) {
return document.querySelector(selector) !== null;
}
function extractLinks() {
return Array.from(document.querySelectorAll("a[href]")).map(a => ({
text: a.textContent.trim().slice(0, 100),
href: a.href,
}));
}
function extractImages() {
return Array.from(document.querySelectorAll("img")).map(img => {
const src =
img.src ||
img.getAttribute("data-src") ||
img.getAttribute("data-lazy-src") ||
img.getAttribute("data-original") ||
(img.srcset ? img.srcset.split(",")[0].trim().split(" ")[0] : "") ||
"";
const FAKE_ALT = new Set(["true", "false", "null", "undefined", "image", "img"]);
const alt = img.alt && !FAKE_ALT.has(img.alt.trim().toLowerCase()) ? img.alt.trim() : "";
return { alt, src };
}).filter(img => img.src !== "");
}
function extractText() {
return document.body.innerText;
}
function extractJson({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
return JSON.parse(el.textContent);
}
const fns = { domQuery, domClick, domType, domAttr, domText, domExists,
extractLinks, extractImages, extractText, extractJson };
const fn = fns[funcName];
if (!fn) throw new Error(`Unknown content function: ${funcName}`);
return fn(args);
}
// ── Session ───────────────────────────────────────────────────────────────────
async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({});
const urls = tabs.map(t => t.url).filter(Boolean);
const sessions = await getSessions();
sessions[name] = { urls, savedAt: Date.now() };
await chrome.storage.local.set({ sessions });
return { name, tabs: urls.length };
}
async function sessionLoad({ name }) {
const sessions = await getSessions();
const session = sessions[name];
if (!session) throw new Error(`Session '${name}' not found`);
for (const url of session.urls) {
await chrome.tabs.create({ url, active: false });
}
return { name, tabs: session.urls.length };
}
async function sessionList() {
const sessions = await getSessions();
return Object.entries(sessions).map(([name, s]) => ({
name,
tabs: (s.urls || []).length,
savedAt: s.savedAt || null,
}));
}
async function sessionRemove({ name }) {
const sessions = await getSessions();
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
delete sessions[name];
await chrome.storage.local.set({ sessions });
return { name };
}
async function sessionDiff({ nameA, nameB }) {
const sessions = await getSessions();
const a = new Set((sessions[nameA]?.urls) || []);
const b = new Set((sessions[nameB]?.urls) || []);
return {
added: [...b].filter(u => !a.has(u)),
removed: [...a].filter(u => !b.has(u)),
};
}
async function sessionAutoSave({ enabled }) {
await chrome.storage.local.set({ autoSave: enabled });
if (enabled) {
chrome.tabs.onUpdated.addListener(autoSaveHandler);
chrome.tabs.onRemoved.addListener(autoSaveHandler);
}
return { enabled };
}
async function autoSaveHandler() {
const { autoSave } = await chrome.storage.local.get("autoSave");
if (!autoSave) return;
await sessionSave({ name: "__auto__" });
}
// ── Misc ──────────────────────────────────────────────────────────────────────
async function clientsList() {
const manifest = chrome.runtime.getManifest();
const alias = await getProfileAlias();
return [{
name: "Chrome",
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown",
platform: navigator.platform,
extensionVersion: manifest.version,
profile: alias,
}];
}
async function clientsRenameProfile({ alias }) {
await chrome.storage.local.set({ profileAlias: alias });
return { alias };
}
// ── Helpers ───────────────────────────────────────────────────────────────────
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
if (!tab) throw new Error("No active tab found");
return tab;
}
async function resolveGroupId(nameOrId) {
const asInt = parseInt(nameOrId);
if (!isNaN(asInt)) return asInt;
const groups = await chrome.tabGroups.query({});
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
return match.id;
}
async function getAliases() {
const { windowAliases } = await chrome.storage.local.get("windowAliases");
return windowAliases || {};
}
async function getSessions() {
const { sessions } = await chrome.storage.local.get("sessions");
return sessions || {};
}