1638 lines
54 KiB
JavaScript
1638 lines
54 KiB
JavaScript
const WS_URL = "ws://192.168.50.151:17373";
|
|
let socket = null;
|
|
let reconnectTimer = null;
|
|
|
|
// Feature flag: Enable Content Script routing for structured commands
|
|
// Set to true to use content script, false to use debugger API for all commands
|
|
const USE_CONTENT_SCRIPT = true;
|
|
|
|
// Actions that can be handled by content script
|
|
const CONTENT_SCRIPT_ACTIONS = new Set([
|
|
"FILL",
|
|
"CLICK",
|
|
"CLICK_TEXT",
|
|
"WAIT_FOR_SELECTOR",
|
|
"GET_CONTENT",
|
|
"SEND_KEY",
|
|
"IS_VISIBLE",
|
|
"GET_TEXT",
|
|
"GET_VALUE",
|
|
"SCROLL_INTO_VIEW",
|
|
// EVAL is handled specially - content script for simple cases, debugger for complex
|
|
]);
|
|
|
|
// Simple Set to track what we've attached to
|
|
const attachedTabs = new Set();
|
|
// Track which tabs have Runtime and Log domains enabled
|
|
const enabledTabs = new Set();
|
|
|
|
// Clear stored tabs on startup since debugger sessions don't persist across restarts
|
|
chrome.storage.session.remove("attached").then(() => {
|
|
log("Cleared stale debugger session data on startup");
|
|
});
|
|
|
|
// Logging is disabled by default to reduce console noise. Toggle via Service Worker console.
|
|
let debugEnabled = true;
|
|
self.enableTerminatorDebug = () => {
|
|
debugEnabled = true;
|
|
};
|
|
self.disableTerminatorDebug = () => {
|
|
debugEnabled = false;
|
|
};
|
|
function log(...args) {
|
|
if (!debugEnabled) {
|
|
return;
|
|
}
|
|
console.log("[TerminatorBridge]", ...args);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Content Script Routing (New Architecture)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Route structured commands to content script instead of debugger API.
|
|
* This avoids CDP page refresh issues and provides a cleaner API.
|
|
*
|
|
* @param {number} tabId - Tab ID to send message to
|
|
* @param {string} action - Action name (FILL, CLICK, etc.)
|
|
* @param {object} payload - Action payload
|
|
* @returns {Promise<object>} - Response from content script
|
|
*/
|
|
async function handleViaContentScript(tabId, action, payload) {
|
|
log(`[ContentScript] Sending ${action} to tab ${tabId}`, payload);
|
|
|
|
// Helper to send message with retry logic
|
|
const sendMessageWithRetry = (retryCount = 0) => {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Content script timeout for action: ${action}`));
|
|
}, 30000); // 30 second timeout
|
|
|
|
chrome.tabs.sendMessage(
|
|
tabId,
|
|
{ action, payload },
|
|
async (response) => {
|
|
clearTimeout(timeout);
|
|
|
|
if (chrome.runtime.lastError) {
|
|
const errorMsg = chrome.runtime.lastError.message;
|
|
|
|
// If content script not loaded and we haven't retried yet, try to inject it
|
|
if (
|
|
retryCount === 0 &&
|
|
(errorMsg.includes("Could not establish connection") ||
|
|
errorMsg.includes("Receiving end does not exist"))
|
|
) {
|
|
log(`[ContentScript] Content script not loaded in tab ${tabId}, injecting...`);
|
|
try {
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
files: ["content.js"],
|
|
world: "MAIN",
|
|
});
|
|
log(`[ContentScript] Content script injected into tab ${tabId}, retrying...`);
|
|
// Wait a bit for the script to initialize, then retry
|
|
setTimeout(() => {
|
|
sendMessageWithRetry(1).then(resolve).catch(reject);
|
|
}, 200);
|
|
return;
|
|
} catch (injectErr) {
|
|
log(`[ContentScript] Failed to inject content script:`, injectErr);
|
|
reject(new Error(`Failed to inject content script: ${injectErr.message}`));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Content script error or already retried
|
|
log(
|
|
`[ContentScript] Error sending to tab ${tabId}:`,
|
|
errorMsg
|
|
);
|
|
reject(new Error(errorMsg));
|
|
return;
|
|
}
|
|
|
|
if (!response) {
|
|
reject(new Error("No response from content script"));
|
|
return;
|
|
}
|
|
|
|
if (response.status === "error") {
|
|
log(`[ContentScript] Action ${action} failed:`, response.error);
|
|
reject(new Error(response.error));
|
|
return;
|
|
}
|
|
|
|
log(`[ContentScript] Action ${action} succeeded:`, response.result);
|
|
resolve(response.result);
|
|
}
|
|
);
|
|
});
|
|
};
|
|
|
|
return sendMessageWithRetry();
|
|
}
|
|
|
|
/**
|
|
* Check if an action should use content script routing.
|
|
*/
|
|
function shouldUseContentScript(action) {
|
|
return USE_CONTENT_SCRIPT && CONTENT_SCRIPT_ACTIONS.has(action);
|
|
}
|
|
|
|
// Exponential backoff to reduce repeated connection error spam
|
|
const BASE_RECONNECT_DELAY_MS = 500; // faster initial retry
|
|
const MAX_RECONNECT_DELAY_MS = 3000; // cap retries to 3s to align with host waiting
|
|
let currentReconnectDelayMs = BASE_RECONNECT_DELAY_MS;
|
|
let connectionAttempts = 0;
|
|
const MAX_LOGGED_ATTEMPTS = 3; // Only log first few attempts to reduce noise
|
|
|
|
function connect() {
|
|
try {
|
|
if (
|
|
socket &&
|
|
(socket.readyState === WebSocket.OPEN ||
|
|
socket.readyState === WebSocket.CONNECTING)
|
|
) {
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
try {
|
|
socket = new WebSocket(WS_URL);
|
|
} catch (e) {
|
|
log("WebSocket construct error", e);
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
socket.onopen = () => {
|
|
log("Connected to", WS_URL);
|
|
// Reset backoff on successful connection
|
|
currentReconnectDelayMs = BASE_RECONNECT_DELAY_MS;
|
|
connectionAttempts = 0; // Reset connection attempts on successful connection
|
|
socket.send(JSON.stringify({ type: "hello", from: "extension" }));
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
// Only log socket closed for first 3 attempts and every 10th
|
|
if (connectionAttempts <= 3 || connectionAttempts % 10 === 0) {
|
|
log(`Socket closed (attempt ${connectionAttempts})`);
|
|
}
|
|
scheduleReconnect();
|
|
};
|
|
|
|
socket.onerror = (e) => {
|
|
connectionAttempts++;
|
|
// Only log first 3 attempts and then every 10th attempt to reduce noise
|
|
if (connectionAttempts <= 3 || connectionAttempts % 10 === 0) {
|
|
log(`Socket error (attempt ${connectionAttempts})`, e);
|
|
}
|
|
try {
|
|
socket.close();
|
|
} catch (_) {}
|
|
};
|
|
|
|
socket.onmessage = async (event) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(event.data);
|
|
} catch (e) {
|
|
log("Invalid JSON", event.data);
|
|
return;
|
|
}
|
|
if (!msg || !msg.action) {
|
|
return;
|
|
}
|
|
|
|
if (msg.action === "eval") {
|
|
const { id, code, awaitPromise = true } = msg;
|
|
try {
|
|
const tabId = await getActiveTabId();
|
|
const result = await evalInTab(tabId, code, awaitPromise, id);
|
|
safeSend({ id, ok: true, result });
|
|
} catch (err) {
|
|
safeSend({ id, ok: false, error: String(err && (err.message || err)) });
|
|
}
|
|
} else if (msg.action === "ping") {
|
|
safeSend({ type: "pong" });
|
|
} else if (msg.action === "reset") {
|
|
// Force reset all debugger state
|
|
log("Received reset command");
|
|
await forceResetDebuggerState();
|
|
safeSend({ type: "reset_complete", ok: true });
|
|
} else if (msg.action === "capture_element_at_point") {
|
|
// New action for recording DOM elements
|
|
const { id, x, y } = msg;
|
|
try {
|
|
const tabId = await getActiveTabId();
|
|
const result = await captureElementAtPoint(tabId, x, y, id);
|
|
safeSend({ id, ok: true, result });
|
|
} catch (err) {
|
|
safeSend({ id, ok: false, error: String(err && (err.message || err)) });
|
|
}
|
|
} else if (msg.action === "trusted_click") {
|
|
// Trusted click using Input.dispatchMouseEvent - produces isTrusted=true events
|
|
// Coordinates come from payload (sent via _send_command)
|
|
const { id, payload } = msg;
|
|
const x = payload?.x;
|
|
const y = payload?.y;
|
|
log(`[TrustedClick] Received request: id=${id}, x=${x}, y=${y}`);
|
|
try {
|
|
if (x === undefined || y === undefined) {
|
|
throw new Error(`Invalid coordinates: x=${x}, y=${y}`);
|
|
}
|
|
const tabId = await getActiveTabId();
|
|
const result = await trustedClickAtPoint(tabId, x, y);
|
|
safeSend({ id, ok: true, result });
|
|
} catch (err) {
|
|
safeSend({ id, ok: false, error: String(err && (err.message || err)) });
|
|
}
|
|
} else if (msg.action === "start_recording_session") {
|
|
// Initialize recording mode
|
|
const { id, sessionId } = msg;
|
|
try {
|
|
await startRecordingSession(sessionId);
|
|
safeSend({ id, ok: true, result: { sessionId, status: "recording" } });
|
|
} catch (err) {
|
|
safeSend({ id, ok: false, error: String(err && (err.message || err)) });
|
|
}
|
|
} else if (msg.action === "stop_recording_session") {
|
|
// Stop recording mode
|
|
const { id, sessionId } = msg;
|
|
try {
|
|
await stopRecordingSession(sessionId);
|
|
safeSend({ id, ok: true, result: { sessionId, status: "stopped" } });
|
|
} catch (err) {
|
|
safeSend({ id, ok: false, error: String(err && (err.message || err)) });
|
|
}
|
|
} else if (shouldUseContentScript(msg.action)) {
|
|
// Route structured commands to content script
|
|
const { id, action, payload } = msg;
|
|
try {
|
|
const tabId = await getActiveTabId();
|
|
const result = await handleViaContentScript(tabId, action, payload || {});
|
|
safeSend({ id, ok: true, result });
|
|
} catch (err) {
|
|
// If content script fails, try fallback to debugger API for EVAL-like actions
|
|
if (msg.action === "EVAL" || msg.payload?.code) {
|
|
log(`[ContentScript] Falling back to debugger API for ${msg.action}`);
|
|
try {
|
|
const tabId = await getActiveTabId();
|
|
const code = msg.payload?.code || "";
|
|
const result = await evalInTab(tabId, code, true, id);
|
|
safeSend({ id, ok: true, result });
|
|
} catch (fallbackErr) {
|
|
safeSend({
|
|
id,
|
|
ok: false,
|
|
error: String(fallbackErr && (fallbackErr.message || fallbackErr)),
|
|
});
|
|
}
|
|
} else {
|
|
safeSend({ id, ok: false, error: String(err && (err.message || err)) });
|
|
}
|
|
}
|
|
} else if (msg.action && msg.id) {
|
|
// Unknown action with id - send error response
|
|
safeSend({
|
|
id: msg.id,
|
|
ok: false,
|
|
error: `Unknown action: ${msg.action}`,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
function ensureConnected() {
|
|
try {
|
|
if (
|
|
!socket ||
|
|
(socket.readyState !== WebSocket.OPEN &&
|
|
socket.readyState !== WebSocket.CONNECTING)
|
|
) {
|
|
connect();
|
|
}
|
|
} catch (_) {
|
|
connect();
|
|
}
|
|
}
|
|
|
|
// Ensure we have an active WS connection on first message from content script
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
try {
|
|
if (!message || message.type !== "terminator_content_handshake") {
|
|
return;
|
|
}
|
|
log("Received handshake from content script", {
|
|
tab: sender.tab && sender.tab.id,
|
|
});
|
|
// Kick the connector if not already connected; otherwise noop
|
|
ensureConnected();
|
|
sendResponse({ ok: true });
|
|
} catch (e) {
|
|
try {
|
|
sendResponse({ ok: false, error: String(e && (e.message || e)) });
|
|
} catch (_) {}
|
|
// swallow
|
|
}
|
|
// Keep listener alive for async sendResponse
|
|
return true;
|
|
});
|
|
|
|
// Inject a lightweight handshake into the active tab when it changes/updates
|
|
async function injectHandshake(tabId) {
|
|
try {
|
|
if (typeof tabId !== "number") {
|
|
return;
|
|
}
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
func: () => {
|
|
try {
|
|
chrome.runtime.sendMessage({ type: "terminator_content_handshake" });
|
|
} catch (_) {}
|
|
},
|
|
world: "ISOLATED",
|
|
});
|
|
} catch (e) {
|
|
// ignore (e.g., not permitted on special pages)
|
|
}
|
|
}
|
|
|
|
async function getActiveTabIdSafe() {
|
|
try {
|
|
const [tab] = await chrome.tabs.query({
|
|
active: true,
|
|
lastFocusedWindow: true,
|
|
});
|
|
return tab && tab.id != null ? tab.id : null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Additional event-based triggers to wake the worker and maintain connection
|
|
chrome.runtime.onInstalled.addListener(() => {
|
|
log("onInstalled → ensureConnected");
|
|
ensureConnected();
|
|
});
|
|
chrome.runtime.onStartup.addListener(() => {
|
|
log("onStartup → ensureConnected");
|
|
ensureConnected();
|
|
});
|
|
chrome.webNavigation.onCommitted.addListener(() => {
|
|
ensureConnected();
|
|
});
|
|
chrome.alarms.clear("terminator_keepalive");
|
|
chrome.alarms.create("terminator_keepalive", { periodInMinutes: 1 });
|
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
if (alarm && alarm.name === "terminator_keepalive") {
|
|
ensureConnected();
|
|
}
|
|
});
|
|
chrome.tabs.onActivated.addListener(async () => {
|
|
ensureConnected();
|
|
const tabId = await getActiveTabIdSafe();
|
|
if (tabId != null) {
|
|
injectHandshake(tabId);
|
|
}
|
|
});
|
|
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
|
|
if (
|
|
changeInfo &&
|
|
(changeInfo.status === "loading" || changeInfo.status === "complete")
|
|
) {
|
|
ensureConnected();
|
|
injectHandshake(tabId);
|
|
}
|
|
});
|
|
|
|
// Clean up our tracking when tab closes
|
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
if (attachedTabs.has(tabId)) {
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId); // Also clean enabled domains state
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
log(`Tab ${tabId} closed, removed from attached tabs and enabled domains`);
|
|
}
|
|
});
|
|
|
|
// Clean up when debugger is manually detached (user clicked Cancel)
|
|
chrome.debugger.onDetach.addListener((source, reason) => {
|
|
const {tabId} = source;
|
|
if (attachedTabs.has(tabId)) {
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId); // Also clean enabled domains state
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
log(`Debugger detached from tab ${tabId}, reason: ${reason}`);
|
|
}
|
|
});
|
|
|
|
function scheduleReconnect() {
|
|
if (reconnectTimer) {
|
|
return;
|
|
}
|
|
|
|
const delay = currentReconnectDelayMs;
|
|
currentReconnectDelayMs = Math.min(
|
|
currentReconnectDelayMs * 2,
|
|
MAX_RECONNECT_DELAY_MS,
|
|
);
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
// Only log reconnection for first 3 attempts and then every 10th
|
|
if (connectionAttempts <= 3 || connectionAttempts % 10 === 0) {
|
|
log(
|
|
`Reconnecting... (attempt ${connectionAttempts + 1}, delay=${delay}ms)`,
|
|
);
|
|
}
|
|
connect();
|
|
}, delay);
|
|
}
|
|
|
|
async function forceResetDebuggerState() {
|
|
log("Force resetting all debugger state...");
|
|
|
|
// Store the previous size for logging
|
|
const previousSize = attachedTabs.size;
|
|
|
|
// 1. FIRST: Detach from all tabs (must happen before clearing state)
|
|
let detachedCount = 0;
|
|
try {
|
|
const tabs = await chrome.tabs.query({});
|
|
for (const tab of tabs) {
|
|
if (tab.id != null) {
|
|
try {
|
|
await debuggerDetach(tab.id);
|
|
detachedCount++;
|
|
} catch (e) {
|
|
// Ignore - tab might not be attached or might be special page
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
log("Error during tab cleanup (continuing anyway):", e.message || e);
|
|
}
|
|
|
|
// 2. THEN: Clear in-memory state (after detachment completes)
|
|
attachedTabs.clear();
|
|
enabledTabs.clear();
|
|
|
|
// 3. Clear session storage
|
|
await chrome.storage.session.remove("attached");
|
|
|
|
log(
|
|
`Reset complete: cleared ${previousSize} tracked tabs, detached from ${detachedCount} tabs`,
|
|
);
|
|
log("Debugger state reset complete");
|
|
}
|
|
|
|
function safeSend(obj) {
|
|
try {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify(obj));
|
|
}
|
|
} catch (e) {
|
|
log("Failed to send", e);
|
|
}
|
|
}
|
|
|
|
async function getActiveTabId() {
|
|
const [tab] = await chrome.tabs.query({
|
|
active: true,
|
|
lastFocusedWindow: true,
|
|
});
|
|
if (!tab || tab.id == null) {
|
|
throw new Error("No active tab");
|
|
}
|
|
|
|
// Check if the tab URL is accessible for debugging
|
|
const url = tab.url || "";
|
|
const restrictedPrefixes = [
|
|
"chrome://",
|
|
"chrome-extension://",
|
|
"devtools://",
|
|
"edge://",
|
|
"about:",
|
|
];
|
|
|
|
const isRestricted = restrictedPrefixes.some(prefix => url.startsWith(prefix));
|
|
if (isRestricted) {
|
|
throw new Error(
|
|
`Cannot attach debugger to restricted page: ${url.substring(0, 50)}. ` +
|
|
`Please navigate to a regular webpage (e.g., google.com, github.com) and try again.`
|
|
);
|
|
}
|
|
|
|
return tab.id;
|
|
}
|
|
|
|
function formatRemoteObject(obj) {
|
|
try {
|
|
if (obj === null || obj === undefined) {
|
|
return null;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(obj, "value")) {
|
|
return obj.value;
|
|
}
|
|
if (obj.description !== undefined) {
|
|
return obj.description;
|
|
}
|
|
return obj.type || null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Helper function to detect if code has top-level return statements
|
|
function hasTopLevelReturn(code) {
|
|
// Quick check if 'return' keyword exists at all
|
|
if (!code.includes("return")) {
|
|
return false;
|
|
}
|
|
|
|
// Simple heuristic: if code starts with return or has return after newline/semicolon
|
|
// but NOT inside a function body
|
|
// This is a simplified check that covers most common cases
|
|
|
|
// First, check for some patterns that definitely DON'T need wrapping
|
|
// 1. Already wrapped in IIFE
|
|
if (/^\s*\(\s*function\s*\(/.test(code) || /^\s*\(\s*\(\s*\)/.test(code)) {
|
|
return false;
|
|
}
|
|
|
|
// 2. Is just an expression (no statements)
|
|
if (!code.includes(";") && !code.includes("\n") && !code.includes("return")) {
|
|
return false;
|
|
}
|
|
|
|
// Remove strings and comments for cleaner analysis
|
|
let cleanCode = code
|
|
// Remove single-line comments
|
|
.replace(/\/\/.*$/gm, "")
|
|
// Remove multi-line comments
|
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
// Remove strings (simplified - doesn't handle escaped quotes)
|
|
.replace(/"[^"]*"/g, '""')
|
|
.replace(/'[^']*'/g, "''")
|
|
.replace(/`[^`]*`/g, "``");
|
|
|
|
// Look for return statements that are likely at the top level
|
|
// Common patterns:
|
|
// 1. return at start of code (with optional whitespace)
|
|
// 2. return after a semicolon or closing brace
|
|
// 3. return on a new line
|
|
const returnPatterns = [
|
|
/^\s*return\s+/, // starts with return
|
|
/;\s*return\s+/, // return after semicolon
|
|
/}\s*return\s+/, // return after closing brace (like after if block)
|
|
/\n\s*return\s+/, // return on new line
|
|
];
|
|
|
|
// Check if any of these patterns exist
|
|
const hasReturn = returnPatterns.some((pattern) => pattern.test(cleanCode));
|
|
|
|
if (!hasReturn) {
|
|
return false;
|
|
}
|
|
|
|
// Additional check: if it looks like it's inside a function, don't wrap
|
|
// Look for function keyword before the return
|
|
const beforeReturn = cleanCode.substring(0, cleanCode.indexOf("return"));
|
|
|
|
// Count unmatched opening braces before return
|
|
const openBraces = (beforeReturn.match(/{/g) || []).length;
|
|
const closeBraces = (beforeReturn.match(/}/g) || []).length;
|
|
|
|
// If we have unclosed braces and a function declaration, the return is likely inside it
|
|
if (openBraces > closeBraces && /function\s*\(|=>\s*{/.test(beforeReturn)) {
|
|
return false;
|
|
}
|
|
|
|
// Likely has top-level return
|
|
return true;
|
|
}
|
|
|
|
// Helper function to detect async IIFE pattern
|
|
function isAsyncIIFE(code) {
|
|
// Extract the last statement from the code (after any var declarations)
|
|
const trimmed = code.trim();
|
|
|
|
// Find the last complete statement by looking for the last IIFE pattern
|
|
// This handles cases where env vars are injected before the IIFE
|
|
const asyncIIFEPatterns = [
|
|
/\(\s*async\s+function\s*\([^)]*\)\s*{[\s\S]*?}\s*\)\s*\(\s*\)\s*;?\s*$/,
|
|
/\(\s*async\s*\([^)]*\)\s*=>\s*{[\s\S]*?}\s*\)\s*\(\s*\)\s*;?\s*$/,
|
|
/\(\s*async\s+function\s+\w+\s*\([^)]*\)\s*{[\s\S]*?}\s*\)\s*\(\s*\)\s*;?\s*$/,
|
|
];
|
|
|
|
return asyncIIFEPatterns.some((pattern) => pattern.test(trimmed));
|
|
}
|
|
|
|
// Helper function to detect regular (non-async) IIFE pattern
|
|
function isRegularIIFE(code) {
|
|
// Extract the last statement from the code (after any var declarations)
|
|
const trimmed = code.trim();
|
|
|
|
// Find the last complete statement by looking for the last IIFE pattern
|
|
// This handles cases where env vars are injected before the IIFE
|
|
const iifePatterns = [
|
|
/\(\s*function\s*\([^)]*\)\s*{[\s\S]*?}\s*\)\s*\(\s*\)\s*;?\s*$/,
|
|
/\(\s*function\s+\w+\s*\([^)]*\)\s*{[\s\S]*?}\s*\)\s*\(\s*\)\s*;?\s*$/,
|
|
/\(\s*\([^)]*\)\s*=>\s*{[\s\S]*?}\s*\)\s*\(\s*\)\s*;?\s*$/,
|
|
];
|
|
|
|
return iifePatterns.some((pattern) => pattern.test(trimmed));
|
|
}
|
|
|
|
// Helper function to wrap code in IIFE if it has top-level returns
|
|
function wrapCodeIfNeeded(code) {
|
|
// Check if it's an async IIFE - if so, return as-is since it's already wrapped
|
|
if (isAsyncIIFE(code)) {
|
|
log(
|
|
"Detected async IIFE pattern, using as-is (will set awaitPromise=true)",
|
|
);
|
|
return code;
|
|
}
|
|
|
|
// Check if it's a regular IIFE - if so, return as-is since it's already wrapped
|
|
if (isRegularIIFE(code)) {
|
|
log("Detected regular IIFE pattern, using as-is");
|
|
return code;
|
|
}
|
|
|
|
if (hasTopLevelReturn(code)) {
|
|
log("Detected top-level return statement, wrapping in IIFE");
|
|
return `(function() {\n${code}\n})()`;
|
|
}
|
|
|
|
// For code without top-level returns, wrap in eval to capture last expression
|
|
// This creates a clean scope while still returning the last expression
|
|
log("Wrapping in eval to capture last expression and create clean scope");
|
|
return `(function() { return eval(${JSON.stringify(code)}); })()`;
|
|
}
|
|
|
|
async function evalInTab(tabId, code, awaitPromise, evalId) {
|
|
/*
|
|
* executes only for iframes by bypassing the CORS issue
|
|
* so this implementatiton is something
|
|
* first we'll get every single frames from the tab
|
|
* then parse an special const name `IFRAMESELCTOR` from raw js code to
|
|
get the selector of iframe from main document
|
|
* after that we'll get the `frameId` of the iframe to execute
|
|
raw js code inside the iframe's document to avoid CORS issue
|
|
* rewrite the raw js code so it removes the unneccesary `IFRAMESELCTOR` from eval code
|
|
* run user code safely via eval inside an isolated function scope
|
|
*/
|
|
if (code.includes("IFRAMESELCTOR")) {
|
|
const frames = await chrome.webNavigation.getAllFrames({ tabId });
|
|
log("Frames seen by extension:", frames);
|
|
|
|
const regex =
|
|
/const\s+IFRAMESELCTOR\s*=\s*['"`]?\s*(querySelector|getElementById)\s*\(\s*(['"`])(.*?)\2\s*\)\s*;?\s*['"`]?/s;
|
|
const match = code.match(regex);
|
|
const iframeInfo = {
|
|
method: match[1],
|
|
selector: match[3],
|
|
};
|
|
if (!match) {
|
|
throw new Error(`No selector named 'IFRAMESELCTOR' have been defined please define
|
|
this variable to execute code inside in iframe's document context`);
|
|
}
|
|
|
|
if (iframeInfo) {
|
|
const iframeSelector = iframeInfo.selector;
|
|
if (iframeSelector) {
|
|
log(
|
|
`Executing in given iframe of selector: ${iframeSelector}, selector type: ${iframeInfo.method}`,
|
|
);
|
|
if (!attachedTabs.has(tabId)) {
|
|
await debuggerAttach(tabId);
|
|
attachedTabs.add(tabId);
|
|
await chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
log(`Debugger attached to tab '${tabId}' for iframe evaluation`);
|
|
}
|
|
|
|
let frameId;
|
|
try {
|
|
await sendCommand(tabId, "Page.enable", {});
|
|
await sendCommand(tabId, "DOM.enable", {});
|
|
const { root } = await sendCommand(tabId, "DOM.getDocument", {
|
|
depth: -1,
|
|
});
|
|
const { nodeId } = await sendCommand(tabId, "DOM.querySelector", {
|
|
nodeId: root.nodeId,
|
|
selector: iframeSelector,
|
|
});
|
|
|
|
if (!nodeId) {
|
|
throw new Error(
|
|
`Iframe with selector "${iframeSelector}" not found.`,
|
|
);
|
|
}
|
|
const { node } = await sendCommand(tabId, "DOM.describeNode", {
|
|
nodeId,
|
|
});
|
|
if (!node) {
|
|
throw new Error(
|
|
`Could not retrieve node info for the specified iframe, selector ${iframeInfo.selector}`,
|
|
);
|
|
}
|
|
|
|
/* now here we've to get the actual correct `frameId` from by matching
|
|
the `src` attributes of iframe to the all the existing iframe on that opened tab
|
|
*/
|
|
const iframeSrc =
|
|
node.attributes?.[node.attributes.indexOf("src") + 1];
|
|
log("iframeSrc", iframeSrc);
|
|
const match = frames.find((f) => {
|
|
try {
|
|
const u1 = new URL(f.url);
|
|
const u2 = new URL(iframeSrc);
|
|
const host1 = u1.hostname.replace(/^www\./, "").toLowerCase();
|
|
const host2 = u2.hostname.replace(/^www\./, "").toLowerCase();
|
|
if (host1 !== host2) {
|
|
return false;
|
|
}
|
|
const p1 = u1.pathname.split("/").filter(Boolean);
|
|
const p2 = u2.pathname.split("/").filter(Boolean);
|
|
for (let i = 0; i < Math.min(3, p1.length, p2.length); i++) {
|
|
if (p1[i] !== p2[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
frameId = match.frameId;
|
|
log(`matched frameId: ${match.frameId}`);
|
|
} catch (err) {
|
|
log("Error finding frame:", err);
|
|
try {
|
|
await debuggerDetach(tabId);
|
|
} catch (_) {}
|
|
attachedTabs.delete(tabId);
|
|
throw err;
|
|
}
|
|
|
|
const lines = code
|
|
.split("\n")
|
|
.filter((line) => !line.includes("IFRAMESELCTOR"));
|
|
const rewritten = lines.join("\n");
|
|
log(`Rewritten code for iframe: ${rewritten}`);
|
|
|
|
/* execute the rewritten code inside the found frame */
|
|
try {
|
|
const results = await chrome.scripting.executeScript({
|
|
target: { tabId: tabId, frameIds: [frameId] },
|
|
world: "MAIN",
|
|
func: (userScript, shouldAwait) => {
|
|
const runUserScript = () => (0, eval)(userScript);
|
|
const result = runUserScript();
|
|
if (shouldAwait && result instanceof Promise) {
|
|
return result;
|
|
}
|
|
return result;
|
|
},
|
|
args: [rewritten, Boolean(awaitPromise)],
|
|
});
|
|
|
|
if (!results || results.length === 0) {
|
|
throw new Error("Script execution in frame produced no result.");
|
|
}
|
|
return results[0].result;
|
|
} catch (err) {
|
|
log(`Error executing script in frame ${frameId}:`, err);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const perfStart = performance.now();
|
|
const timings = {};
|
|
|
|
const originalCode = code;
|
|
let shouldAwaitPromise = awaitPromise;
|
|
if (isAsyncIIFE(originalCode)) {
|
|
log("Detected async IIFE, forcing awaitPromise=true");
|
|
shouldAwaitPromise = true;
|
|
}
|
|
|
|
// Auto-detect and wrap code with top-level returns
|
|
const processedCode = wrapCodeIfNeeded(originalCode);
|
|
|
|
// Only attach if we haven't before
|
|
if (!attachedTabs.has(tabId)) {
|
|
const attachStart = performance.now();
|
|
try {
|
|
await debuggerAttach(tabId);
|
|
attachedTabs.add(tabId);
|
|
// Persist (fire and forget)
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
timings.attach = performance.now() - attachStart;
|
|
log(`Debugger attached to tab ${tabId} (${timings.attach.toFixed(1)}ms)`);
|
|
} catch (e) {
|
|
// If already attached by another concurrent operation, treat as attached
|
|
if (e.message && e.message.includes("already attached")) {
|
|
log(
|
|
`Tab ${tabId} already attached by another operation, treating as success`,
|
|
);
|
|
attachedTabs.add(tabId);
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
timings.attach = performance.now() - attachStart;
|
|
} else {
|
|
// If we can't attach, we can't continue - throw the error
|
|
log(`Could not attach to tab ${tabId}:`, e.message);
|
|
throw new Error(`Failed to attach debugger: ${e.message}`);
|
|
}
|
|
}
|
|
} else {
|
|
// Verify the debugger is actually still attached by checking Chrome's debugger API
|
|
// We need to check if the debugger is attached BEFORE trying any commands
|
|
try {
|
|
// First, check if debugger is still attached using Chrome's getTargets API
|
|
// If this fails, we know the debugger is detached
|
|
const targets = await new Promise((resolve, reject) => {
|
|
chrome.debugger.getTargets((targets) => {
|
|
if (chrome.runtime.lastError) {
|
|
reject(chrome.runtime.lastError);
|
|
} else {
|
|
resolve(targets);
|
|
}
|
|
});
|
|
});
|
|
|
|
const isAttached = targets.some(
|
|
(target) => target.tabId === tabId && target.attached,
|
|
);
|
|
|
|
if (!isAttached) {
|
|
// Debugger was detached (e.g., by navigation or user canceling)
|
|
log(
|
|
`Debugger was detached from tab ${tabId} (detected via getTargets), reattaching...`,
|
|
);
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId);
|
|
|
|
const attachStart = performance.now();
|
|
try {
|
|
await debuggerAttach(tabId);
|
|
attachedTabs.add(tabId);
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
timings.attach = performance.now() - attachStart;
|
|
log(
|
|
`Debugger reattached to tab ${tabId} (${timings.attach.toFixed(1)}ms)`,
|
|
);
|
|
} catch (attachError) {
|
|
// If already attached by another concurrent operation, treat as attached
|
|
if (
|
|
attachError.message &&
|
|
attachError.message.includes("already attached")
|
|
) {
|
|
log(
|
|
`Tab ${tabId} already attached by another operation, treating as success`,
|
|
);
|
|
attachedTabs.add(tabId);
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
timings.attach = performance.now() - attachStart;
|
|
} else {
|
|
log(`Failed to reattach to tab ${tabId}:`, attachError.message);
|
|
throw new Error(
|
|
`Failed to reattach debugger: ${attachError.message}`,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Debugger is still attached, verify with a simple command
|
|
try {
|
|
await sendCommand(tabId, "Runtime.evaluate", {
|
|
expression: "1",
|
|
returnByValue: true,
|
|
});
|
|
log(`Reusing existing debugger for tab ${tabId}`);
|
|
timings.attach = 0;
|
|
} catch (cmdError) {
|
|
// Command failed even though debugger appeared attached
|
|
// This can happen if domains need to be re-enabled
|
|
log(
|
|
`Command failed for tab ${tabId}, may need domain re-enable: ${cmdError.message}`,
|
|
);
|
|
// Mark domains as needing re-enable (handled in next section)
|
|
enabledTabs.delete(tabId);
|
|
timings.attach = 0;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// getTargets failed, fall back to trying a command
|
|
log(
|
|
`getTargets check failed for tab ${tabId}, trying command: ${e.message}`,
|
|
);
|
|
try {
|
|
await sendCommand(tabId, "Runtime.evaluate", {
|
|
expression: "1",
|
|
returnByValue: true,
|
|
});
|
|
log(`Reusing existing debugger for tab ${tabId} (fallback path)`);
|
|
timings.attach = 0;
|
|
} catch (cmdError) {
|
|
// Debugger was likely detached, need to reattach
|
|
log(`Debugger was detached from tab ${tabId}, reattaching...`);
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId);
|
|
|
|
const attachStart = performance.now();
|
|
try {
|
|
await debuggerAttach(tabId);
|
|
attachedTabs.add(tabId);
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
timings.attach = performance.now() - attachStart;
|
|
log(
|
|
`Debugger reattached to tab ${tabId} (${timings.attach.toFixed(1)}ms)`,
|
|
);
|
|
} catch (attachError) {
|
|
// If already attached by another concurrent operation, treat as attached
|
|
if (
|
|
attachError.message &&
|
|
attachError.message.includes("already attached")
|
|
) {
|
|
log(
|
|
`Tab ${tabId} already attached by another operation, treating as success`,
|
|
);
|
|
attachedTabs.add(tabId);
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
timings.attach = performance.now() - attachStart;
|
|
} else {
|
|
log(`Failed to reattach to tab ${tabId}:`, attachError.message);
|
|
throw new Error(
|
|
`Failed to reattach debugger: ${attachError.message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let onEvent = null;
|
|
try {
|
|
// Only enable domains if not already enabled
|
|
if (!enabledTabs.has(tabId)) {
|
|
const enableStart = performance.now();
|
|
try {
|
|
const runtimeStart = performance.now();
|
|
await sendCommand(tabId, "Runtime.enable", {});
|
|
timings.runtimeEnable = performance.now() - runtimeStart;
|
|
|
|
const logStart = performance.now();
|
|
await sendCommand(tabId, "Log.enable", {});
|
|
timings.logEnable = performance.now() - logStart;
|
|
|
|
enabledTabs.add(tabId);
|
|
timings.totalEnable = performance.now() - enableStart;
|
|
log(
|
|
`Domains enabled for tab ${tabId} - Runtime: ${timings.runtimeEnable.toFixed(1)}ms, Log: ${timings.logEnable.toFixed(1)}ms, Total: ${timings.totalEnable.toFixed(1)}ms`,
|
|
);
|
|
} catch (e) {
|
|
const errorMsg = e.message || String(e);
|
|
// Check if error is due to debugger being detached
|
|
if (errorMsg.includes("Debugger is not attached")) {
|
|
log(
|
|
`Debugger was detached from tab ${tabId} during domain enable, reattaching...`,
|
|
);
|
|
// Clear state and reattach
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId);
|
|
|
|
try {
|
|
// Reattach the debugger
|
|
await debuggerAttach(tabId);
|
|
attachedTabs.add(tabId);
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
log(`Successfully reattached debugger to tab ${tabId}`);
|
|
|
|
// Now try to enable domains again
|
|
const runtimeStart = performance.now();
|
|
await sendCommand(tabId, "Runtime.enable", {});
|
|
timings.runtimeEnable = performance.now() - runtimeStart;
|
|
|
|
const logStart = performance.now();
|
|
await sendCommand(tabId, "Log.enable", {});
|
|
timings.logEnable = performance.now() - logStart;
|
|
|
|
enabledTabs.add(tabId);
|
|
timings.totalEnable = performance.now() - enableStart;
|
|
log(
|
|
`Domains enabled after reattach for tab ${tabId} - Runtime: ${timings.runtimeEnable.toFixed(1)}ms, Log: ${timings.logEnable.toFixed(1)}ms, Total: ${timings.totalEnable.toFixed(1)}ms`,
|
|
);
|
|
} catch (reattachError) {
|
|
log(
|
|
`Failed to reattach and enable domains for tab ${tabId}:`,
|
|
reattachError.message,
|
|
);
|
|
// Clean up state
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId);
|
|
try {
|
|
await debuggerDetach(tabId);
|
|
} catch (_) {}
|
|
throw new Error(
|
|
`Failed to reattach debugger and enable domains: ${reattachError.message}`,
|
|
);
|
|
}
|
|
} else {
|
|
// Different error, not related to detachment
|
|
log(`Could not enable domains for tab ${tabId}:`, e.message);
|
|
// Clear the tab from tracking since it's in a bad state
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId);
|
|
// Try to detach and clean up
|
|
try {
|
|
await debuggerDetach(tabId);
|
|
} catch (_) {}
|
|
// Throw the error to trigger retry logic at the higher level
|
|
throw new Error(`Failed to enable debugger domains: ${e.message}`);
|
|
}
|
|
}
|
|
} else {
|
|
// Verify domains are actually still enabled by testing a simple command
|
|
try {
|
|
// Quick test to verify Runtime domain is still active
|
|
await sendCommand(tabId, "Runtime.evaluate", {
|
|
expression: "1",
|
|
returnByValue: true,
|
|
});
|
|
log(`Reusing enabled domains for tab ${tabId}`);
|
|
timings.runtimeEnable = 0;
|
|
timings.logEnable = 0;
|
|
timings.totalEnable = 0;
|
|
} catch (e) {
|
|
// Domains were disabled (debugger detached), need to re-enable
|
|
log(`Domains were disabled for tab ${tabId}, re-enabling...`);
|
|
enabledTabs.delete(tabId);
|
|
|
|
const enableStart = performance.now();
|
|
try {
|
|
const runtimeStart = performance.now();
|
|
await sendCommand(tabId, "Runtime.enable", {});
|
|
timings.runtimeEnable = performance.now() - runtimeStart;
|
|
|
|
const logStart = performance.now();
|
|
await sendCommand(tabId, "Log.enable", {});
|
|
timings.logEnable = performance.now() - logStart;
|
|
|
|
enabledTabs.add(tabId);
|
|
timings.totalEnable = performance.now() - enableStart;
|
|
log(
|
|
`Domains re-enabled for tab ${tabId} - Runtime: ${timings.runtimeEnable.toFixed(1)}ms, Log: ${timings.logEnable.toFixed(1)}ms, Total: ${timings.totalEnable.toFixed(1)}ms`,
|
|
);
|
|
} catch (enableError) {
|
|
// Check if error is due to debugger being detached
|
|
const errorMsg = enableError.message || String(enableError);
|
|
if (errorMsg.includes("Debugger is not attached")) {
|
|
log(
|
|
`Debugger was detached from tab ${tabId}, need to reattach before enabling domains`,
|
|
);
|
|
// Clear state and reattach
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId);
|
|
|
|
try {
|
|
// Reattach the debugger
|
|
await debuggerAttach(tabId);
|
|
attachedTabs.add(tabId);
|
|
chrome.storage.session.set({ attached: [...attachedTabs] });
|
|
log(`Successfully reattached debugger to tab ${tabId}`);
|
|
|
|
// Now try to enable domains again
|
|
const runtimeStart = performance.now();
|
|
await sendCommand(tabId, "Runtime.enable", {});
|
|
timings.runtimeEnable = performance.now() - runtimeStart;
|
|
|
|
const logStart = performance.now();
|
|
await sendCommand(tabId, "Log.enable", {});
|
|
timings.logEnable = performance.now() - logStart;
|
|
|
|
enabledTabs.add(tabId);
|
|
timings.totalEnable = performance.now() - enableStart;
|
|
log(
|
|
`Domains enabled after reattach for tab ${tabId} - Runtime: ${timings.runtimeEnable.toFixed(1)}ms, Log: ${timings.logEnable.toFixed(1)}ms, Total: ${timings.totalEnable.toFixed(1)}ms`,
|
|
);
|
|
} catch (reattachError) {
|
|
log(
|
|
`Failed to reattach and enable domains for tab ${tabId}:`,
|
|
reattachError.message,
|
|
);
|
|
// Clean up state
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId);
|
|
try {
|
|
await debuggerDetach(tabId);
|
|
} catch (_) {}
|
|
throw new Error(
|
|
`Failed to reattach debugger and enable domains: ${reattachError.message}`,
|
|
);
|
|
}
|
|
} else {
|
|
// Different error, not related to detachment
|
|
log(
|
|
`Could not re-enable domains for tab ${tabId}:`,
|
|
enableError.message,
|
|
);
|
|
// Clear the tab from tracking since it's in a bad state
|
|
attachedTabs.delete(tabId);
|
|
enabledTabs.delete(tabId);
|
|
// Try to detach and clean up
|
|
try {
|
|
await debuggerDetach(tabId);
|
|
} catch (_) {}
|
|
// Throw the error to trigger retry logic at the higher level
|
|
throw new Error(
|
|
`Failed to re-enable debugger domains: ${enableError.message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Listen for console/log/exception events for this tab while the eval runs
|
|
onEvent = (source, method, params) => {
|
|
try {
|
|
if (!source || source.tabId !== tabId) {
|
|
return;
|
|
}
|
|
if (method === "Runtime.consoleAPICalled") {
|
|
const level = params.type || "log";
|
|
const args = (params.args || []).map((a) => formatRemoteObject(a));
|
|
const stackTrace = params.stackTrace || null;
|
|
safeSend({
|
|
type: "console_event",
|
|
id: evalId,
|
|
level,
|
|
args,
|
|
stackTrace,
|
|
ts: params.timestamp || Date.now(),
|
|
});
|
|
} else if (method === "Runtime.exceptionThrown") {
|
|
safeSend({
|
|
type: "exception_event",
|
|
id: evalId,
|
|
details: params.exceptionDetails || params || null,
|
|
});
|
|
} else if (method === "Log.entryAdded") {
|
|
safeSend({
|
|
type: "log_event",
|
|
id: evalId,
|
|
entry: params.entry || params || null,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// swallow
|
|
}
|
|
};
|
|
chrome.debugger.onEvent.addListener(onEvent);
|
|
|
|
const evalStart = performance.now();
|
|
let evalResult;
|
|
let retryWithWrapper = false;
|
|
|
|
try {
|
|
evalResult = await sendCommand(tabId, "Runtime.evaluate", {
|
|
expression: processedCode,
|
|
awaitPromise: Boolean(shouldAwaitPromise),
|
|
returnByValue: true,
|
|
userGesture: true,
|
|
});
|
|
} catch (error) {
|
|
// If sendCommand itself fails (not script error), re-throw
|
|
throw error;
|
|
}
|
|
|
|
let { result, exceptionDetails } = evalResult;
|
|
timings.evaluate = performance.now() - evalStart;
|
|
|
|
// Check if we got "Illegal return statement" error and haven't wrapped yet
|
|
if (
|
|
exceptionDetails &&
|
|
processedCode !== originalCode && // Already wrapped, don't retry
|
|
(exceptionDetails.text?.includes("Illegal return statement") ||
|
|
exceptionDetails.exception?.description?.includes(
|
|
"Illegal return statement",
|
|
))
|
|
) {
|
|
// This shouldn't happen since we already wrapped, but log it
|
|
log("Still got 'Illegal return statement' after wrapping, not retrying");
|
|
} else if (
|
|
exceptionDetails &&
|
|
processedCode === originalCode && // Not wrapped yet
|
|
(exceptionDetails.text?.includes("Illegal return statement") ||
|
|
exceptionDetails.exception?.description?.includes(
|
|
"Illegal return statement",
|
|
))
|
|
) {
|
|
// Our detection missed it, retry with wrapper
|
|
log("Got 'Illegal return statement' error, retrying with IIFE wrapper");
|
|
retryWithWrapper = true;
|
|
}
|
|
|
|
// Retry with wrapper if needed
|
|
if (retryWithWrapper) {
|
|
const wrappedCode = `(function() {\n${originalCode}\n})()`;
|
|
const retryStart = performance.now();
|
|
|
|
evalResult = await sendCommand(tabId, "Runtime.evaluate", {
|
|
expression: wrappedCode,
|
|
awaitPromise: Boolean(shouldAwaitPromise),
|
|
returnByValue: true,
|
|
userGesture: true,
|
|
});
|
|
|
|
({ result, exceptionDetails } = evalResult);
|
|
timings.evaluateRetry = performance.now() - retryStart;
|
|
timings.evaluate += timings.evaluateRetry;
|
|
log(`Retry with wrapper took ${timings.evaluateRetry.toFixed(1)}ms`);
|
|
}
|
|
|
|
if (exceptionDetails) {
|
|
// Build rich error details for MCP side
|
|
const details = {
|
|
text: exceptionDetails.text,
|
|
url: exceptionDetails.url,
|
|
lineNumber: exceptionDetails.lineNumber,
|
|
columnNumber: exceptionDetails.columnNumber,
|
|
exception:
|
|
(exceptionDetails.exception &&
|
|
(exceptionDetails.exception.description ||
|
|
exceptionDetails.exception.value)) ||
|
|
null,
|
|
stackTrace:
|
|
(exceptionDetails.stackTrace &&
|
|
Array.isArray(exceptionDetails.stackTrace.callFrames) &&
|
|
exceptionDetails.stackTrace.callFrames.map((cf) => ({
|
|
functionName: cf.functionName,
|
|
url: cf.url,
|
|
lineNumber: cf.lineNumber,
|
|
columnNumber: cf.columnNumber,
|
|
}))) ||
|
|
null,
|
|
};
|
|
// Emit console error for visibility in extension worker logs
|
|
console.error("[TerminatorBridge] Eval exception:", details);
|
|
// Throw a JSON-encoded error so the bridge returns full context
|
|
throw new Error(
|
|
JSON.stringify({
|
|
code: "EVAL_ERROR",
|
|
message: details.text || "Evaluation error",
|
|
details,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Log timing summary
|
|
timings.total = performance.now() - perfStart;
|
|
log(
|
|
`[TIMING] Total: ${timings.total.toFixed(1)}ms | Attach: ${timings.attach.toFixed(1)}ms | Enable: ${timings.totalEnable.toFixed(1)}ms | Eval: ${timings.evaluate.toFixed(1)}ms`,
|
|
);
|
|
|
|
// Return JSON-serializable value
|
|
// Check if result is null or undefined - these should be treated as errors
|
|
const resultValue = result?.value;
|
|
if (resultValue === null || resultValue === undefined) {
|
|
// Throw an error to trigger workflow fallback_id behavior
|
|
throw new Error(
|
|
JSON.stringify({
|
|
code: "NULL_RESULT",
|
|
message: "JavaScript execution returned null or undefined",
|
|
details: {
|
|
text: "Script returned null/undefined value",
|
|
resultType: resultValue === null ? "null" : "undefined",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
return resultValue;
|
|
} finally {
|
|
try {
|
|
// Best-effort: remove onEvent listener
|
|
if (
|
|
onEvent &&
|
|
chrome &&
|
|
chrome.debugger &&
|
|
chrome.debugger.onEvent &&
|
|
chrome.debugger.onEvent.removeListener
|
|
) {
|
|
chrome.debugger.onEvent.removeListener(onEvent);
|
|
}
|
|
// DON'T DISABLE DOMAINS - Keep them enabled for reuse
|
|
// Removed: await sendCommand(tabId, "Log.disable", {});
|
|
// Removed: await sendCommand(tabId, "Runtime.disable", {});
|
|
} catch (_) {}
|
|
// DON'T DETACH - Keep debugger attached for reuse
|
|
// This was: await debuggerDetach(tabId);
|
|
}
|
|
}
|
|
|
|
function debuggerAttach(tabId) {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.debugger.attach({ tabId }, "1.3", (err) => {
|
|
if (chrome.runtime.lastError) {
|
|
return reject(chrome.runtime.lastError);
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function debuggerDetach(tabId) {
|
|
return new Promise((resolve) => {
|
|
chrome.debugger.detach({ tabId }, () => resolve());
|
|
});
|
|
}
|
|
|
|
function sendCommand(tabId, method, params) {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.debugger.sendCommand({ tabId }, method, params, (result) => {
|
|
const err = chrome.runtime.lastError;
|
|
if (err) {
|
|
return reject(err);
|
|
}
|
|
resolve(result || {});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Recording session management
|
|
let recordingSessionId = null;
|
|
|
|
async function startRecordingSession(sessionId) {
|
|
recordingSessionId = sessionId;
|
|
log(`Started recording session: ${sessionId}`);
|
|
// Could inject content scripts here if needed
|
|
return { sessionId, status: "recording" };
|
|
}
|
|
|
|
async function stopRecordingSession(sessionId) {
|
|
if (recordingSessionId === sessionId) {
|
|
recordingSessionId = null;
|
|
log(`Stopped recording session: ${sessionId}`);
|
|
}
|
|
return { sessionId, status: "stopped" };
|
|
}
|
|
|
|
// DOM element capture for recording
|
|
async function captureElementAtPoint(tabId, x, y, captureId) {
|
|
const code = `(function() {
|
|
const x = ${x};
|
|
const y = ${y};
|
|
|
|
// Get element at coordinates
|
|
const element = document.elementFromPoint(x, y);
|
|
if (!element) {
|
|
return { error: 'No element at coordinates', x: x, y: y };
|
|
}
|
|
|
|
// Generate selector candidates
|
|
function generateSelectors(el) {
|
|
const selectors = [];
|
|
|
|
// 1. ID selector (highest priority)
|
|
if (el.id) {
|
|
selectors.push({
|
|
selector: '#' + CSS.escape(el.id),
|
|
selector_type: 'Id',
|
|
specificity: 100,
|
|
requires_jquery: false
|
|
});
|
|
}
|
|
|
|
// 2. Data attributes
|
|
const dataAttrs = Array.from(el.attributes)
|
|
.filter(attr => attr.name.startsWith('data-'))
|
|
.map(attr => ({
|
|
selector: '[' + attr.name + '="' + CSS.escape(attr.value) + '"]',
|
|
selector_type: 'DataAttribute',
|
|
specificity: 90,
|
|
requires_jquery: false
|
|
}));
|
|
selectors.push(...dataAttrs);
|
|
|
|
// 3. Aria label
|
|
if (el.getAttribute('aria-label')) {
|
|
selectors.push({
|
|
selector: '[aria-label="' + CSS.escape(el.getAttribute('aria-label')) + '"]',
|
|
selector_type: 'AriaLabel',
|
|
specificity: 85,
|
|
requires_jquery: false
|
|
});
|
|
}
|
|
|
|
// 4. Class combinations
|
|
if (el.className && typeof el.className === 'string') {
|
|
const classes = el.className.split(' ').filter(c => c);
|
|
if (classes.length > 0) {
|
|
selectors.push({
|
|
selector: '.' + classes.map(c => CSS.escape(c)).join('.'),
|
|
selector_type: 'Class',
|
|
specificity: 70,
|
|
requires_jquery: false
|
|
});
|
|
}
|
|
}
|
|
|
|
// 5. Text content for buttons/links
|
|
if (['button', 'a'].includes(el.tagName.toLowerCase())) {
|
|
const text = el.textContent.trim();
|
|
if (text && text.length < 50) {
|
|
selectors.push({
|
|
selector: el.tagName.toLowerCase() + ':contains("' + text + '")',
|
|
selector_type: 'Text',
|
|
specificity: 60,
|
|
requires_jquery: true
|
|
});
|
|
}
|
|
}
|
|
|
|
// 6. Generate XPath
|
|
function getXPath(element) {
|
|
if (element.id) {
|
|
return '//*[@id="' + element.id + '"]';
|
|
}
|
|
|
|
const parts = [];
|
|
while (element && element.nodeType === Node.ELEMENT_NODE) {
|
|
let index = 1;
|
|
let sibling = element.previousElementSibling;
|
|
while (sibling) {
|
|
if (sibling.tagName === element.tagName) index++;
|
|
sibling = sibling.previousElementSibling;
|
|
}
|
|
const tagName = element.tagName.toLowerCase();
|
|
const part = tagName + '[' + index + ']';
|
|
parts.unshift(part);
|
|
element = element.parentElement;
|
|
}
|
|
return '/' + parts.join('/');
|
|
}
|
|
|
|
selectors.push({
|
|
selector: getXPath(el),
|
|
selector_type: 'XPath',
|
|
specificity: 40,
|
|
requires_jquery: false
|
|
});
|
|
|
|
// 7. CSS path (most specific, least maintainable)
|
|
function getCSSPath(el) {
|
|
const path = [];
|
|
while (el && el.nodeType === Node.ELEMENT_NODE) {
|
|
let selector = el.tagName.toLowerCase();
|
|
if (el.id) {
|
|
selector = '#' + CSS.escape(el.id);
|
|
path.unshift(selector);
|
|
break;
|
|
} else if (el.className && typeof el.className === 'string') {
|
|
const classes = el.className.split(' ').filter(c => c);
|
|
if (classes.length > 0) {
|
|
selector += '.' + classes.map(c => CSS.escape(c)).join('.');
|
|
}
|
|
}
|
|
path.unshift(selector);
|
|
el = el.parentElement;
|
|
}
|
|
return path.join(' > ');
|
|
}
|
|
|
|
selectors.push({
|
|
selector: getCSSPath(el),
|
|
selector_type: 'CssPath',
|
|
specificity: 30,
|
|
requires_jquery: false
|
|
});
|
|
|
|
return selectors;
|
|
}
|
|
|
|
// Capture element information
|
|
const rect = element.getBoundingClientRect();
|
|
const computedStyle = window.getComputedStyle(element);
|
|
|
|
// Get all attributes as a map
|
|
const attributes = {};
|
|
for (const attr of element.attributes) {
|
|
attributes[attr.name] = attr.value;
|
|
}
|
|
|
|
// Get class names as array
|
|
const classNames = element.className
|
|
? (typeof element.className === 'string'
|
|
? element.className.split(' ').filter(c => c)
|
|
: [])
|
|
: [];
|
|
|
|
return {
|
|
tag_name: element.tagName.toLowerCase(),
|
|
id: element.id || null,
|
|
class_names: classNames,
|
|
attributes: attributes,
|
|
css_selector: getCSSPath(element),
|
|
xpath: getXPath(element),
|
|
inner_text: element.innerText ? element.innerText.substring(0, 100) : null,
|
|
input_value: element.value || null,
|
|
bounding_rect: {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
top: rect.top,
|
|
left: rect.left
|
|
},
|
|
is_visible: computedStyle.display !== 'none' &&
|
|
computedStyle.visibility !== 'hidden' &&
|
|
computedStyle.opacity !== '0',
|
|
is_interactive: !element.disabled &&
|
|
computedStyle.pointerEvents !== 'none',
|
|
computed_role: element.getAttribute('role') || null,
|
|
aria_label: element.getAttribute('aria-label') || null,
|
|
placeholder: element.placeholder || null,
|
|
selector_candidates: generateSelectors(element),
|
|
page_context: {
|
|
url: window.location.href,
|
|
title: document.title,
|
|
domain: window.location.hostname
|
|
},
|
|
capture_id: '${captureId}'
|
|
};
|
|
})()`;
|
|
|
|
try {
|
|
const result = await evalInTab(tabId, code, false, captureId);
|
|
log(`Captured DOM element at (${x}, ${y}):`, result);
|
|
return result;
|
|
} catch (err) {
|
|
log(`Failed to capture DOM element at (${x}, ${y}):`, err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform a trusted click at specific coordinates using Input.dispatchMouseEvent.
|
|
* This produces events with isTrusted=true, which MUI ClickAwayListener responds to.
|
|
*
|
|
* @param {number} tabId - Tab ID
|
|
* @param {number} x - X coordinate
|
|
* @param {number} y - Y coordinate
|
|
* @returns {Promise<object>} - Result of the click operation
|
|
*/
|
|
async function trustedClickAtPoint(tabId, x, y) {
|
|
log(`[TrustedClick] Clicking at (${x}, ${y}) on tab ${tabId}`);
|
|
|
|
// Ensure debugger is attached (reuse existing attachment if possible)
|
|
if (!attachedTabs.has(tabId)) {
|
|
try {
|
|
await debuggerAttach(tabId);
|
|
attachedTabs.add(tabId);
|
|
log(`[TrustedClick] Attached debugger to tab ${tabId}`);
|
|
} catch (err) {
|
|
// Might already be attached
|
|
if (!err.message?.includes("already attached")) {
|
|
throw err;
|
|
}
|
|
attachedTabs.add(tabId);
|
|
log(`[TrustedClick] Debugger already attached to tab ${tabId}`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Send mousePressed event
|
|
await sendCommand(tabId, "Input.dispatchMouseEvent", {
|
|
type: "mousePressed",
|
|
x: Math.round(x),
|
|
y: Math.round(y),
|
|
button: "left",
|
|
clickCount: 1,
|
|
});
|
|
|
|
// Small delay between press and release
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
// Send mouseReleased event
|
|
await sendCommand(tabId, "Input.dispatchMouseEvent", {
|
|
type: "mouseReleased",
|
|
x: Math.round(x),
|
|
y: Math.round(y),
|
|
button: "left",
|
|
clickCount: 1,
|
|
});
|
|
|
|
log(`[TrustedClick] Click completed at (${x}, ${y})`);
|
|
return { success: true, x, y };
|
|
} catch (err) {
|
|
log(`[TrustedClick] Click failed at (${x}, ${y}):`, err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
connect();
|