/** * 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 || {}; }