From 7ee664153b777f3b58541990a3eee1c769943cdd Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Fri, 1 May 2026 19:28:36 +0200 Subject: [PATCH] change background.js into split typescript files for better managemand and build background.js from typescript --- .gitea/workflows/package-extension.yml | 19 +- .gitignore | 11 +- README.md | 17 +- extension/background.js | 1630 ------------------------ extension/src/commands/browser-data.ts | 62 + extension/src/commands/dom.ts | 82 ++ extension/src/commands/groups.ts | 95 ++ extension/src/commands/injected.ts | 560 ++++++++ extension/src/commands/navigation.ts | 93 ++ extension/src/commands/session.ts | 127 ++ extension/src/commands/tabs.ts | 214 ++++ extension/src/commands/windows.ts | 34 + extension/src/core.ts | 131 ++ extension/src/index.ts | 246 ++++ package-lock.json | 548 ++++++++ package.json | 14 + tsconfig.json | 14 + 17 files changed, 2261 insertions(+), 1636 deletions(-) delete mode 100644 extension/background.js create mode 100644 extension/src/commands/browser-data.ts create mode 100644 extension/src/commands/dom.ts create mode 100644 extension/src/commands/groups.ts create mode 100644 extension/src/commands/injected.ts create mode 100644 extension/src/commands/navigation.ts create mode 100644 extension/src/commands/session.ts create mode 100644 extension/src/commands/tabs.ts create mode 100644 extension/src/commands/windows.ts create mode 100644 extension/src/core.ts create mode 100644 extension/src/index.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitea/workflows/package-extension.yml b/.gitea/workflows/package-extension.yml index 9a93d65..13ad427 100644 --- a/.gitea/workflows/package-extension.yml +++ b/.gitea/workflows/package-extension.yml @@ -14,6 +14,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install extension build dependencies + run: npm ci + + - name: Build extension + run: npm run check:extension + - name: Read extension version id: version run: | @@ -29,8 +41,11 @@ jobs: - name: Build extension archive run: | - mkdir -p dist - cd extension + rm -rf extension-package + mkdir -p dist extension-package + cp extension/manifest.json extension/background.js extension/content.js extension/icon.svg extension-package/ + cp -R extension/icons extension-package/icons + cd extension-package zip -r "../dist/browser-cli-extension-v${{ steps.version.outputs.version }}.zip" . - name: Publish extension release asset diff --git a/.gitignore b/.gitignore index 01aba14..fcbfeeb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,11 @@ -__pycache__/ -.vscode/ +# TypeScript / Node +extension/background.js +node_modules/ +dist/ +# Python +__pycache__/ *.pyc + +# IDE +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index d90aee5..5c7d5ed 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,9 @@ browser-cli/ │ └── session.py # session save/load ├── extension/ │ ├── manifest.json # MV3 extension manifest -│ ├── background.js # Service worker command dispatcher -│ └── content.js # Content-script helpers +│ ├── content.js # Content-script helpers +│ └── src/ # TypeScript source split by command area +│ └── index.ts # Builds generated extension/background.js ├── examples/ │ ├── demo.py # Python API walkthrough │ └── demo.sh # Bash CLI walkthrough @@ -402,6 +403,18 @@ bash examples/demo.sh --- +## Development + +```sh +npm ci +npm run check:extension # type-check, build extension/background.js, syntax-check bundle +uv run pytest -q +``` + +The extension source lives in `extension/src/`. `extension/background.js` is generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. + +--- + ## Limitations - **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. diff --git a/extension/background.js b/extension/background.js deleted file mode 100644 index 7a3b1e6..0000000 --- a/extension/background.js +++ /dev/null @@ -1,1630 +0,0 @@ -/** - * 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 { - const { __page, ...commandArgs } = args || {}; - data = await dispatch(command, commandArgs); - if (__page && Array.isArray(data)) { - data = makePagedData(data, __page); - } - } 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(); - } -} - -function makePagedData(items, page) { - const total = items.length; - const offset = Math.max(0, Number(page.offset) || 0); - const requestedLimit = Math.max(1, Number(page.limit) || 100); - const limit = Math.min(requestedLimit, 1000); - const end = Math.min(offset + limit, total); - return { - __browserCliPage: true, - items: items.slice(offset, end), - offset, - limit, - total, - nextOffset: end < total ? end : null, - }; -} - -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); - case "navigate.wait": return navWait(args); - case "navigate.open_wait": return navOpenWait(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); - case "tabs.pin": return tabsPin(args); - case "tabs.unpin": return tabsUnpin(args); - case "tabs.screenshot": return tabsScreenshot(args); - case "tabs.watch_url": return tabsWatchUrl(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); - case "dom.scroll": return domOp("domScroll", args); - case "dom.select": return domOp("domSelect", args); - case "dom.key": return domOp("domKey", args); - case "dom.hover": return domOp("domHover", args); - case "dom.check": return domOp("domCheck", { ...args, checked: true }); - case "dom.uncheck": return domOp("domCheck", { ...args, checked: false }); - case "dom.clear": return domOp("domClear", args); - case "dom.focus": return domOp("domFocus", args); - case "dom.submit": return domOp("domSubmit", args); - case "dom.eval": return domEval(args); - case "dom.wait_for": return domWaitFor(args); - case "dom.poll": return domPoll(args); - - // ── Page ────────────────────────────────────────────────────────────── - case "page.info": return domOp("pageInfo", {}); - - // ── Storage ─────────────────────────────────────────────────────────── - case "storage.get": return storageGet(args); - case "storage.set": return storageSet(args); - - // ── Cookies ─────────────────────────────────────────────────────────── - case "cookies.list": return cookiesList(args); - case "cookies.get": return cookiesGet(args); - case "cookies.set": return cookiesSet(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 }; -} - -async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) { - const tab = tabId ? { id: tabId } : await getActiveTab(); - const deadline = Date.now() + timeout; - const interval = 200; - while (Date.now() < deadline) { - const t = await chrome.tabs.get(tab.id); - if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") { - return tabInfo(t); - } - await new Promise(r => setTimeout(r, interval)); - } - throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`); -} - -async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) { - const opened = await navOpen({ url, background, window: windowName, group }); - return await navWait({ tabId: opened.id, timeout }); -} - -// ── 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 tabsPin({ tabId }) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - await chrome.tabs.update(tab.id, { pinned: true }); - return { tabId: tab.id, pinned: true }; -} - -async function tabsUnpin({ tabId }) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - await chrome.tabs.update(tab.id, { pinned: false }); - return { tabId: tab.id, pinned: false }; -} - -async function tabsScreenshot({ tabId, format = "png", quality } = {}) { - let windowId; - if (tabId) { - const tab = await chrome.tabs.get(tabId); - await chrome.tabs.update(tabId, { active: true }); - windowId = tab.windowId; - } else { - const tab = await getActiveTab(); - windowId = tab.windowId; - } - const opts = { format }; - if (format === "jpeg" && quality != null) opts.quality = quality; - const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts); - return { dataUrl, format }; -} - -async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - const deadline = Date.now() + timeout; - const regex = new RegExp(pattern); - while (Date.now() < deadline) { - const t = await chrome.tabs.get(tab.id); - const url = t.url || t.pendingUrl || ""; - if (regex.test(url)) return tabInfo(t); - await new Promise(r => setTimeout(r, 200)); - } - throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`); -} - -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; -} - -async function domEval({ code, tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : 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 }, - world: "MAIN", - func: (c) => (0, eval)(c), - args: [code], - }); - return results[0]?.result ?? null; -} - -async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - if (!isScriptableUrl(tab.url)) { - throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); - } - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const results = await executeScript({ - target: { tabId: tab.id }, - func: (sel, vis, hid) => { - const el = document.querySelector(sel); - if (hid) return !el || el.offsetParent === null; - if (!el) return false; - if (vis) { - const r = el.getBoundingClientRect(); - return r.width > 0 && r.height > 0; - } - return true; - }, - args: [selector, visible, hidden], - }); - if (results[0]?.result) return { selector, found: !hidden }; - await new Promise(r => setTimeout(r, 200)); - } - throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`); -} - -async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - if (!isScriptableUrl(tab.url)) { - throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); - } - const deadline = Date.now() + timeout; - const regex = new RegExp(pattern); - while (Date.now() < deadline) { - const results = await executeScript({ - target: { tabId: tab.id }, - func: (sel, a) => { - const el = document.querySelector(sel); - if (!el) return null; - if (a) return el.getAttribute(a) ?? el[a] ?? null; - return el.value !== undefined ? el.value : el.textContent.trim(); - }, - args: [selector, attr || null], - }); - const value = results[0]?.result; - if (value != null && regex.test(String(value))) return { selector, value, pattern }; - await new Promise(r => setTimeout(r, interval)); - } - throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`); -} - -async function storageGet({ key, type = "local", tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - if (!isScriptableUrl(tab.url)) { - throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); - } - const results = await executeScript({ - target: { tabId: tab.id }, - world: "MAIN", - func: (k, t) => { - const store = t === "session" ? sessionStorage : localStorage; - if (k) return store.getItem(k); - return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)])); - }, - args: [key || null, type], - }); - return results[0]?.result ?? null; -} - -async function storageSet({ key, value, type = "local", tabId } = {}) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - if (!isScriptableUrl(tab.url)) { - throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); - } - const results = await executeScript({ - target: { tabId: tab.id }, - world: "MAIN", - func: (k, v, t) => { - const store = t === "session" ? sessionStorage : localStorage; - store.setItem(k, typeof v === "string" ? v : JSON.stringify(v)); - return true; - }, - args: [key, value, type], - }); - return results[0]?.result ?? false; -} - -async function cookiesList({ url, domain, name } = {}) { - const details = {}; - if (url) details.url = url; - if (domain) details.domain = domain; - if (name) details.name = name; - return await chrome.cookies.getAll(details); -} - -async function cookiesGet({ url, name }) { - return await chrome.cookies.get({ url, name }); -} - -async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) { - const details = { url, name, value }; - if (domain != null) details.domain = domain; - if (path != null) details.path = path; - if (secure != null) details.secure = secure; - if (httpOnly != null) details.httpOnly = httpOnly; - if (expirationDate != null) details.expirationDate = expirationDate; - if (sameSite != null) details.sameSite = sameSite; - return await chrome.cookies.set(details); -} - -// 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 domKey({ selector, key }) { - const el = selector ? document.querySelector(selector) : document.activeElement; - if (selector && !el) throw new Error(`No element: ${selector}`); - const target = el || document.body; - ["keydown", "keypress", "keyup"].forEach(type => { - target.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true })); - }); - return true; - } - function domHover({ selector }) { - const el = document.querySelector(selector); - if (!el) throw new Error(`No element: ${selector}`); - el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); - return true; - } - function domCheck({ selector, checked }) { - const el = document.querySelector(selector); - if (!el) throw new Error(`No element: ${selector}`); - el.checked = checked; - el.dispatchEvent(new Event("change", { bubbles: true })); - return true; - } - function domClear({ selector }) { - const el = document.querySelector(selector); - if (!el) throw new Error(`No element: ${selector}`); - el.value = ""; - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - return true; - } - function domFocus({ selector }) { - const el = document.querySelector(selector); - if (!el) throw new Error(`No element: ${selector}`); - el.focus(); - return true; - } - function domSubmit({ selector }) { - const el = document.querySelector(selector); - if (!el) throw new Error(`No element: ${selector}`); - const form = el.tagName === "FORM" ? el : el.closest("form"); - if (!form) throw new Error(`No form found for: ${selector}`); - form.submit(); - return true; - } - function pageInfo() { - const metas = {}; - document.querySelectorAll("meta[name], meta[property]").forEach(m => { - const k = m.getAttribute("name") || m.getAttribute("property"); - if (k) metas[k] = m.getAttribute("content") || ""; - }); - return { - title: document.title, - url: location.href, - readyState: document.readyState, - lang: document.documentElement.lang || null, - meta: metas, - }; - } - function domScroll({ selector, x, y }) { - if (selector) { - const el = document.querySelector(selector); - if (!el) throw new Error(`No element: ${selector}`); - el.scrollIntoView({ behavior: "smooth", block: "center" }); - return true; - } - window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" }); - return true; - } - function domSelect({ selector, value }) { - const el = document.querySelector(selector); - if (!el) throw new Error(`No element: ${selector}`); - el.value = value; - el.dispatchEvent(new Event("change", { bubbles: true })); - el.dispatchEvent(new Event("input", { bubbles: true })); - return true; - } - 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 ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`; - } - 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, - domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit, - pageInfo, - 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 || {}; -} diff --git a/extension/src/commands/browser-data.ts b/extension/src/commands/browser-data.ts new file mode 100644 index 0000000..6883271 --- /dev/null +++ b/extension/src/commands/browser-data.ts @@ -0,0 +1,62 @@ +// @ts-nocheck +import { executeScript, getActiveTab, isScriptableUrl } from '../core'; +export async function storageGet({ key, type = "local", tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); + } + const results = await executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: (k, t) => { + const store = t === "session" ? sessionStorage : localStorage; + if (k) return store.getItem(k); + return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)])); + }, + args: [key || null, type], + }); + return results[0]?.result ?? null; +} + +export async function storageSet({ key, value, type = "local", tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); + } + const results = await executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: (k, v, t) => { + const store = t === "session" ? sessionStorage : localStorage; + store.setItem(k, typeof v === "string" ? v : JSON.stringify(v)); + return true; + }, + args: [key, value, type], + }); + return results[0]?.result ?? false; +} + +export async function cookiesList({ url, domain, name } = {}) { + const details = {}; + if (url) details.url = url; + if (domain) details.domain = domain; + if (name) details.name = name; + return await chrome.cookies.getAll(details); +} + +export async function cookiesGet({ url, name }) { + return await chrome.cookies.get({ url, name }); +} + +export async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) { + const details = { url, name, value }; + if (domain != null) details.domain = domain; + if (path != null) details.path = path; + if (secure != null) details.secure = secure; + if (httpOnly != null) details.httpOnly = httpOnly; + if (expirationDate != null) details.expirationDate = expirationDate; + if (sameSite != null) details.sameSite = sameSite; + return await chrome.cookies.set(details); +} + +// This function is serialized and injected into the page by chrome.scripting diff --git a/extension/src/commands/dom.ts b/extension/src/commands/dom.ts new file mode 100644 index 0000000..119cccd --- /dev/null +++ b/extension/src/commands/dom.ts @@ -0,0 +1,82 @@ +// @ts-nocheck +import { executeScript, getActiveTab, isScriptableUrl } from '../core'; +import { contentDispatch } from './injected'; +export 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; +} + +export async function domEval({ code, tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : 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 }, + world: "MAIN", + func: (c) => (0, eval)(c), + args: [code], + }); + return results[0]?.result ?? null; +} + +export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); + } + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const results = await executeScript({ + target: { tabId: tab.id }, + func: (sel, vis, hid) => { + const el = document.querySelector(sel); + if (hid) return !el || el.offsetParent === null; + if (!el) return false; + if (vis) { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + } + return true; + }, + args: [selector, visible, hidden], + }); + if (results[0]?.result) return { selector, found: !hidden }; + await new Promise(r => setTimeout(r, 200)); + } + throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`); +} + +export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); + } + const deadline = Date.now() + timeout; + const regex = new RegExp(pattern); + while (Date.now() < deadline) { + const results = await executeScript({ + target: { tabId: tab.id }, + func: (sel, a) => { + const el = document.querySelector(sel); + if (!el) return null; + if (a) return el.getAttribute(a) ?? el[a] ?? null; + return el.value !== undefined ? el.value : el.textContent.trim(); + }, + args: [selector, attr || null], + }); + const value = results[0]?.result; + if (value != null && regex.test(String(value))) return { selector, value, pattern }; + await new Promise(r => setTimeout(r, interval)); + } + throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`); +} + diff --git a/extension/src/commands/groups.ts b/extension/src/commands/groups.ts new file mode 100644 index 0000000..5fa1a8d --- /dev/null +++ b/extension/src/commands/groups.ts @@ -0,0 +1,95 @@ +// @ts-nocheck +import { buildTabBlocks, resolveGroupId, tabInfo } from '../core'; +export 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, + })); +} + +export async function groupTabs({ groupId }) { + const all = await chrome.tabs.query({}); + return all.filter(t => t.groupId === groupId).map(tabInfo); +} + +export async function groupCount() { + const groups = await chrome.tabGroups.query({}); + return groups.length; +} + +export 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)); +} + +export 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 }; +} + +export 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 }; +} + +export 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 }; +} + +export 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 ─────────────────────────────────────────────────────────────────── diff --git a/extension/src/commands/injected.ts b/extension/src/commands/injected.ts new file mode 100644 index 0000000..2c86b7a --- /dev/null +++ b/extension/src/commands/injected.ts @@ -0,0 +1,560 @@ +// @ts-nocheck +export 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 domKey({ selector, key }) { + const el = selector ? document.querySelector(selector) : document.activeElement; + if (selector && !el) throw new Error(`No element: ${selector}`); + const target = el || document.body; + ["keydown", "keypress", "keyup"].forEach(type => { + target.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true })); + }); + return true; + } + function domHover({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + return true; + } + function domCheck({ selector, checked }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.checked = checked; + el.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + function domClear({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.value = ""; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + function domFocus({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.focus(); + return true; + } + function domSubmit({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + const form = el.tagName === "FORM" ? el : el.closest("form"); + if (!form) throw new Error(`No form found for: ${selector}`); + form.submit(); + return true; + } + function pageInfo() { + const metas = {}; + document.querySelectorAll("meta[name], meta[property]").forEach(m => { + const k = m.getAttribute("name") || m.getAttribute("property"); + if (k) metas[k] = m.getAttribute("content") || ""; + }); + return { + title: document.title, + url: location.href, + readyState: document.readyState, + lang: document.documentElement.lang || null, + meta: metas, + }; + } + function domScroll({ selector, x, y }) { + if (selector) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.scrollIntoView({ behavior: "smooth", block: "center" }); + return true; + } + window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" }); + return true; + } + function domSelect({ selector, value }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + el.dispatchEvent(new Event("input", { bubbles: true })); + return true; + } + 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 ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`; + } + 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, + domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit, + pageInfo, + extractLinks, extractImages, extractText, extractJson, extractMarkdown }; + const fn = fns[funcName]; + if (!fn) throw new Error(`Unknown content function: ${funcName}`); + return fn(args); +} + +// ── Session ─────────────────────────────────────────────────────────────────── diff --git a/extension/src/commands/navigation.ts b/extension/src/commands/navigation.ts new file mode 100644 index 0000000..c605243 --- /dev/null +++ b/extension/src/commands/navigation.ts @@ -0,0 +1,93 @@ +// @ts-nocheck +import { getActiveTab, getAliases, resolveGroupId, tabInfo } from '../core'; +export 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 }; +} + +export async function navTo({ tabId, url }) { + const tab = await chrome.tabs.update(tabId, { url }); + return { id: tab.id, url: tab.url || url }; +} + +export async function navReload({ tabId }, bypassCache) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.reload(tab.id, { bypassCache }); + return { tabId: tab.id }; +} + +export async function navBack({ tabId }) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.goBack(tab.id); + return { tabId: tab.id }; +} + +export async function navForward({ tabId }) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.goForward(tab.id); + return { tabId: tab.id }; +} + +export 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 }; +} + +export async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + const deadline = Date.now() + timeout; + const interval = 200; + while (Date.now() < deadline) { + const t = await chrome.tabs.get(tab.id); + if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") { + return tabInfo(t); + } + await new Promise(r => setTimeout(r, interval)); + } + throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`); +} + +export async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) { + const opened = await navOpen({ url, background, window: windowName, group }); + return await navWait({ tabId: opened.id, timeout }); +} + +// ── Tabs ────────────────────────────────────────────────────────────────────── diff --git a/extension/src/commands/session.ts b/extension/src/commands/session.ts new file mode 100644 index 0000000..adae3d9 --- /dev/null +++ b/extension/src/commands/session.ts @@ -0,0 +1,127 @@ +// @ts-nocheck +import { getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor } from '../core'; +export 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 }; +} + +export 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 }; +} + +export async function sessionList() { + const sessions = await getSessions(); + return Object.entries(sessions).map(([name, s]) => ({ + name, + tabs: getSessionTabs(s).length, + savedAt: s.savedAt || null, + })); +} + +export 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 }; +} + +export 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)), + }; +} + +export 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 }; +} + +export async function autoSaveHandler() { + const { autoSave } = await chrome.storage.local.get("autoSave"); + if (!autoSave) return; + await sessionSave({ name: "__auto__" }); +} + +// ── Misc ────────────────────────────────────────────────────────────────────── + +export 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, + }]; +} + +export async function clientsRenameProfile({ alias }) { + await chrome.storage.local.set({ profileAlias: alias }); + return { alias }; +} diff --git a/extension/src/commands/tabs.ts b/extension/src/commands/tabs.ts new file mode 100644 index 0000000..77e72b5 --- /dev/null +++ b/extension/src/commands/tabs.ts @@ -0,0 +1,214 @@ +// @ts-nocheck +import { executeScript, getActiveTab, isScriptableUrl, resolveTabForDirectAction } from '../core'; +export 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; +} + +export 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 }; +} + +export 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 }; +} + +export 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 }; +} + +export 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); +} + +export async function tabsStatus({ tabId }) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + return tabInfo(tab); +} + +export async function tabsFilter({ pattern }) { + const all = await chrome.tabs.query({}); + return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); +} + +export 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; +} + +export 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); +} + +export 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; + } + } +} + +export async function tabsDedupe() { + return tabsClose({ duplicates: true }); +} + +export 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 }; +} + +export 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 }; +} + +export async function tabsPin({ tabId }) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + await chrome.tabs.update(tab.id, { pinned: true }); + return { tabId: tab.id, pinned: true }; +} + +export async function tabsUnpin({ tabId }) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + await chrome.tabs.update(tab.id, { pinned: false }); + return { tabId: tab.id, pinned: false }; +} + +export async function tabsScreenshot({ tabId, format = "png", quality } = {}) { + let windowId; + if (tabId) { + const tab = await chrome.tabs.get(tabId); + await chrome.tabs.update(tabId, { active: true }); + windowId = tab.windowId; + } else { + const tab = await getActiveTab(); + windowId = tab.windowId; + } + const opts = { format }; + if (format === "jpeg" && quality != null) opts.quality = quality; + const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts); + return { dataUrl, format }; +} + +export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + const deadline = Date.now() + timeout; + const regex = new RegExp(pattern); + while (Date.now() < deadline) { + const t = await chrome.tabs.get(tab.id); + const url = t.url || t.pendingUrl || ""; + if (regex.test(url)) return tabInfo(t); + await new Promise(r => setTimeout(r, 200)); + } + throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`); +} + +export async function tabsMute({ tabId }) { + const tab = await resolveTabForDirectAction(tabId, "mute"); + await chrome.tabs.update(tab.id, { muted: true }); + return { tabId: tab.id, muted: true }; +} + +export async function tabsUnmute({ tabId }) { + const tab = await resolveTabForDirectAction(tabId, "unmute"); + await chrome.tabs.update(tab.id, { muted: false }); + return { tabId: tab.id, muted: false }; +} + diff --git a/extension/src/commands/windows.ts b/extension/src/commands/windows.ts new file mode 100644 index 0000000..6be245a --- /dev/null +++ b/extension/src/commands/windows.ts @@ -0,0 +1,34 @@ +// @ts-nocheck +import { getAliases } from '../core'; +export 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, + })); +} + +export async function windowsRename({ windowId, name }) { + const aliases = await getAliases(); + aliases[windowId] = name; + await chrome.storage.local.set({ windowAliases: aliases }); + return { windowId, name }; +} + +export async function windowsClose({ windowId }) { + await chrome.windows.remove(windowId); + return { windowId }; +} + +export 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 ───────────────────────────────────────────────────────────── diff --git a/extension/src/core.ts b/extension/src/core.ts new file mode 100644 index 0000000..fca3a09 --- /dev/null +++ b/extension/src/core.ts @@ -0,0 +1,131 @@ +// @ts-nocheck +// Shared helpers for browser-cli extension command handlers. +export async function getProfileAlias() { + const { profileAlias } = await chrome.storage.local.get("profileAlias"); + return profileAlias || "default"; +} + +export 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; + } + } +} + +export 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 ──────────────────────────────────────────────────────────────────── + +export function isScriptableUrl(url) { + if (!url) return false; + return !url.startsWith("chrome://") && + !url.startsWith("brave://") && + !url.startsWith("about:") && + !url.startsWith("edge://") && + !url.startsWith("chrome-extension://"); +} + +export 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]; +} + +export 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]; +} + +export 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; +} + +export 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; +} + +export 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 []; +} + +export function normalizeGroupColor(color) { + const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); + return allowed.has(color) ? color : "grey"; +} + +export async function getAliases() { + const { windowAliases } = await chrome.storage.local.get("windowAliases"); + return windowAliases || {}; +} + +export async function getSessions() { + const { sessions } = await chrome.storage.local.get("sessions"); + return sessions || {}; +} diff --git a/extension/src/index.ts b/extension/src/index.ts new file mode 100644 index 0000000..5bc2654 --- /dev/null +++ b/extension/src/index.ts @@ -0,0 +1,246 @@ +// @ts-nocheck +/** + * browser-cli Extension — Background Service Worker + * + * Connects to the native host (com.browsercli.host) via Native Messaging. + */ + +import { getProfileAlias } from './core'; +import * as nav from './commands/navigation'; +import * as tabs from './commands/tabs'; +import * as groups from './commands/groups'; +import * as windowsCmd from './commands/windows'; +import * as dom from './commands/dom'; +import * as browserData from './commands/browser-data'; +import * as session from './commands/session'; + +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 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 { + const { __page, ...commandArgs } = args || {}; + data = await dispatch(command, commandArgs); + if (__page && Array.isArray(data)) { + data = makePagedData(data, __page); + } + } 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(); + } +} +function makePagedData(items, page) { + const total = items.length; + const offset = Math.max(0, Number(page.offset) || 0); + const requestedLimit = Math.max(1, Number(page.limit) || 100); + const limit = Math.min(requestedLimit, 1000); + const end = Math.min(offset + limit, total); + return { + __browserCliPage: true, + items: items.slice(offset, end), + offset, + limit, + total, + nextOffset: end < total ? end : null, + }; +} +async function dispatch(command, args) { + switch (command) { + // ── Navigation ──────────────────────────────────────────────────────── + case "navigate.open": return nav.navOpen(args); + case "navigate.to": return nav.navTo(args); + case "navigate.reload": return nav.navReload(args, false); + case "navigate.hard_reload": return nav.navReload(args, true); + case "navigate.back": return nav.navBack(args); + case "navigate.forward": return nav.navForward(args); + case "navigate.focus": return nav.navFocus(args); + case "navigate.wait": return nav.navWait(args); + case "navigate.open_wait": return nav.navOpenWait(args); + + // ── Tabs ────────────────────────────────────────────────────────────── + case "tabs.list": return tabs.tabsList(); + case "tabs.close": return tabs.tabsClose(args); + case "tabs.move": return tabs.tabsMove(args); + case "tabs.active": return tabs.tabsActive(args); + case "tabs.active_in_window": return tabs.tabsActiveInWindow(args); + case "tabs.status": return tabs.tabsStatus(args); + case "tabs.filter": return tabs.tabsFilter(args); + case "tabs.count": return tabs.tabsCount(args); + case "tabs.query": return tabs.tabsQuery(args); + case "tabs.html": return tabs.tabsHtml(args); + case "tabs.dedupe": return tabs.tabsDedupe(); + case "tabs.sort": return tabs.tabsSort(args); + case "tabs.merge_windows": return tabs.tabsMergeWindows(); + case "tabs.mute": return tabs.tabsMute(args); + case "tabs.unmute": return tabs.tabsUnmute(args); + case "tabs.pin": return tabs.tabsPin(args); + case "tabs.unpin": return tabs.tabsUnpin(args); + case "tabs.screenshot": return tabs.tabsScreenshot(args); + case "tabs.watch_url": return tabs.tabsWatchUrl(args); + + // ── Groups ──────────────────────────────────────────────────────────── + case "group.list": return groups.groupList(); + case "group.tabs": return groups.groupTabs(args); + case "group.count": return groups.groupCount(); + case "group.query": return groups.groupQuery(args); + case "group.close": return groups.groupClose(args); + case "group.open": return groups.groupOpen(args); + case "group.add_tab": return groups.groupAddTab(args); + case "group.move": return groups.groupMove(args); + + // ── Windows ─────────────────────────────────────────────────────────── + case "windows.list": return windowsCmd.windowsList(); + case "windows.rename": return windowsCmd.windowsRename(args); + case "windows.close": return windowsCmd.windowsClose(args); + case "windows.open": return windowsCmd.windowsOpen(args); + + // ── DOM ─────────────────────────────────────────────────────────────── + case "dom.query": return dom.domOp("domQuery", args); + case "dom.click": return dom.domOp("domClick", args); + case "dom.type": return dom.domOp("domType", args); + case "dom.attr": return dom.domOp("domAttr", args); + case "dom.text": return dom.domOp("domText", args); + case "dom.exists": return dom.domOp("domExists", args); + case "dom.scroll": return dom.domOp("domScroll", args); + case "dom.select": return dom.domOp("domSelect", args); + case "dom.key": return dom.domOp("domKey", args); + case "dom.hover": return dom.domOp("domHover", args); + case "dom.check": return dom.domOp("domCheck", { ...args, checked: true }); + case "dom.uncheck": return dom.domOp("domCheck", { ...args, checked: false }); + case "dom.clear": return dom.domOp("domClear", args); + case "dom.focus": return dom.domOp("domFocus", args); + case "dom.submit": return dom.domOp("domSubmit", args); + case "dom.eval": return dom.domEval(args); + case "dom.wait_for": return dom.domWaitFor(args); + case "dom.poll": return dom.domPoll(args); + + // ── Page ────────────────────────────────────────────────────────────── + case "page.info": return dom.domOp("pageInfo", {}); + + // ── Storage ─────────────────────────────────────────────────────────── + case "storage.get": return browserData.storageGet(args); + case "storage.set": return browserData.storageSet(args); + + // ── Cookies ─────────────────────────────────────────────────────────── + case "cookies.list": return browserData.cookiesList(args); + case "cookies.get": return browserData.cookiesGet(args); + case "cookies.set": return browserData.cookiesSet(args); + + // ── Extract ─────────────────────────────────────────────────────────── + case "extract.links": return dom.domOp("extractLinks", args); + case "extract.images": return dom.domOp("extractImages", args); + case "extract.text": return dom.domOp("extractText", args); + case "extract.json": return dom.domOp("extractJson", args); + case "extract.markdown": return dom.domOp("extractMarkdown", args); + case "extract.html": return tabs.tabsHtml({}); + + // ── Session ─────────────────────────────────────────────────────────── + case "session.save": return session.sessionSave(args); + case "session.load": return session.sessionLoad(args); + case "session.list": return session.sessionList(); + case "session.remove": return session.sessionRemove(args); + case "session.diff": return session.sessionDiff(args); + case "session.auto_save": return session.sessionAutoSave(args); + + // ── Misc ────────────────────────────────────────────────────────────── + case "clients.list": return session.clientsList(); + case "clients.rename_profile": return session.clientsRenameProfile(args); + + default: + throw new Error(`Unknown command: ${command}`); + } +} + +// ── Navigation ──────────────────────────────────────────────────────────────── diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..298bc68 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,548 @@ +{ + "name": "browser-cli-extension-build", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "browser-cli-extension-build", + "devDependencies": { + "@types/chrome": "^0.0.326", + "esbuild": "^0.25.3", + "typescript": "^5.8.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/chrome": { + "version": "0.0.326", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.326.tgz", + "integrity": "sha512-WS7jKf3ZRZFHOX7dATCZwqNJgdfiSF0qBRFxaO0LhIOvTNBrfkab26bsZwp6EBpYtqp8loMHJTnD6vDTLWPKYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..043cd53 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "browser-cli-extension-build", + "private": true, + "type": "module", + "scripts": { + "build:extension": "esbuild extension/src/index.ts --bundle --format=iife --target=chrome120 --outfile=extension/background.js", + "check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js" + }, + "devDependencies": { + "@types/chrome": "^0.0.326", + "esbuild": "^0.25.3", + "typescript": "^5.8.3" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42c45db --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["chrome"], + "allowJs": false, + "strict": false, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["extension/src/**/*.ts"] +}