809 lines
29 KiB
JavaScript
809 lines
29 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) {
|
|
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 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));
|
|
} catch (e) {
|
|
if (!e.message.startsWith("No tab group found")) throw e;
|
|
// Group doesn't exist — create it with the tab already in it
|
|
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
|
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
|
|
}
|
|
}
|
|
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 blocks = buildTabBlocks(allTabs);
|
|
const currentIdx = blocks.findIndex(block => block.groupId === groupId);
|
|
if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`);
|
|
|
|
const currentBlock = blocks[currentIdx];
|
|
const currentLength = currentBlock.tabIds.length;
|
|
|
|
if (forward) {
|
|
const nextBlock = blocks[currentIdx + 1];
|
|
if (!nextBlock) return { groupId, moved: false };
|
|
const targetIndex =
|
|
nextBlock.groupId === null
|
|
? currentBlock.startIndex + 1
|
|
: nextBlock.endIndex - currentLength + 1;
|
|
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
|
} else if (backward) {
|
|
const previousBlock = blocks[currentIdx - 1];
|
|
if (!previousBlock) return { groupId, moved: false };
|
|
const targetIndex =
|
|
previousBlock.groupId === null
|
|
? currentBlock.startIndex - 1
|
|
: previousBlock.startIndex;
|
|
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
|
}
|
|
|
|
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() {
|
|
const seen = new Set();
|
|
return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => {
|
|
const href = a.href;
|
|
if (!href || seen.has(href)) return links;
|
|
seen.add(href);
|
|
links.push({
|
|
text: a.textContent.trim().slice(0, 100),
|
|
href,
|
|
});
|
|
return links;
|
|
}, []);
|
|
}
|
|
function extractImages() {
|
|
const seen = new Set();
|
|
return Array.from(document.querySelectorAll("img")).reduce((images, 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] : "") ||
|
|
"";
|
|
if (!src || seen.has(src)) return images;
|
|
seen.add(src);
|
|
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() : "";
|
|
images.push({ alt, src });
|
|
return images;
|
|
}, []);
|
|
}
|
|
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 groups = await chrome.tabGroups.query({});
|
|
const groupById = new Map(groups.map(group => [group.id, group]));
|
|
const sessionTabs = tabs
|
|
.filter(tab => Boolean(tab.url))
|
|
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
|
|
.map(tab => {
|
|
const entry = { url: tab.url };
|
|
if (tab.groupId >= 0) {
|
|
const group = groupById.get(tab.groupId);
|
|
entry.group = {
|
|
key: `${tab.windowId}:${tab.groupId}`,
|
|
title: group?.title || "",
|
|
color: normalizeGroupColor(group?.color),
|
|
collapsed: Boolean(group?.collapsed),
|
|
};
|
|
}
|
|
return entry;
|
|
});
|
|
const sessions = await getSessions();
|
|
sessions[name] = {
|
|
tabs: sessionTabs,
|
|
urls: sessionTabs.map(tab => tab.url),
|
|
savedAt: Date.now(),
|
|
};
|
|
await chrome.storage.local.set({ sessions });
|
|
return { name, tabs: sessionTabs.length };
|
|
}
|
|
|
|
async function sessionLoad({ name }) {
|
|
const sessions = await getSessions();
|
|
const session = sessions[name];
|
|
if (!session) throw new Error(`Session '${name}' not found`);
|
|
|
|
const sessionTabs = getSessionTabs(session);
|
|
const createdTabs = [];
|
|
|
|
for (const entry of sessionTabs) {
|
|
const tab = await chrome.tabs.create({ url: entry.url, active: false });
|
|
createdTabs.push({ tabId: tab.id, entry });
|
|
}
|
|
|
|
const groups = new Map();
|
|
for (const { tabId, entry } of createdTabs) {
|
|
if (!entry.group) continue;
|
|
const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`;
|
|
if (!groups.has(key)) {
|
|
groups.set(key, { meta: entry.group, tabIds: [] });
|
|
}
|
|
groups.get(key).tabIds.push(tabId);
|
|
}
|
|
|
|
for (const { meta, tabIds } of groups.values()) {
|
|
const restoredGroupId = await chrome.tabs.group({ tabIds });
|
|
await chrome.tabGroups.update(restoredGroupId, {
|
|
title: meta.title || "",
|
|
color: normalizeGroupColor(meta.color),
|
|
collapsed: Boolean(meta.collapsed),
|
|
});
|
|
}
|
|
|
|
return { name, tabs: sessionTabs.length };
|
|
}
|
|
|
|
async function sessionList() {
|
|
const sessions = await getSessions();
|
|
return Object.entries(sessions).map(([name, s]) => ({
|
|
name,
|
|
tabs: getSessionTabs(s).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(getSessionTabs(sessions[nameA]).map(tab => tab.url));
|
|
const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url));
|
|
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;
|
|
}
|
|
|
|
function buildTabBlocks(tabs) {
|
|
const blocks = [];
|
|
for (const tab of tabs) {
|
|
const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null;
|
|
const lastBlock = blocks[blocks.length - 1];
|
|
if (lastBlock?.groupId === normalizedGroupId) {
|
|
lastBlock.tabIds.push(tab.id);
|
|
lastBlock.endIndex = tab.index;
|
|
continue;
|
|
}
|
|
|
|
blocks.push({
|
|
groupId: normalizedGroupId,
|
|
startIndex: tab.index,
|
|
endIndex: tab.index,
|
|
tabIds: [tab.id],
|
|
});
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function getSessionTabs(session) {
|
|
if (!session) return [];
|
|
if (Array.isArray(session.tabs)) {
|
|
return session.tabs
|
|
.map(entry => typeof entry === "string" ? { url: entry } : entry)
|
|
.filter(entry => entry?.url);
|
|
}
|
|
if (Array.isArray(session.urls)) {
|
|
return session.urls.filter(Boolean).map(url => ({ url }));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function normalizeGroupColor(color) {
|
|
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
|
|
return allowed.has(color) ? color : "grey";
|
|
}
|
|
|
|
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 || {};
|
|
}
|