mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
56d4326646
Replace inline sync chrome.debugger.detach() in onUpdated listener with the shared async detach() function for consistent cleanup behavior across all detach paths.
583 lines
16 KiB
JavaScript
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
|