Files
jackwener__opencli/extension/dist/background.js
jakevin c44bc62b60 chore: code cleanup + extension conflict troubleshooting (#260)
Cleanup:
- Remove redundant double-retry in resolveTabId (was retrying data: URI
  with the same data: URI)
- Fix stale comment (30s → 120s idle timeout)
- Remove verbose debug logging in resolveTabId
- Built extension is now smaller (16.66kB vs 17.18kB)

Extension conflict:
- Add hint to attach-failed error when chrome-extension:// URL is detected
- Add troubleshooting entry for extension conflicts (e.g. youmind, New Tab
  Override) to both README.md and README.zh-CN.md

Ref: #249
2026-03-22 22:18:22 +08:00

508 lines
16 KiB
JavaScript

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();
function isDebuggableUrl$1(url) {
if (!url) return true;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
async function ensureAttached(tabId) {
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl$1(tab.url)) {
attached.delete(tabId);
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
}
} catch (e) {
if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
attached.delete(tabId);
throw new Error(`Tab ${tabId} no longer exists`);
}
if (attached.has(tabId)) {
try {
await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression: "1",
returnByValue: true
});
return;
} catch {
attached.delete(tabId);
}
}
try {
await chrome.debugger.attach({ tabId }, "1.3");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const hint = msg.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : "";
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}${hint}`);
}
} else {
throw new Error(`attach failed: ${msg}${hint}`);
}
}
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);
});
chrome.tabs.onUpdated.addListener((tabId, info) => {
if (info.url && !isDebuggableUrl$1(info.url)) {
if (attached.has(tabId)) {
attached.delete(tabId);
try {
chrome.debugger.detach({ tabId });
} catch {
}
}
}
});
}
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);
}
const automationSessions = /* @__PURE__ */ new Map();
const WINDOW_IDLE_TIMEOUT = 12e4;
function getWorkspaceKey(workspace) {
return workspace?.trim() || "default";
}
function resetWindowIdleTimer(workspace) {
const session = automationSessions.get(workspace);
if (!session) return;
if (session.idleTimer) clearTimeout(session.idleTimer);
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
session.idleTimer = setTimeout(async () => {
const current = automationSessions.get(workspace);
if (!current) return;
try {
await chrome.windows.remove(current.windowId);
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
} catch {
}
automationSessions.delete(workspace);
}, WINDOW_IDLE_TIMEOUT);
}
async function getAutomationWindow(workspace) {
const existing = automationSessions.get(workspace);
if (existing) {
try {
await chrome.windows.get(existing.windowId);
return existing.windowId;
} catch {
automationSessions.delete(workspace);
}
}
const win = await chrome.windows.create({
url: "data:text/html,<html></html>",
focused: false,
width: 1280,
height: 900,
type: "normal"
});
const session = {
windowId: win.id,
idleTimer: null,
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT
};
automationSessions.set(workspace, session);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
resetWindowIdleTimer(workspace);
await new Promise((resolve) => setTimeout(resolve, 200));
return session.windowId;
}
chrome.windows.onRemoved.addListener((windowId) => {
for (const [workspace, session] of automationSessions.entries()) {
if (session.windowId === windowId) {
console.log(`[opencli] Automation window closed (${workspace})`);
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(workspace);
}
}
});
let initialized = false;
function initialize() {
if (initialized) return;
initialized = true;
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
registerListeners();
connect();
console.log("[opencli] OpenCLI 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) {
const workspace = getWorkspaceKey(cmd.workspace);
resetWindowIdleTimer(workspace);
try {
switch (cmd.action) {
case "exec":
return await handleExec(cmd, workspace);
case "navigate":
return await handleNavigate(cmd, workspace);
case "tabs":
return await handleTabs(cmd, workspace);
case "cookies":
return await handleCookies(cmd);
case "screenshot":
return await handleScreenshot(cmd, workspace);
case "close-window":
return await handleCloseWindow(cmd, workspace);
case "sessions":
return await handleSessions(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 isDebuggableUrl(url) {
if (!url) return true;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
async function resolveTabId(tabId, workspace) {
if (tabId !== void 0) {
try {
const tab = await chrome.tabs.get(tabId);
if (isDebuggableUrl(tab.url)) return tabId;
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
} catch {
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
}
const windowId = await getAutomationWindow(workspace);
const tabs = await chrome.tabs.query({ windowId });
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) return debuggableTab.id;
const reuseTab = tabs.find((t) => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: "data:text/html,<html></html>" });
await new Promise((resolve) => setTimeout(resolve, 300));
try {
const updated = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated.url)) return reuseTab.id;
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
} catch {
}
}
const newTab = await chrome.tabs.create({ windowId, url: "data:text/html,<html></html>", active: true });
if (!newTab.id) throw new Error("Failed to create tab in automation window");
return newTab.id;
}
async function listAutomationTabs(workspace) {
const session = automationSessions.get(workspace);
if (!session) return [];
try {
return await chrome.tabs.query({ windowId: session.windowId });
} catch {
automationSessions.delete(workspace);
return [];
}
}
async function listAutomationWebTabs(workspace) {
const tabs = await listAutomationTabs(workspace);
return tabs.filter((tab) => isDebuggableUrl(tab.url));
}
async function handleExec(cmd, workspace) {
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
const tabId = await resolveTabId(cmd.tabId, workspace);
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, workspace) {
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
const tabId = await resolveTabId(cmd.tabId, workspace);
const beforeTab = await chrome.tabs.get(tabId);
const beforeUrl = beforeTab.url ?? "";
const targetUrl = cmd.url;
await chrome.tabs.update(tabId, { url: targetUrl });
let timedOut = false;
await new Promise((resolve) => {
let urlChanged = false;
const listener = (id, info, tab2) => {
if (id !== tabId) return;
if (info.url && info.url !== beforeUrl) {
urlChanged = true;
}
if (urlChanged && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.url !== beforeUrl && currentTab.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
} catch {
}
}, 100);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
resolve();
}, 15e3);
});
const tab = await chrome.tabs.get(tabId);
return {
id: cmd.id,
ok: true,
data: { title: tab.title, url: tab.url, tabId, timedOut }
};
}
async function handleTabs(cmd, workspace) {
switch (cmd.op) {
case "list": {
const tabs = await listAutomationWebTabs(workspace);
const data = tabs.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 windowId = await getAutomationWindow(workspace);
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? "data:text/html,<html></html>", 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 listAutomationWebTabs(workspace);
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, workspace);
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 listAutomationWebTabs(workspace);
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, workspace) {
const tabId = await resolveTabId(cmd.tabId, workspace);
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) };
}
}
async function handleCloseWindow(cmd, workspace) {
const session = automationSessions.get(workspace);
if (session) {
try {
await chrome.windows.remove(session.windowId);
} catch {
}
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(workspace);
}
return { id: cmd.id, ok: true, data: { closed: true } };
}
async function handleSessions(cmd) {
const now = Date.now();
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
workspace,
windowId: session.windowId,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
})));
return { id: cmd.id, ok: true, data };
}