Files
jakevin 56d4326646 refactor(extension): reuse async detach() in registerListeners (#328)
Replace inline sync chrome.debugger.detach() in onUpdated listener
with the shared async detach() function for consistent cleanup behavior
across all detach paths.
2026-03-24 02:18:34 +08:00

583 lines
16 KiB
JavaScript

//#region src/protocol.ts
/** Default daemon port */
var DAEMON_PORT = 19825;
var DAEMON_HOST = "localhost";
var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
`${DAEMON_HOST}${DAEMON_PORT}`;
/** Base reconnect delay for extension WebSocket (ms) */
var WS_RECONNECT_BASE_DELAY = 2e3;
/** Max reconnect delay (ms) */
var WS_RECONNECT_MAX_DELAY = 6e4;
//#endregion
//#region src/cdp.ts
/**
* CDP execution via chrome.debugger API.
*
* chrome.debugger only needs the "debugger" permission — no host_permissions.
* It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
* tabs (resolveTabId in background.ts filters them).
*/
var attached = /* @__PURE__ */ new Set();
/** Check if a URL can be attached via CDP */
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;
}
var evaluateAsync = evaluate;
/**
* Capture a screenshot via CDP Page.captureScreenshot.
* Returns base64-encoded image data.
*/
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));
return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data;
} finally {
if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {});
}
}
async function detach(tabId) {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try {
await 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(async (tabId, info) => {
if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId);
});
}
//#endregion
//#region src/background.ts
var ws = null;
var reconnectTimer = null;
var reconnectAttempts = 0;
var _origLog = console.log.bind(console);
var _origWarn = console.warn.bind(console);
var _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 result = await handleCommand(JSON.parse(event.data));
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);
}
var automationSessions = /* @__PURE__ */ new Map();
var 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);
}
/** Get or create the dedicated automation window. */
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 session = {
windowId: (await chrome.windows.create({
url: "data:text/html,<html></html>",
focused: false,
width: 1280,
height: 900,
type: "normal"
})).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);
}
});
var initialized = false;
function initialize() {
if (initialized) return;
initialized = true;
chrome.alarms.create("keepalive", { periodInMinutes: .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)
};
}
}
/** Check if a URL can be attached via CDP (not chrome:// or chrome-extension://) */
function isDebuggableUrl(url) {
if (!url) return true;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
/**
* Resolve target tab in the automation window.
* If explicit tabId is given, use that directly.
* Otherwise, find or create a tab in the dedicated automation window.
*/
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) {
return (await listAutomationTabs(workspace)).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 beforeUrl = (await chrome.tabs.get(tabId)).url ?? "";
const targetUrl = cmd.url;
await detach(tabId);
await chrome.tabs.update(tabId, { url: targetUrl });
let timedOut = false;
await new Promise((resolve) => {
let urlChanged = false;
const listener = (id, info, tab) => {
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 data = (await listAutomationWebTabs(workspace)).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 target = (await listAutomationWebTabs(workspace))[cmd.index];
if (!target?.id) return {
id: cmd.id,
ok: false,
error: `Tab index ${cmd.index} not found`
};
await chrome.tabs.remove(target.id);
await 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);
await 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 target = (await listAutomationWebTabs(workspace))[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 data = (await chrome.cookies.getAll(details)).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
};
}
//#endregion