1317 lines
46 KiB
JavaScript
1317 lines
46 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;
|
|
let keepaliveEnabled = true;
|
|
|
|
// ── Connection management ─────────────────────────────────────────────────────
|
|
|
|
function sendControlMessage(targetPort, message) {
|
|
if (!targetPort) return;
|
|
try {
|
|
targetPort.postMessage(message);
|
|
} catch (e) {
|
|
console.warn("[browser-cli] Failed to send control message:", e);
|
|
}
|
|
}
|
|
|
|
function disconnectPort({ sendBye = false } = {}) {
|
|
const currentPort = port;
|
|
if (!currentPort) return;
|
|
|
|
if (sendBye) sendControlMessage(currentPort, { type: "bye" });
|
|
|
|
if (port === currentPort) port = null;
|
|
|
|
try {
|
|
currentPort.disconnect();
|
|
} catch (e) {
|
|
console.warn("[browser-cli] Failed to disconnect native port:", e);
|
|
}
|
|
}
|
|
|
|
async function getProfileAlias() {
|
|
const { profileAlias } = await chrome.storage.local.get("profileAlias");
|
|
return profileAlias || "default";
|
|
}
|
|
|
|
async function connect() {
|
|
if (port || !keepaliveEnabled) return;
|
|
try {
|
|
const nativePort = chrome.runtime.connectNative(NATIVE_HOST);
|
|
port = nativePort;
|
|
nativePort.onMessage.addListener(onMessage);
|
|
nativePort.onDisconnect.addListener(() => {
|
|
if (port === nativePort) 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();
|
|
nativePort.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);
|
|
chrome.runtime.onSuspend.addListener(() => {
|
|
disconnectPort({ sendBye: true });
|
|
});
|
|
chrome.windows.onCreated.addListener(() => {
|
|
keepaliveEnabled = true;
|
|
if (!port) connect();
|
|
});
|
|
chrome.windows.onRemoved.addListener(async () => {
|
|
const windows = await chrome.windows.getAll({});
|
|
if (windows.length > 0) return;
|
|
|
|
keepaliveEnabled = false;
|
|
disconnectPort({ sendBye: true });
|
|
});
|
|
|
|
// 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 && keepaliveEnabled) 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 });
|
|
}
|
|
|
|
if (command === "clients.rename_profile" && error === undefined) {
|
|
disconnectPort({ sendBye: true });
|
|
keepaliveEnabled = true;
|
|
await connect();
|
|
}
|
|
}
|
|
|
|
async function dispatch(command, args) {
|
|
switch (command) {
|
|
// ── Navigation ────────────────────────────────────────────────────────
|
|
case "navigate.open": return navOpen(args);
|
|
case "navigate.to": return navTo(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.active_in_window": return tabsActiveInWindow(args);
|
|
case "tabs.status": return tabsStatus(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();
|
|
case "tabs.mute": return tabsMute(args);
|
|
case "tabs.unmute": return tabsUnmute(args);
|
|
|
|
// ── 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.markdown": return domOp("extractMarkdown", 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, windowId: explicitWindowId, group: groupNameOrId }) {
|
|
let windowId;
|
|
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 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 navTo({ tabId, url }) {
|
|
const tab = await chrome.tabs.update(tabId, { url });
|
|
return { id: tab.id, url: tab.url || 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({
|
|
...tabInfo(t),
|
|
windowAlias: aliases[t.windowId] || null,
|
|
pinned: t.pinned,
|
|
favIconUrl: t.favIconUrl,
|
|
});
|
|
}
|
|
}
|
|
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 tabsActiveInWindow({ windowId }) {
|
|
const activeTabs = await chrome.tabs.query({ windowId, active: true });
|
|
const tab = activeTabs[0];
|
|
if (!tab) {
|
|
throw new Error(`No active tab found for window ${windowId}`);
|
|
}
|
|
return tabInfo(tab);
|
|
}
|
|
|
|
async function tabsStatus({ tabId }) {
|
|
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
|
return tabInfo(tab);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
async function tabsMute({ tabId }) {
|
|
const tab = await resolveTabForDirectAction(tabId, "mute");
|
|
await chrome.tabs.update(tab.id, { muted: true });
|
|
return { tabId: tab.id, muted: true };
|
|
}
|
|
|
|
async function tabsUnmute({ tabId }) {
|
|
const tab = await resolveTabForDirectAction(tabId, "unmute");
|
|
await chrome.tabs.update(tab.id, { muted: false });
|
|
return { tabId: tab.id, muted: false };
|
|
}
|
|
|
|
function tabInfo(t) {
|
|
return {
|
|
id: t.id,
|
|
windowId: t.windowId,
|
|
active: t.active,
|
|
muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
|
|
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({ url }) {
|
|
const createData = { focused: true };
|
|
if (url) createData.url = url;
|
|
const w = await chrome.windows.create(createData);
|
|
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);
|
|
}
|
|
function extractMarkdown({ selector }) {
|
|
const BLOCKS = new Set([
|
|
"article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption",
|
|
"figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr",
|
|
"li", "main", "nav", "ol", "p", "pre", "section", "table", "tbody", "td", "tfoot",
|
|
"th", "thead", "tr", "ul"
|
|
]);
|
|
const NOISE_SELECTOR = [
|
|
"script",
|
|
"style",
|
|
"noscript",
|
|
"template",
|
|
"svg",
|
|
"canvas",
|
|
"iframe",
|
|
"dialog",
|
|
"button",
|
|
"input",
|
|
"textarea",
|
|
"select",
|
|
"option",
|
|
"form",
|
|
"[hidden]",
|
|
"[aria-hidden='true']",
|
|
".sr-only",
|
|
"[class*='sr-only']",
|
|
"[class*='file-tile']",
|
|
"form[data-type='unified-composer']",
|
|
".composer-btn",
|
|
"[data-composer-surface='true']",
|
|
"#thread-bottom-container",
|
|
"[data-testid*='action-button']",
|
|
].join(", ");
|
|
|
|
function normalizeText(value) {
|
|
return value.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function normalizeInline(value) {
|
|
return value
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/\n[ \t]+/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.replace(/[ \t]{2,}/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function collapseBlankLines(value) {
|
|
return value
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function escapeMarkdown(text) {
|
|
return text.replace(/([\\`[\]])/g, "\\$1");
|
|
}
|
|
|
|
function escapeTableCell(text) {
|
|
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
|
}
|
|
|
|
function absoluteUrl(attr, fallback) {
|
|
return attr || fallback || "";
|
|
}
|
|
|
|
function isNoiseElement(node) {
|
|
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
|
|
const tag = node.tagName.toLowerCase();
|
|
if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true;
|
|
if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true;
|
|
if (node.hasAttribute("hidden")) return true;
|
|
if ((node.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
|
|
if (node.matches(".sr-only, [class*='sr-only']")) return true;
|
|
if (node.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
|
|
if (node.matches("[data-testid*='action-button']")) return true;
|
|
return false;
|
|
}
|
|
|
|
function stripNoise(root) {
|
|
const clone = root.cloneNode(true);
|
|
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
|
|
return clone;
|
|
}
|
|
|
|
function candidateScore(node) {
|
|
const text = normalizeText(node.innerText || "");
|
|
if (!text) return -Infinity;
|
|
|
|
const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length;
|
|
const paragraphs = node.querySelectorAll("p").length;
|
|
const listItems = node.querySelectorAll("li").length;
|
|
const tables = node.querySelectorAll("table").length;
|
|
const codeBlocks = node.querySelectorAll("pre, code").length;
|
|
const images = node.querySelectorAll("img, figure").length;
|
|
const mainLike = node.matches("main, article, [role='main']") ? 1 : 0;
|
|
const proseBlocks = node.matches(".markdown, .prose, [data-message-author-role='assistant']") ? 1 : 0;
|
|
const buttons = node.querySelectorAll("button, input, textarea, select").length;
|
|
const forms = node.querySelectorAll("form").length;
|
|
const svgs = node.querySelectorAll("svg, canvas").length;
|
|
|
|
return text.length
|
|
+ (mainLike * 4000)
|
|
+ (proseBlocks * 5000)
|
|
+ (headings * 250)
|
|
+ (paragraphs * 60)
|
|
+ (listItems * 35)
|
|
+ (tables * 80)
|
|
+ (codeBlocks * 60)
|
|
+ (images * 25)
|
|
- (buttons * 120)
|
|
- (forms * 200)
|
|
- (svgs * 40);
|
|
}
|
|
|
|
function pickRoot() {
|
|
if (selector) {
|
|
const matched = document.querySelector(selector);
|
|
if (!matched) throw new Error(`No element: ${selector}`);
|
|
return matched;
|
|
}
|
|
|
|
const candidates = Array.from(document.querySelectorAll(
|
|
"main, article, [role='main'], section, .markdown, .prose, [data-message-author-role]"
|
|
))
|
|
.filter(node => normalizeText(node.innerText || "").length > 0);
|
|
if (!candidates.length) return document.body;
|
|
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
|
|
return candidates[0];
|
|
}
|
|
|
|
function inlineText(node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return escapeMarkdown(node.textContent || "");
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
|
if (isNoiseElement(node)) return "";
|
|
|
|
const tag = node.tagName.toLowerCase();
|
|
if (tag === "br") return "\n";
|
|
if (tag === "img") {
|
|
const src = absoluteUrl(node.getAttribute("src"), node.src);
|
|
if (!src) return "";
|
|
const alt = normalizeText(node.getAttribute("alt") || "");
|
|
return alt ? `` : ``;
|
|
}
|
|
if (tag === "a") {
|
|
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
|
const href = absoluteUrl(node.getAttribute("href"), node.href);
|
|
if (!href) return text;
|
|
return `[${text || href}](${href})`;
|
|
}
|
|
if (tag === "code") {
|
|
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
|
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
|
|
}
|
|
if (tag === "strong" || tag === "b") {
|
|
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
|
return text ? `**${text}**` : "";
|
|
}
|
|
if (tag === "em" || tag === "i") {
|
|
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
|
return text ? `*${text}*` : "";
|
|
}
|
|
|
|
const chunks = [];
|
|
for (const child of node.childNodes) {
|
|
const rendered = inlineText(child);
|
|
if (!rendered) continue;
|
|
chunks.push(rendered);
|
|
if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has(child.tagName.toLowerCase())) {
|
|
chunks.push("\n");
|
|
}
|
|
}
|
|
return chunks.join("");
|
|
}
|
|
|
|
function textBlock(node) {
|
|
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
|
|
}
|
|
|
|
function preserveNodeText(node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return node.textContent || "";
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
|
|
|
const tag = node.tagName.toLowerCase();
|
|
if (tag === "br") return "\n";
|
|
|
|
const parts = [];
|
|
for (const child of node.childNodes) {
|
|
const rendered = preserveNodeText(child);
|
|
if (!rendered) continue;
|
|
parts.push(rendered);
|
|
}
|
|
|
|
if (["div", "p", "li"].includes(tag)) {
|
|
return `${parts.join("")}\n`;
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
function repairFlattenedDiagram(text) {
|
|
if (text.includes("\n")) return text;
|
|
const markerCount = (text.match(/[│▼├└]/g) || []).length;
|
|
if (markerCount < 2) return text;
|
|
|
|
let repaired = text;
|
|
repaired = repaired.replace(/\s{2,}([│▼])/g, "\n $1");
|
|
repaired = repaired.replace(/([│▼])\s{2,}/g, "$1\n");
|
|
repaired = repaired.replace(/([│▼])(?=[^\s\n│▼├└])/g, "$1\n");
|
|
repaired = repaired.replace(/(?<=[^\s\n])([├└])/g, "\n$1");
|
|
repaired = repaired.replace(/([^\s\n])(\()/g, "$1\n$2");
|
|
return repaired
|
|
.split("\n")
|
|
.map(line => line.replace(/\s+$/, ""))
|
|
.filter(line => line.trim())
|
|
.join("\n");
|
|
}
|
|
|
|
function convertDashListsToBranches(lines) {
|
|
const converted = [];
|
|
let index = 0;
|
|
while (index < lines.length) {
|
|
const match = lines[index].match(/^(\s*)-\s+(.*)$/);
|
|
if (!match) {
|
|
converted.push(lines[index]);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const indent = match[1];
|
|
const items = [];
|
|
while (index < lines.length) {
|
|
const nextMatch = lines[index].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\s+(.*)$`));
|
|
if (!nextMatch) break;
|
|
items.push(nextMatch[1]);
|
|
index += 1;
|
|
}
|
|
|
|
items.forEach((item, itemIndex) => {
|
|
const branch = itemIndex === items.length - 1 ? "└" : "├";
|
|
converted.push(`${indent}${branch} ${item}`);
|
|
});
|
|
}
|
|
return converted;
|
|
}
|
|
|
|
function normalizeCodeBlock(text) {
|
|
let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, ""));
|
|
while (lines.length && !lines[0].trim()) lines.shift();
|
|
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
|
|
const flattened = repairFlattenedDiagram(lines.join("\n"));
|
|
lines = flattened ? flattened.split("\n") : [];
|
|
lines = lines.map(line => {
|
|
const trimmed = line.trim();
|
|
if ((trimmed === "│" || trimmed === "▼") && !/^\s+[│▼]\s*$/.test(line)) {
|
|
return ` ${trimmed}`;
|
|
}
|
|
return line;
|
|
});
|
|
lines = convertDashListsToBranches(lines);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function tableToMarkdown(table) {
|
|
const rows = Array.from(table.querySelectorAll("tr"))
|
|
.map(row => Array.from(row.children)
|
|
.filter(cell => cell.tagName === "TD" || cell.tagName === "TH")
|
|
.map(cell => escapeTableCell(textBlock(cell)))
|
|
)
|
|
.filter(cells => cells.length > 0);
|
|
if (!rows.length) return "";
|
|
|
|
const widths = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
|
const normalizedRows = rows.map(row => {
|
|
const next = row.slice();
|
|
while (next.length < widths) next.push("");
|
|
return next;
|
|
});
|
|
|
|
let headers = normalizedRows[0];
|
|
let bodyRows = normalizedRows.slice(1);
|
|
const firstRowIsBlank = headers.every(cell => !cell.trim());
|
|
if (firstRowIsBlank && normalizedRows.length > 1) {
|
|
headers = normalizedRows[1];
|
|
bodyRows = normalizedRows.slice(2);
|
|
}
|
|
|
|
const firstRow = table.querySelector("tr");
|
|
const thead = table.querySelector("thead");
|
|
const firstRowHasTh = firstRow && Array.from(firstRow.children).some(cell => cell.tagName === "TH");
|
|
if (!(thead || firstRowHasTh || firstRowIsBlank)) {
|
|
headers = new Array(widths).fill("");
|
|
bodyRows = normalizedRows;
|
|
}
|
|
|
|
const separator = new Array(widths).fill("---");
|
|
const lines = [
|
|
`| ${headers.join(" | ")} |`,
|
|
`| ${separator.join(" | ")} |`,
|
|
];
|
|
for (const row of bodyRows) {
|
|
lines.push(`| ${row.join(" | ")} |`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function listToMarkdown(list, depth = 0) {
|
|
const ordered = list.tagName.toLowerCase() === "ol";
|
|
const items = [];
|
|
const children = Array.from(list.children).filter(child => child.tagName === "LI");
|
|
children.forEach((item, index) => {
|
|
const marker = ordered ? `${index + 1}. ` : "- ";
|
|
const indent = " ".repeat(depth);
|
|
const nested = [];
|
|
const content = [];
|
|
|
|
for (const child of item.childNodes) {
|
|
if (child.nodeType === Node.ELEMENT_NODE && (child.tagName === "UL" || child.tagName === "OL")) {
|
|
nested.push(listToMarkdown(child, depth + 1));
|
|
} else {
|
|
content.push(inlineText(child));
|
|
}
|
|
}
|
|
|
|
const line = collapseBlankLines(normalizeInline(content.join("")));
|
|
if (line) {
|
|
const lineParts = line.split("\n");
|
|
items.push(`${indent}${marker}${lineParts[0]}`);
|
|
const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
|
|
lineParts.slice(1).forEach(part => items.push(`${continuationIndent}${part}`));
|
|
}
|
|
nested.filter(Boolean).forEach(block => items.push(block));
|
|
});
|
|
return items.join("\n");
|
|
}
|
|
|
|
function blockToMarkdown(node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return normalizeText(node.textContent || "");
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
|
if (isNoiseElement(node)) return "";
|
|
|
|
const tag = node.tagName.toLowerCase();
|
|
if (tag === "table") return tableToMarkdown(node);
|
|
if (tag === "ul" || tag === "ol") return listToMarkdown(node);
|
|
if (node.matches(".cm-editor[data-is-code-block-view='true']")) {
|
|
const lines = Array.from(node.querySelectorAll(".cm-line")).map(line => {
|
|
const text = preserveNodeText(line);
|
|
return text === "\n" ? "" : text.replace(/\n$/, "");
|
|
});
|
|
const code = normalizeCodeBlock(lines.join("\n"));
|
|
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
|
}
|
|
if (tag === "pre") {
|
|
const code = normalizeCodeBlock(preserveNodeText(node));
|
|
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
|
}
|
|
if (tag === "blockquote") {
|
|
const content = collapseBlankLines(Array.from(node.childNodes).map(blockToMarkdown).join("\n\n"));
|
|
return content
|
|
.split("\n")
|
|
.map(line => line ? `> ${line}` : ">")
|
|
.join("\n");
|
|
}
|
|
if (/^h[1-6]$/.test(tag)) {
|
|
const level = Number(tag.slice(1));
|
|
const text = textBlock(node);
|
|
return text ? `${"#".repeat(level)} ${text}` : "";
|
|
}
|
|
if (tag === "p" || tag === "figcaption") {
|
|
return textBlock(node);
|
|
}
|
|
if (tag === "hr") {
|
|
return "---";
|
|
}
|
|
if (tag === "img") {
|
|
return inlineText(node);
|
|
}
|
|
|
|
const childBlocks = Array.from(node.childNodes)
|
|
.map(child => blockToMarkdown(child))
|
|
.filter(Boolean);
|
|
if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n"));
|
|
|
|
return textBlock(node);
|
|
}
|
|
|
|
const root = stripNoise(pickRoot());
|
|
const markdown = blockToMarkdown(root);
|
|
return collapseBlankLines(markdown);
|
|
}
|
|
|
|
const fns = { domQuery, domClick, domType, domAttr, domText, domExists,
|
|
extractLinks, extractImages, extractText, extractJson, extractMarkdown };
|
|
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 activeTabs = await chrome.tabs.query({ active: true });
|
|
if (!activeTabs.length) throw new Error("No active tab found");
|
|
|
|
const windows = await chrome.windows.getAll({ populate: false });
|
|
const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id));
|
|
|
|
const chooseTab = (predicate) => activeTabs.find(predicate);
|
|
const byFocusAndScriptable = tab => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
|
|
const byScriptable = tab => isScriptableUrl(tab.url || tab.pendingUrl || "");
|
|
const byFocus = tab => focusedWindowIds.has(tab.windowId);
|
|
|
|
return chooseTab(byFocusAndScriptable)
|
|
|| chooseTab(byScriptable)
|
|
|| chooseTab(byFocus)
|
|
|| activeTabs[0];
|
|
}
|
|
|
|
async function resolveTabForDirectAction(tabId, actionName) {
|
|
if (tabId != null) {
|
|
return chrome.tabs.get(tabId);
|
|
}
|
|
const allTabs = await chrome.tabs.query({});
|
|
if (allTabs.length !== 1) {
|
|
throw new Error(
|
|
`Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open`
|
|
);
|
|
}
|
|
return allTabs[0];
|
|
}
|
|
|
|
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 || {};
|
|
}
|