diff --git a/.gitignore b/.gitignore index 4df6f4d..684e4f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +!extension/dist/ *.tsbuildinfo .opencli/ .mcp.json diff --git a/extension/.gitignore b/extension/.gitignore index b947077..c2658d7 100644 --- a/extension/.gitignore +++ b/extension/.gitignore @@ -1,2 +1 @@ node_modules/ -dist/ diff --git a/extension/dist/background.js b/extension/dist/background.js new file mode 100644 index 0000000..79fee83 --- /dev/null +++ b/extension/dist/background.js @@ -0,0 +1,337 @@ +const DAEMON_PORT = 19825; +const DAEMON_HOST = "localhost"; +const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const WS_RECONNECT_BASE_DELAY = 2e3; +const WS_RECONNECT_MAX_DELAY = 6e4; + +const attached = /* @__PURE__ */ new Set(); +async function ensureAttached(tabId) { + if (attached.has(tabId)) return; + try { + await chrome.debugger.attach({ tabId }, "1.3"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("Another debugger is already attached")) { + try { + await chrome.debugger.detach({ tabId }); + } catch { + } + try { + await chrome.debugger.attach({ tabId }, "1.3"); + } catch { + throw new Error(`attach failed: ${msg}`); + } + } else { + throw new Error(`attach failed: ${msg}`); + } + } + attached.add(tabId); + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + } catch { + } +} +async function evaluate(tabId, expression) { + await ensureAttached(tabId); + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; +} +const evaluateAsync = evaluate; +async function screenshot(tabId, options = {}) { + await ensureAttached(tabId); + const format = options.format ?? "png"; + if (options.fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) { + await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: Math.ceil(size.width), + height: Math.ceil(size.height), + deviceScaleFactor: 1 + }); + } + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) { + params.quality = Math.max(0, Math.min(100, options.quality)); + } + const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); + return result.data; + } finally { + if (options.fullPage) { + await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { + }); + } + } +} +function detach(tabId) { + if (!attached.has(tabId)) return; + attached.delete(tabId); + try { + chrome.debugger.detach({ tabId }); + } catch { + } +} +function registerListeners() { + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) attached.delete(source.tabId); + }); +} + +let ws = null; +let reconnectTimer = null; +let reconnectAttempts = 0; +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); +function forwardLog(level, args) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); + } catch { + } +} +console.log = (...args) => { + _origLog(...args); + forwardLog("info", args); +}; +console.warn = (...args) => { + _origWarn(...args); + forwardLog("warn", args); +}; +console.error = (...args) => { + _origError(...args); + forwardLog("error", args); +}; +function connect() { + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + ws.onopen = () => { + console.log("[opencli] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + ws.onmessage = async (event) => { + try { + const command = JSON.parse(event.data); + const result = await handleCommand(command); + ws?.send(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + ws.onclose = () => { + console.log("[opencli] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + ws?.close(); + }; +} +function scheduleReconnect() { + if (reconnectTimer) return; + reconnectAttempts++; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} +let initialized = false; +function initialize() { + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); + registerListeners(); + connect(); + console.log("[opencli] Browser Bridge extension initialized"); +} +chrome.runtime.onInstalled.addListener(() => { + initialize(); +}); +chrome.runtime.onStartup.addListener(() => { + initialize(); +}); +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "keepalive") connect(); +}); +async function handleCommand(cmd) { + try { + switch (cmd.action) { + case "exec": + return await handleExec(cmd); + case "navigate": + return await handleNavigate(cmd); + case "tabs": + return await handleTabs(cmd); + case "cookies": + return await handleCookies(cmd); + case "screenshot": + return await handleScreenshot(cmd); + default: + return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; + } + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +function isWebUrl(url) { + if (!url) return false; + return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://"); +} +async function resolveTabId(tabId) { + if (tabId !== void 0) return tabId; + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (activeTab?.id && isWebUrl(activeTab.url)) { + return activeTab.id; + } + const allTabs = await chrome.tabs.query({ currentWindow: true }); + const webTab = allTabs.find((t) => t.id && isWebUrl(t.url)); + if (webTab?.id) { + await chrome.tabs.update(webTab.id, { active: true }); + return webTab.id; + } + const newTab = await chrome.tabs.create({ url: "about:blank", active: true }); + if (!newTab.id) throw new Error("Failed to create new tab"); + return newTab.id; +} +async function handleExec(cmd) { + if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; + const tabId = await resolveTabId(cmd.tabId); + try { + const data = await evaluateAsync(tabId, cmd.code); + return { id: cmd.id, ok: true, data }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} +async function handleNavigate(cmd) { + if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; + const tabId = await resolveTabId(cmd.tabId); + await chrome.tabs.update(tabId, { url: cmd.url }); + await new Promise((resolve) => { + chrome.tabs.get(tabId).then((tab2) => { + if (tab2.status === "complete") { + resolve(); + return; + } + const listener = (id, info) => { + if (id === tabId && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + }, 15e3); + }); + }); + const tab = await chrome.tabs.get(tabId); + return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } }; +} +async function handleTabs(cmd) { + switch (cmd.op) { + case "list": { + const tabs = await chrome.tabs.query({}); + const data = tabs.filter((t) => isWebUrl(t.url)).map((t, i) => ({ + index: i, + tabId: t.id, + url: t.url, + title: t.title, + active: t.active + })); + return { id: cmd.id, ok: true, data }; + } + case "new": { + const tab = await chrome.tabs.create({ url: cmd.url, active: true }); + return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; + } + case "close": { + if (cmd.index !== void 0) { + const tabs = await chrome.tabs.query({}); + const target = tabs[cmd.index]; + if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; + await chrome.tabs.remove(target.id); + detach(target.id); + return { id: cmd.id, ok: true, data: { closed: target.id } }; + } + const tabId = await resolveTabId(cmd.tabId); + await chrome.tabs.remove(tabId); + detach(tabId); + return { id: cmd.id, ok: true, data: { closed: tabId } }; + } + case "select": { + if (cmd.index === void 0 && cmd.tabId === void 0) + return { id: cmd.id, ok: false, error: "Missing index or tabId" }; + if (cmd.tabId !== void 0) { + await chrome.tabs.update(cmd.tabId, { active: true }); + return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; + } + const tabs = await chrome.tabs.query({}); + const target = tabs[cmd.index]; + if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; + await chrome.tabs.update(target.id, { active: true }); + return { id: cmd.id, ok: true, data: { selected: target.id } }; + } + default: + return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; + } +} +async function handleCookies(cmd) { + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const cookies = await chrome.cookies.getAll(details); + const data = cookies.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { id: cmd.id, ok: true, data }; +} +async function handleScreenshot(cmd) { + const tabId = await resolveTabId(cmd.tabId); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage + }); + return { id: cmd.id, ok: true, data }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +}