const BRIDGE_URL = "http://127.0.0.1:17318"; const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`; const POLL_ERROR_BACKOFF_MS = 2000; // /next is a server-side long poll held for up to 25s (see waitForCommand in index.ts). A fetch that // outlives this deadline means the socket is half-open (the owning Pi process died, the machine // slept, the network changed) and will never settle; abort it so pollLoop backs off and reconnects // instead of parking on a zombie connection until the extension is reloaded by hand. const POLL_REQUEST_TIMEOUT_MS = 45_000; const RESULT_REQUEST_TIMEOUT_MS = 15_000; const RESULT_POST_ATTEMPTS = 3; const DEFAULT_GROUP_COLOR = "blue"; const PI_GROUP_RE = /^Pi(\b|\s*-)/i; const VALID_GROUP_COLORS = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); const COMMAND_TIMEOUT_MS = 25_000; const CDP_COMMAND_TIMEOUT_MS = 5_000; const SCRIPTING_TIMEOUT_MS = 8_000; const ATTACH_TIMEOUT_MS = 3_000; // Raw CDP methods that explicitly focus or activate a tab. Blocked by hard background mode. const CDP_FOCUS_METHODS = new Set(["Page.bringToFront", "Target.activateTarget"]); let polling = false; // =================== pi-chrome automation target ownership =================== // pi-chrome must never hijack the user's active tab. When a page/navigation action runs without // an explicit target (targetId/urlIncludes/titleIncludes), we route it to a dedicated automation // target that pi-chrome created and owns. We prefer a separate Chrome window so the user's // windows are left untouched; if the windows API is unavailable we fall back to a dedicated tab. // // Ownership is SESSION-SCOPED, keyed by the calling Pi session's `sessionKey` (forwarded on the // wire). One Chrome extension / service worker brokers commands for *all* Pi sessions (see the // client/server bridge in index.ts), so a single global target would make concurrent sessions // fight over one window. A per-session map gives each session its own isolated window and lets // cleanup close exactly that session's target — never another session's, never a user's. // // State is mirrored to chrome.storage.session so a service-worker restart (MV3 can suspend the // worker at any time) re-hydrates ownership instead of orphaning the window it already created. // storage.session is also cleared when the extension reloads, which it does itself on every // pi-chrome version bump, so ownership is mirrored to chrome.storage.local too (see // readOwnership). The local copy is restored only when the session copy is missing, every restored // tab is re-checked, and the local copy is dropped on browser startup because tab ids do not carry // over between browser runs. // storage.session is cleared on browser restart; any window restored by Chrome's session-restore // is then untracked and simply left alone (we only ever close ids we still recognize as ours). const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number } const DEFAULT_SESSION_KEY = "__default__"; const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets"; let automationHydrated; const sessionTabs = new Map(); // sessionKey -> Map const SESSION_TABS_STORAGE_KEY = "piChromeSessionTabs"; let sessionTabsReady; let sessionTabsWrite = Promise.resolve(); function sessionKeyOf(params) { return params && typeof params.sessionKey === "string" && params.sessionKey ? params.sessionKey : DEFAULT_SESSION_KEY; } // Read an ownership record: the storage.session copy if present, else the storage.local copy // (after an extension reload). Returns { value, restored } where restored marks a local copy. async function readOwnership(key) { const fromSession = await chrome.storage?.session?.get?.(key).catch(() => null); if (fromSession && fromSession[key] && typeof fromSession[key] === "object") return { value: fromSession[key], restored: false }; const fromLocal = await chrome.storage?.local?.get?.(key).catch(() => null); if (fromLocal && fromLocal[key] && typeof fromLocal[key] === "object") return { value: fromLocal[key], restored: true }; return { value: null, restored: false }; } async function writeOwnership(key, value) { await Promise.all([ Promise.resolve(chrome.storage?.session?.set?.({ [key]: value })).catch(() => {}), Promise.resolve(chrome.storage?.local?.set?.({ [key]: value })).catch(() => {}), ]); } // A restored (local) record is trusted only if its tab still exists in the window it was recorded // in. Anything else is forgotten, so a reused id or a tab the user moved is never closed by Pi. async function restoredTabStillOurs(tabId, windowId) { if (!Number.isInteger(tabId) || tabId < 0 || !Number.isInteger(windowId)) return null; const tab = await chrome.tabs.get(tabId).catch(() => null); return tab && tab.windowId === windowId ? tab : null; } async function tabWindowId(tabId) { const tab = Number.isInteger(tabId) ? await chrome.tabs.get(tabId).catch(() => null) : null; return Number.isInteger(tab?.windowId) ? tab.windowId : undefined; } // Re-hydrate the in-memory ownership map from storage.session once per worker lifetime. Best // effort: storage may be unavailable on old Chrome, and a failure just means we may create a // fresh window (a harmless orphan) rather than reusing one. async function hydrateAutomationTargets() { if (automationHydrated) return automationHydrated; automationHydrated = (async () => { try { const { value: saved, restored } = await readOwnership(AUTOMATION_STORAGE_KEY); if (saved && typeof saved === "object") { for (const [key, value] of Object.entries(saved)) { if (!value || typeof value.tabId !== "number") continue; const tabWindow = typeof value.tabWindowId === "number" ? value.tabWindowId : undefined; if (restored && !(await restoredTabStillOurs(value.tabId, tabWindow))) continue; automationTargets.set(key, { windowId: typeof value.windowId === "number" ? value.windowId : undefined, tabId: value.tabId, tabWindowId: tabWindow, }); } if (restored) await persistAutomationTargets(); } } catch { // Ignore: treat as "no persisted state". } })(); return automationHydrated; } async function hydrateSessionTabs() { if (!sessionTabsReady) sessionTabsReady = (async () => { try { const { value: saved, restored } = await readOwnership(SESSION_TABS_STORAGE_KEY); for (const [key, entries] of Object.entries(saved || {})) { if (!Array.isArray(entries)) continue; const tabs = new Map(); for (const entry of entries) { if (!entry || !Number.isInteger(entry.tabId) || entry.tabId < 0) continue; const windowId = Number.isInteger(entry.windowId) ? entry.windowId : undefined; let tab = null; if (restored) { tab = await restoredTabStillOurs(entry.tabId, windowId); if (!tab) continue; } if (entry.created === true) tabs.set(entry.tabId, { created: true, windowId }); else if (entry.created === false && Number.isInteger(entry.groupId) && entry.groupId >= 0) { // An adopted tab must also still be in the group Pi put it in. if (restored && tab.groupId !== entry.groupId) continue; tabs.set(entry.tabId, { created: false, groupId: entry.groupId, windowId }); } } if (tabs.size) sessionTabs.set(key, tabs); } if (restored) await persistSessionTabs(); } catch { // Missing ownership must leave tabs alone, not guess ownership from group names. } })(); return sessionTabsReady; } function persistSessionTabs() { // Serialize writes and construct each snapshot when its turn starts. sessionTabsWrite = sessionTabsWrite.then(async () => { const saved = Object.fromEntries([...sessionTabs].map(([key, tabs]) => [ key, [...tabs].map(([tabId, record]) => ({ tabId, ...record })), ])); await writeOwnership(SESSION_TABS_STORAGE_KEY, saved); }).catch(() => {}); return sessionTabsWrite; } async function trackSessionTab(sessionKey, tabId, created, groupId) { await Promise.all([hydrateSessionTabs(), hydrateAutomationTargets()]); if (!Number.isInteger(tabId)) return; if (!created) { if (!Number.isInteger(groupId) || groupId < 0 || isPiChromeOwnedTarget(tabId)) return; if ([...sessionTabs.values()].some((tabs) => tabs.get(tabId)?.created)) return; } let tabs = sessionTabs.get(sessionKey); if (!tabs) sessionTabs.set(sessionKey, tabs = new Map()); const windowId = await tabWindowId(tabId); tabs.set(tabId, created ? { created: true, windowId } : { created: false, groupId, windowId }); await persistSessionTabs(); } async function cleanupSessionTabs(sessionKey) { await hydrateSessionTabs(); const automation = await cleanupAutomationTarget(sessionKey); const tabs = sessionTabs.get(sessionKey); let closedCreatedTabs = 0; let ungroupedAdoptedTabs = 0; for (const [tabId, record] of [...(tabs || [])]) { try { const tab = await chrome.tabs.get(tabId).catch(() => null); if (tab && record.created) { await chrome.tabs.remove(tabId); closedCreatedTabs++; } else if (tab && tab.groupId === record.groupId) { await chrome.tabs.ungroup(tabId); ungroupedAdoptedTabs++; } tabs.delete(tabId); } catch { // Retain failed operations for a later cleanup; never report them as completed. } } if (tabs && !tabs.size) sessionTabs.delete(sessionKey); await persistSessionTabs(); return { ...automation, closedCreatedTabs, ungroupedAdoptedTabs }; } async function persistAutomationTargets() { try { const obj = {}; for (const [key, value] of automationTargets) { if (typeof value.tabWindowId !== "number") value.tabWindowId = await tabWindowId(value.tabId); obj[key] = { windowId: typeof value.windowId === "number" ? value.windowId : null, tabId: value.tabId, tabWindowId: typeof value.tabWindowId === "number" ? value.tabWindowId : null, }; } await writeOwnership(AUTOMATION_STORAGE_KEY, obj); } catch { // Ignore: persistence is an optimization, not a correctness requirement. } } // True if `tabId` is a pi-chrome-owned automation tab. Pass `sessionKey` to check a specific // session; omit it to check ownership across *any* session (used as a safety predicate so we // never operate on a user-created tab). Never infers ownership from "active". function isPiChromeOwnedTarget(tabId, sessionKey) { if (typeof tabId !== "number") return false; if (sessionKey !== undefined) { const t = automationTargets.get(sessionKey); return !!t && t.tabId === tabId; } for (const t of automationTargets.values()) if (t.tabId === tabId) return true; return false; } // Create a fresh automation target for `sessionKey`. If this session already has a tab group, // create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome // groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a // tab. When the tab is created in a pre-existing group window, leave windowId unset so cleanup only // closes our tab, never that whole window. async function createAutomationTarget(sessionKey, groupTitle) { const existingGroup = groupTitle ? await findGroupRecordByTitle(groupTitle) : null; if (existingGroup && typeof existingGroup.windowId === "number") { const tab = await chrome.tabs.create({ url: "about:blank", active: false, windowId: existingGroup.windowId }); automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined }); await persistAutomationTargets(); return tab; } if (chrome.windows && typeof chrome.windows.create === "function") { try { const win = await chrome.windows.create({ url: "about:blank", focused: false }); const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined; if (created && typeof created.id === "number") { automationTargets.set(sessionKey, { windowId: typeof win.id === "number" ? win.id : undefined, tabId: created.id }); await persistAutomationTargets(); return created; } } catch { // Window creation can fail (policy, headless, etc.); fall back to a dedicated tab below. } } // Tab fallback: the tab lives in a pre-existing (user/shared) window we did NOT create, so we // must leave windowId unset — cleanup then closes only our tab, never the user's window. const tab = await chrome.tabs.create({ url: "about:blank", active: false }); automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined }); await persistAutomationTargets(); return tab; } // Return the session's owned automation target if it still exists, else null. Robust to the user // (or Chrome) having closed it: a stale entry is forgotten so callers can recreate cleanly. async function resolveOwnedAutomationTarget(sessionKey) { await hydrateAutomationTargets(); const t = automationTargets.get(sessionKey); if (!t || typeof t.tabId !== "number") return null; const existing = await chrome.tabs.get(t.tabId).catch(() => null); if (existing && typeof existing.id === "number") return existing; automationTargets.delete(sessionKey); await persistAutomationTargets(); return null; } // Return the session's dedicated automation target, creating it on first use (or after the user // closed it). Used by page/navigation actions that need a live surface to drive. async function getOrCreateAutomationTarget(sessionKey, groupTitle) { return (await resolveOwnedAutomationTarget(sessionKey)) || createAutomationTarget(sessionKey, groupTitle); } // Close only the session's pi-chrome-owned window/tab, and only if it still exists. Never touches // user tabs/windows or other sessions' targets. Safe to call repeatedly and when nothing exists. async function cleanupAutomationTarget(sessionKey) { await hydrateAutomationTargets(); const t = automationTargets.get(sessionKey); const result = { closedWindowId: null, closedTabId: null }; if (!t) return result; const tab = await chrome.tabs.get(t.tabId).catch(() => null); if (tab) { try { // Never remove a whole window: users/other sessions can add tabs even between a // contents check and removal. Chrome closes empty windows when their last tab closes. await chrome.tabs.remove(t.tabId); result.closedTabId = t.tabId; } catch { return result; // Keep ownership so cleanup can retry. } if (tab.windowId === t.windowId && typeof chrome.windows?.get === "function") { const remaining = await chrome.windows.get(t.windowId).catch(() => null); if (!remaining) result.closedWindowId = t.windowId; } } automationTargets.delete(sessionKey); await persistAutomationTargets(); return result; } function withTimeout(promise, ms, label, onTimeout) { let timer; return Promise.race([ Promise.resolve(promise).finally(() => clearTimeout(timer)), new Promise((_, reject) => { timer = setTimeout(() => { // Settle with the timeout first. Cleanup (e.g. debugger detach) makes the pending command // fail with "Detached while handling command"; if that won the race, cdp() would treat it as // a stale session and re-send a command that may already have run. reject(new Error(`${label} timed out after ${ms}ms`)); Promise.resolve().then(() => onTimeout?.()).catch(() => {}); }, ms); }), ]); } // =================== Chrome input (CDP) layer =================== // Tracks which tabs we have attached chrome.debugger to. const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} } const INPUT_IDLE_DETACH_MS = 15_000; const CDP_VERSION = "1.3"; function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function rng(min, max) { return min + Math.random() * (max - min); } function inputStatus() { return { attachedTabs: Array.from(attachedTabs.keys()), permissionGranted: typeof chrome !== "undefined" && !!chrome.debugger, }; } // Last few attach failures, kept for diagnostics. const attachDebugLog = []; function recordAttachEvent(entry) { attachDebugLog.push({ ...entry, t: Date.now() }); if (attachDebugLog.length > 20) attachDebugLog.shift(); } function normalPageTarget(target, tabId) { const url = String(target?.url || ""); return target?.tabId === tabId && target?.type === "page" && !url.startsWith("chrome://") && !url.startsWith("chrome-extension://") && !url.startsWith("devtools://"); } async function pageDebuggeeForTab(tabId) { const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))).catch(() => []); const target = targets.find((t) => normalPageTarget(t, tabId)); return target?.id ? { targetId: target.id } : { tabId }; } async function debuggerAttachRaw(tabId, preferredDebuggee) { const debuggee = preferredDebuggee || { tabId }; await withTimeout( chrome.debugger.attach(debuggee, CDP_VERSION), ATTACH_TIMEOUT_MS, `Chrome debugger attach to tab ${tabId}`, async () => { attachedTabs.delete(tabId); try { await chrome.debugger.detach(debuggee); } catch {} }, ); return debuggee; } async function attachDebugger(tabId) { if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission"); if (attachedTabs.has(tabId)) { const entry = attachedTabs.get(tabId); entry.detachAt = Date.now() + INPUT_IDLE_DETACH_MS; return entry; } // Before each attach, force-detach any stale CDP target this extension owns on the tab. // Chrome sometimes keeps a half-dead session around (extension reload mid-attach, etc.) and // surfaces it as "Cannot access a chrome-extension://" on the next attach attempt. try { const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); for (const tgt of targets) { if (tgt.tabId === tabId && tgt.attached) { recordAttachEvent({ kind: "stale-target-found", tabId, target: { id: tgt.id, type: tgt.type, url: tgt.url, extensionId: tgt.extensionId } }); try { await chrome.debugger.detach({ tabId }); } catch {} await sleep(80); break; } } } catch {} let attachedDebuggee = null; const attemptAttach = async (debuggee) => { try { attachedDebuggee = await debuggerAttachRaw(tabId, debuggee); return null; } catch (error) { return error; } }; const retryPageTargetIfExtensionBlocked = async (err, kind) => { if (!/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(String(err?.message || err))) return err; const pageDebuggee = await pageDebuggeeForTab(tabId); recordAttachEvent({ kind, tabId, debuggee: pageDebuggee }); return attemptAttach(pageDebuggee); }; let err = await attemptAttach(); if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry"); if (err) { const msg = String(err?.message || err); const transient = /Cannot access a chrome-extension|Cannot access contents of|No tab with id|Debugger is not attached|Another debugger|Target closed/i.test(msg); const tabSnapshot = await chrome.tabs.get(tabId).catch(() => null); recordAttachEvent({ kind: "attach-failed", tabId, message: msg, tabUrl: tabSnapshot?.url, transient }); if (!transient) throw err; if (!tabSnapshot || (tabSnapshot.url || "").startsWith("chrome://") || (tabSnapshot.url || "").startsWith("chrome-extension://")) { throw new Error(`Chrome can't attach the debugger to this tab (${tabSnapshot?.url ?? "unknown"}). Open a normal http(s) tab and try again.`); } await sleep(180); err = await attemptAttach(); if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry2"); if (err) { recordAttachEvent({ kind: "attach-retry-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url }); // One more try after a longer settle. Some Chrome builds need ~500ms after a navigation // for content-script registration on the tab to drain before chrome.debugger.attach // will accept the target. await sleep(500); err = await attemptAttach(); if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry3"); if (err) { recordAttachEvent({ kind: "attach-retry2-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url }); const meta = await describeInputTarget(tabId); throw new Error(`Chrome debugger attach failed for tab ${tabId}: ${String(err.message || err)}${targetMetaSuffix(meta)}`); } } } recordAttachEvent({ kind: "attached", tabId, debuggee: attachedDebuggee }); // Seed pointer in a plausible "just left the address bar" location. const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 }, debuggee: attachedDebuggee || { tabId } }; attachedTabs.set(tabId, entry); return entry; } async function describeInputTarget(tabId) { const id = Number(tabId); const tab = Number.isInteger(id) && id >= 0 ? await chrome.tabs.get(id).catch(() => null) : null; const active = (await chrome.tabs.query({ active: true, lastFocusedWindow: true }).catch(() => []))[0] || null; let targets = []; try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {} return { resolvedTab: tab ? { id: tab.id, windowId: tab.windowId, url: tab.url, status: tab.status, title: tab.title, active: tab.active } : null, activeTab: active ? { id: active.id, windowId: active.windowId, url: active.url, status: active.status, title: active.title, active: active.active } : null, attachedTabs: Array.from(attachedTabs.keys()), cdpTargets: targets.map((t) => ({ id: t.id, tabId: t.tabId, type: t.type, url: t.url, attached: t.attached, extensionId: t.extensionId })), }; } function targetMetaSuffix(meta) { return `\nTarget metadata: ${JSON.stringify(meta).slice(0, 4000)}`; } async function inputDebug(params) { // No explicit target: describe this session's automation tab if it exists, without creating one. let tabId = params?.targetId !== undefined ? Number(params.targetId) : null; if (tabId === null) { const owned = await resolveOwnedAutomationTarget(sessionKeyOf(params || {})).catch(() => null); tabId = owned?.id ?? null; } const requested = await describeInputTarget(tabId); return { extensionVersion: chrome.runtime.getManifest().version, extensionId: chrome.runtime.id, ...requested, recentAttachEvents: attachDebugLog.slice(), }; } // Chrome drops trusted CDP input to hidden pages: mouse presses and keys are silently ignored and // mouse moves can hang waiting for a frame. A page is hidden when its tab is inactive, its window is // minimized, or (macOS) its window became a native window-tab behind a full-screen window. Check // before sending input so the caller gets an actionable error instead of a false success. async function pageVisibilityState(tabId) { try { const evaluated = await cdp(tabId, "Runtime.evaluate", { expression: "document.visibilityState", returnByValue: true }); const value = evaluated?.result?.value; return typeof value === "string" ? value : null; } catch { return null; // Unknown: do not block input on a failed probe. } } function hiddenTabReason(tab, win) { if (win?.state === "minimized") return "its window is minimized"; if (tab && tab.active === false) return "it is an inactive tab in its window"; if (win?.state === "fullscreen") return "its window is behind a full-screen window or on another Space"; return "its window is covered by another window or is on another Space (Chrome treats fully covered windows as hidden)"; } async function assertTabVisibleForInput(tab, params, label) { let state = await pageVisibilityState(tab.id); if (state !== "hidden") return; if (foregroundRequested(params)) { // bringToFront just ran; give Chrome a moment to report the page visible. for (let i = 0; i < 6 && state === "hidden"; i++) { await sleep(100); state = await pageVisibilityState(tab.id); } if (state !== "hidden") return; } const fresh = await chrome.tabs.get(tab.id).catch(() => tab); const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(fresh?.windowId ?? tab.windowId).catch(() => null) : null; const error = new Error( `${label}: tab ${tab.id} is hidden because ${hiddenTabReason(fresh, win)}. Chrome ignores trusted input to hidden pages, so the events would be dropped. ` + `Pass background:false for this call (or run /chrome background off) to bring the tab forward. chrome_click and chrome_fill can instead use their DOM fallback (untrusted page events).`, ); error.hiddenTab = true; error.windowState = win?.state; throw error; } // Put the caret at the end of the focused field with Chrome's editing command (platform-neutral, // works for inputs, textareas, and contenteditables). Used after chrome_type clicks a field to focus // it, so the random click point never splices text into the middle of existing content. async function moveCaretToEnd(tabId) { await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "End", code: "End", windowsVirtualKeyCode: 35, commands: ["moveToEndOfDocument"] }); await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "End", code: "End", windowsVirtualKeyCode: 35 }); } async function detachDebugger(tabId) { const entry = attachedTabs.get(tabId); if (!entry) return; attachedTabs.delete(tabId); try { await chrome.debugger.detach(entry.debuggee || { tabId }); } catch {} } async function detachAll() { const ids = Array.from(attachedTabs.keys()); await Promise.all(ids.map(detachDebugger)); } if (chrome.debugger && chrome.debugger.onDetach) { chrome.debugger.onDetach.addListener(({ tabId }, reason) => { if (tabId !== undefined) attachedTabs.delete(tabId); if (reason === "canceled_by_user") { console.warn(`[pi-chrome] debugger canceled by user on tab ${tabId}; Chrome input will reattach on next call`); } }); } setInterval(() => { const now = Date.now(); for (const [tabId, entry] of attachedTabs) { if (entry.detachAt && entry.detachAt < now) { void detachDebugger(tabId); } } }, 5000); // Deadline policy for cdp.call. Callers may widen the per-command CDP deadline via params.timeoutMs, // clamped below the MV3 service-worker lifetime so a stuck command cannot pin the debugger forever. const CDP_CALL_MAX_TIMEOUT_MS = 120_000; const CDP_CALL_GRACE_MS = 5_000; function cdpCallTimeoutMs(params) { const requested = Number(params?.timeoutMs); if (!Number.isFinite(requested) || requested <= 0) return CDP_COMMAND_TIMEOUT_MS; return Math.min(Math.floor(requested), CDP_CALL_MAX_TIMEOUT_MS); } // `opts.timeoutMs` overrides the default per-command deadline. On timeout the session is detached // and forgotten so the next call re-attaches cleanly. function cdpRaw(tabId, method, params, opts) { const debuggee = attachedTabs.get(tabId)?.debuggee || { tabId }; const requested = Number(opts?.timeoutMs); const timeoutMs = Number.isFinite(requested) && requested > 0 ? requested : CDP_COMMAND_TIMEOUT_MS; return withTimeout(new Promise((resolve, reject) => { chrome.debugger.sendCommand(debuggee, method, params || {}, (result) => { if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`)); else resolve(result); }); }), timeoutMs, `CDP ${method}`, async () => { attachedTabs.delete(tabId); try { await chrome.debugger.detach(debuggee); } catch {} }); } function executeScriptTimed(options, label) { return withTimeout(chrome.scripting.executeScript(options), SCRIPTING_TIMEOUT_MS, label || "chrome.scripting.executeScript"); } // Chrome refuses chrome.scripting on pages outside the extension's host permissions. Notably a // top-level about:blank (pi-chrome's fresh automation tab) has an opaque origin that // does not cover, so snapshot/inspect/probe/console/click-resolution failed there with // "Cannot access contents of url \"about:blank\"". The debugger API still attaches to about:blank. function isScriptingAccessDenied(error) { const message = String(error?.message || error); return /Cannot access contents of url|Cannot access .+ at origin|Extension manifest must request permission|matchAboutBlank must be true/i.test(message); } // chrome.scripting.executeScript with a CDP Runtime.evaluate fallback when Chrome refuses scripting // for a permission reason. Scripting stays primary: it is what ordinary pages use and the only path // that works while DevTools holds the tab's debugger. The fallback runs in the same MAIN world and // covers both forms: `func` is stringified and invoked with its args; `files` are read from the // extension package and evaluated. Any other scripting failure surfaces unchanged. const packagedFileSources = new Map(); async function executeScriptWithFallback(options, label) { try { return await executeScriptTimed(options, label); } catch (error) { if (!isScriptingAccessDenied(error) || !chrome.debugger) throw error; const tabId = options?.target?.tabId; recordAttachEvent({ kind: "scripting-denied-cdp-fallback", tabId, files: options?.files || null, message: String(error?.message || error).slice(0, 300) }); if (Array.isArray(options?.files) && options.files.length) { for (const file of options.files) { if (!packagedFileSources.has(file)) { const response = await fetch(chrome.runtime.getURL(file)); if (!response.ok) throw new Error(`Could not read ${file} for CDP injection (HTTP ${response.status})`); packagedFileSources.set(file, await response.text()); } const injected = await cdpEval(tabId, packagedFileSources.get(file)); if (injected.exceptionDetails) { throw new Error(`${label || file}: ${cdpExceptionText(injected.exceptionDetails) || "CDP injection failed"}`); } } return [{ result: undefined }]; } const expression = `(${options.func.toString()})(...${JSON.stringify(Array.isArray(options.args) ? options.args : [])})`; const result = await cdpEval(tabId, expression); if (result.exceptionDetails) { throw new Error(`${label || "page script"}: ${cdpExceptionText(result.exceptionDetails) || "evaluation failed"}`); } return [{ result: result.result?.value }]; } } // Wraps cdpRaw with one auto-recover on detached/closed sessions: // chrome.debugger.attach can stay cached in attachedTabs even after Chrome killed // the session (tab nav, devtools opened/closed, etc). Recover by detaching the // stale entry and re-attaching, then retry the command once. // Find foreign chrome-extension targets currently anchored to the tab. Password managers, // autofill helpers, and other input-attached extensions create type:"other" CDP targets // whose URL is chrome-extension:///... When that target is in focus, CDP refuses // our Input.dispatchMouseEvent calls with "Cannot access a chrome-extension:// URL of // different extension" — surfacing a cryptic error to the user. async function findForeignExtensionTargets() { try { const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); return targets.filter((t) => { const url = String(t.url || ""); if (!url.startsWith("chrome-extension://")) return false; if (t.extensionId === chrome.runtime.id) return false; return true; }); } catch { return []; } } function extractForeignExtId(targets) { for (const t of targets) { if (t.extensionId && t.extensionId !== chrome.runtime.id) return t.extensionId; const m = String(t.url || "").match(/chrome-extension:\/\/([a-p]+)\//); if (m && m[1] !== chrome.runtime.id) return m[1]; } return null; } async function dismissOverlayViaEscape(tabId) { // Esc routes through key dispatcher (target-by-focus), not by mouse coordinates, so it // works even when a foreign chrome-extension popup is intercepting pointer events. try { await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); await sleep(120); } catch {} } async function cdp(tabId, method, params, opts) { try { return await cdpRaw(tabId, method, params, opts); } catch (error) { const msg = String(error?.message || error); const isStale = /Debugger is not attached|Detached while|Target closed|No tab with id/i.test(msg); const isForeignExtBlock = /Cannot access a chrome-extension:\/\/ URL of different extension/i.test(msg); if (isForeignExtBlock && /Input\./.test(method)) { // Foreign chrome-extension popup (autofill, password manager) is hijacking input. // Try once: dismiss via Esc, then retry. const before = await findForeignExtensionTargets(); recordAttachEvent({ kind: "foreign-ext-detected", tabId, method, foreignExtId: extractForeignExtId(before), targetCount: before.length }); await dismissOverlayViaEscape(tabId); try { return await cdpRaw(tabId, method, params, opts); } catch (retryErr) { const retryMsg = String(retryErr?.message || retryErr); if (/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(retryMsg)) { const after = await findForeignExtensionTargets(); const id = extractForeignExtId(after) || extractForeignExtId(before) || "unknown"; throw new Error( `Another Chrome extension (${id}) has an input overlay on this page (e.g. a password manager / autofill popup). \n` + `pi-chrome tried to dismiss it with Escape but it reappeared. Disable that extension on this page, close its popup, or focus the field via Tab instead of clicking.`, ); } throw retryErr; } } if (!isStale) throw error; attachedTabs.delete(tabId); await attachDebugger(tabId).catch(() => undefined); return cdpRaw(tabId, method, params, opts); } } // cdpEval: evaluate a JavaScript expression string in the page's MAIN world via CDP // Runtime.evaluate. Runtime.evaluate is a DevTools protocol command and is NOT subject to // the page's Content-Security-Policy, so it works on pages that ship `script-src 'self'` // without `'unsafe-eval'` (which blocks `eval`/`new Function`). Ensures the debugger is // attached first. Returns the raw CDP result ({ result, exceptionDetails }). async function cdpEval(tabId, expression, opts) { await attachDebugger(tabId); return cdp(tabId, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true, userGesture: true, ...(opts || {}), }); } function cdpExceptionText(details) { if (!details) return ""; return String( details.exception?.description || details.exception?.value || details.text || "", ); } function cdpIsSyntaxError(details) { if (!details) return false; const className = String(details.exception?.className || ""); return className === "SyntaxError" || /SyntaxError/.test(cdpExceptionText(details)); } // Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab. async function resolveTargetInTab(tabId, params) { const results = await executeScriptWithFallback({ target: { tabId, frameIds: [0] }, world: "MAIN", func: (selector, uid, x, y) => { const state = window.__PI_CHROME_STATE__; let el = null; if (uid) { el = state && state.elements ? state.elements[uid] : null; if (!el || !el.isConnected) return { found: false, staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href }; } else if (selector) { el = document.querySelector(selector); } if (el) { el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, rect: { left: r.left, top: r.top, width: r.width, height: r.height }, tag: el.tagName, found: true }; } if (typeof x === "number" && typeof y === "number") return { x, y, rect: null, tag: null, found: true }; return { found: false }; }, args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null], }, `resolve input target in tab ${tabId}`); const v = results?.[0]?.result; if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot"); if (!v || !v.found) throw new Error("Could not resolve target element for Chrome input"); return v; } function pickInsideRect(rect) { if (!rect) return null; const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1)); const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1)); return { x: rect.left + rect.width / 2 + rng(-insetX, insetX), y: rect.top + rect.height / 2 + rng(-insetY, insetY), }; } async function cdpMoveTo(tabId, x, y) { const entry = attachedTabs.get(tabId); const startX = entry?.pointer?.x ?? Math.max(20, Math.min(400, x - 200)); const startY = entry?.pointer?.y ?? Math.max(20, Math.min(400, y - 200)); const n = Math.max(18, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18))); for (let i = 1; i <= n; i++) { const t = i / n; const ease = t * t * (3 - 2 * t); const wobble = Math.sin(t * Math.PI) * 8; const px = startX + (x - startX) * ease + rng(-wobble, wobble); const py = startY + (y - startY) * ease + rng(-wobble, wobble); await cdp(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x: px, y: py, button: "none", buttons: 0, pointerType: "mouse", }); await sleep(rng(5, 16)); } if (entry) entry.pointer = { x, y }; } function cdpModifiersFor(mods) { let m = 0; if (mods?.altKey) m |= 1; if (mods?.ctrlKey) m |= 2; if (mods?.metaKey) m |= 4; if (mods?.shiftKey) m |= 8; return m; } // Resolve a single printable character to { code, keyCode, needShift } on a US layout. // Self-contained (maps defined inline) so it can be serialized into the page via // HELPER_FUNCS for the DOM-event fallback as well as used by the CDP path. // Using charCodeAt() for punctuation is wrong: e.g. "." is charCode 46 which collides // with VK_DELETE, "-" is 45 (VK_INSERT), so app keydown handlers misfire and drop input. function usKeyLayoutForChar(ch) { const PUNCT = { "`": { code: "Backquote", keyCode: 192 }, "~": { code: "Backquote", keyCode: 192, shift: true }, "-": { code: "Minus", keyCode: 189 }, "_": { code: "Minus", keyCode: 189, shift: true }, "=": { code: "Equal", keyCode: 187 }, "+": { code: "Equal", keyCode: 187, shift: true }, "[": { code: "BracketLeft", keyCode: 219 }, "{": { code: "BracketLeft", keyCode: 219, shift: true }, "]": { code: "BracketRight", keyCode: 221 }, "}": { code: "BracketRight", keyCode: 221, shift: true }, "\\": { code: "Backslash", keyCode: 220 }, "|": { code: "Backslash", keyCode: 220, shift: true }, ";": { code: "Semicolon", keyCode: 186 }, ":": { code: "Semicolon", keyCode: 186, shift: true }, "'": { code: "Quote", keyCode: 222 }, "\"": { code: "Quote", keyCode: 222, shift: true }, ",": { code: "Comma", keyCode: 188 }, "<": { code: "Comma", keyCode: 188, shift: true }, ".": { code: "Period", keyCode: 190 }, ">": { code: "Period", keyCode: 190, shift: true }, "/": { code: "Slash", keyCode: 191 }, "?": { code: "Slash", keyCode: 191, shift: true }, " ": { code: "Space", keyCode: 32 }, }; // Shifted digit symbols share the digit's physical code + keyCode. const SHIFT_DIGIT = { ")": "0", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9" }; if (/^[a-z]$/.test(ch)) return { code: `Key${ch.toUpperCase()}`, keyCode: ch.toUpperCase().charCodeAt(0), needShift: false }; if (/^[A-Z]$/.test(ch)) return { code: `Key${ch}`, keyCode: ch.charCodeAt(0), needShift: true }; if (/^[0-9]$/.test(ch)) return { code: `Digit${ch}`, keyCode: ch.charCodeAt(0), needShift: false }; if (SHIFT_DIGIT[ch]) { const d = SHIFT_DIGIT[ch]; return { code: `Digit${d}`, keyCode: d.charCodeAt(0), needShift: true }; } const p = PUNCT[ch]; if (p) return { code: p.code, keyCode: p.keyCode, needShift: !!p.shift }; // Unknown char (e.g. unicode): keep text-driven insertion, avoid bogus keyCode collisions. return { code: ch, keyCode: 0, needShift: false }; } function cdpKeyInfo(key, shifted) { // Map common keys to CDP key event init fields. Returns { code, key, windowsVirtualKeyCode, text }. const SPECIAL = { Enter: { code: "Enter", windowsVirtualKeyCode: 13, text: "\r" }, Tab: { code: "Tab", windowsVirtualKeyCode: 9, text: "\t" }, Backspace: { code: "Backspace", windowsVirtualKeyCode: 8, text: "" }, Delete: { code: "Delete", windowsVirtualKeyCode: 46, text: "" }, Escape: { code: "Escape", windowsVirtualKeyCode: 27, text: "" }, ArrowLeft: { code: "ArrowLeft", windowsVirtualKeyCode: 37, text: "" }, ArrowUp: { code: "ArrowUp", windowsVirtualKeyCode: 38, text: "" }, ArrowRight: { code: "ArrowRight", windowsVirtualKeyCode: 39, text: "" }, ArrowDown: { code: "ArrowDown", windowsVirtualKeyCode: 40, text: "" }, Shift: { code: "ShiftLeft", windowsVirtualKeyCode: 16, text: "" }, Control: { code: "ControlLeft", windowsVirtualKeyCode: 17, text: "" }, Alt: { code: "AltLeft", windowsVirtualKeyCode: 18, text: "" }, Meta: { code: "MetaLeft", windowsVirtualKeyCode: 91, text: "" }, " ": { code: "Space", windowsVirtualKeyCode: 32, text: " " }, }; if (SPECIAL[key]) return { key, ...SPECIAL[key] }; if (key.length === 1) { // Explicit Shift chords need shifted text as well as a modifier bit. CDP does // not derive printable text from code/windowsVirtualKeyCode for us. const SHIFTED = { "`": "~", "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", "-": "_", "=": "+", "[": "{", "]": "}", "\\": "|", ";": ":", "'": "\"", ",": "<", ".": ">", "/": "?", }; const ch = shifted ? (/^[a-z]$/.test(key) ? key.toUpperCase() : SHIFTED[key] || key) : key; const layout = usKeyLayoutForChar(ch); return { key: ch, code: layout.code, windowsVirtualKeyCode: layout.keyCode, text: ch }; } return { key, code: key, windowsVirtualKeyCode: 0, text: "" }; } async function cdpTypeChar(tabId, ch) { const needShift = /^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch); let modifiers = 0; if (needShift) { await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 8 }); modifiers = 8; await sleep(rng(8, 22)); } const info = cdpKeyInfo(ch); await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: info.key, code: info.code, windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode, text: info.text, unmodifiedText: info.text, modifiers, }); await sleep(rng(25, 90)); await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: info.key, code: info.code, windowsVirtualKeyCode: info.windowsVirtualKeyCode, modifiers, }); if (needShift) { await sleep(rng(5, 18)); await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 0 }); } await sleep(rng(35, 130)); } async function domClickFallback(tabId, params, cause) { const results = await executeScriptWithFallback({ target: { tabId, frameIds: [0] }, world: "MAIN", func: (selector, uid, x, y) => { const state = window.__PI_CHROME_STATE__; let el = uid && state && state.elements ? state.elements[uid] : null; if (uid && (!el || !el.isConnected)) return { staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href }; if (!el && selector) el = document.querySelector(selector); if (!el && typeof x === "number" && typeof y === "number") el = document.elementFromPoint(x, y); if (!el) throw new Error(`DOM fallback target not found: ${uid || selector || `${x},${y}`}`); el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); const rect = el.getBoundingClientRect(); const eventInit = { bubbles: true, cancelable: true, view: window, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, button: 0, buttons: 1 }; el.dispatchEvent(new PointerEvent("pointerdown", { ...eventInit, pointerId: 1, pointerType: "mouse", isPrimary: true })); el.dispatchEvent(new MouseEvent("mousedown", eventInit)); if (typeof el.focus === "function") el.focus({ preventScroll: true }); el.dispatchEvent(new PointerEvent("pointerup", { ...eventInit, pointerId: 1, pointerType: "mouse", isPrimary: true, buttons: 0 })); el.dispatchEvent(new MouseEvent("mouseup", { ...eventInit, buttons: 0 })); el.click(); return { tag: el.tagName, url: location.href }; }, args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null], }, `DOM click fallback in tab ${tabId}`); const v = results?.[0]?.result; if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot"); return { input: "dom-fallback", reason: String(cause?.message || cause).slice(0, 500), tag: v?.tag }; } async function chromeInputClick(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); try { await attachDebugger(tab.id); await assertTabVisibleForInput(tab, params, "chrome.click"); const resolved = await resolveTargetInTab(tab.id, params); const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y }; await cdpMoveTo(tab.id, point.x, point.y); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse", force: 0.5 }); await sleep(rng(45, 140)); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" }); // Reset :focus-visible if the click landed on a focusable element. CDP-driven pointer // focus can leave :focus-visible=true in Chromium, which trips heuristics that expect // Reset focus styling after pointer click when possible. if (params.selector || params.uid) { await executeScriptWithFallback({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", func: (sel, uid) => { const state = window.__PI_CHROME_STATE__; let el = null; if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid]; else if (sel) el = document.querySelector(sel); if (el && typeof el.focus === "function" && el === document.activeElement) { try { el.blur(); el.focus({ preventScroll: true, focusVisible: false }); } catch {} } }, args: [params.selector ?? null, params.uid ?? null], }, `reset focus style in tab ${tab.id}`).catch(() => undefined); } return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag }; } catch (error) { if (params.domFallback === false) throw error; return domClickFallback(tab.id, params, error); } } async function chromeInputHover(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); await assertTabVisibleForInput(tab, params, "chrome.hover"); const resolved = await resolveTargetInTab(tab.id, params); const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y }; await cdpMoveTo(tab.id, point.x, point.y); await sleep(rng(80, 220)); return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag }; } async function chromeInputKey(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); await assertTabVisibleForInput(tab, params, "chrome.key"); const key = String(params.key || ""); if (!key) throw new Error("chrome.key: missing key"); const mods = params.modifiers || {}; const modBits = cdpModifiersFor(mods); // Press modifiers in standard order, then key, then release in reverse. const modOrder = []; if (mods.metaKey) modOrder.push({ key: "Meta", code: "MetaLeft", vk: 91 }); if (mods.ctrlKey) modOrder.push({ key: "Control", code: "ControlLeft", vk: 17 }); if (mods.altKey) modOrder.push({ key: "Alt", code: "AltLeft", vk: 18 }); if (mods.shiftKey) modOrder.push({ key: "Shift", code: "ShiftLeft", vk: 16 }); for (const m of modOrder) { await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: modBits }); await sleep(rng(6, 18)); } const info = cdpKeyInfo(key, mods.shiftKey); // Ctrl/Meta/Alt chords must not insert literal text (e.g. Cmd+V). Shift alone // still types: Shift+a -> A, Shift+1 -> !, and Shift+Enter carries a newline. const shortcut = !!(mods.ctrlKey || mods.metaKey || mods.altKey); await cdp(tab.id, "Input.dispatchKeyEvent", { type: shortcut ? "rawKeyDown" : "keyDown", key: info.key, code: info.code, windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode, text: shortcut ? "" : info.text, unmodifiedText: shortcut ? "" : info.text, modifiers: modBits, }); await sleep(rng(25, 90)); await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: info.key, code: info.code, windowsVirtualKeyCode: info.windowsVirtualKeyCode, modifiers: modBits, }); for (const m of modOrder.reverse()) { await sleep(rng(5, 18)); await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: 0 }); } return { input: "chrome", key: info.key, modifiers: mods }; } // Read the actual focused editor, not a role=textbox lookalike. For fill, select // the requested editor's entire contents: triple-click only selects a paragraph. // Selection uses the DOM; deletion and insertion still use Chrome's input layer. async function contentEditableInTab(tabId, selectAllParams = null) { const results = await executeScriptWithFallback({ target: { tabId, frameIds: [0] }, world: "MAIN", func: (selector, uid, selectAll) => { const active = document.activeElement; if (selectAll) { const state = window.__PI_CHROME_STATE__; const el = uid ? state?.elements?.[uid] : document.querySelector(selector); if (uid && (!el || !el.isConnected)) throw new Error(`snapshot uid ${uid} is stale; refresh chrome_snapshot`); if (!el?.isContentEditable) return false; if (!active?.isContentEditable || !(el === active || el.contains(active) || active.contains(el))) { throw new Error("chrome.fill: requested contenteditable is not focused"); } const selection = window.getSelection(); if (!selection) throw new Error("Could not select contenteditable contents"); const range = document.createRange(); range.selectNodeContents(el); selection.removeAllRanges(); selection.addRange(range); } return active?.isContentEditable === true; }, args: [selectAllParams?.selector ?? null, selectAllParams?.uid ?? null, selectAllParams !== null], }, `inspect contenteditable in tab ${tabId}`); return results?.[0]?.result === true; } async function typeTextInTab(tabId, text, perCharacter) { if (!text) return "none"; if (!perCharacter && await contentEditableInTab(tabId)) { // One native edit avoids per-character delays and rich-editor render races. // Do not retry as keystrokes if insertion fails: it may already have applied. await cdp(tabId, "Input.insertText", { text }); return "insertText"; } for (const ch of Array.from(text)) await cdpTypeChar(tabId, ch); return "keys"; } async function chromeInputType(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); await assertTabVisibleForInput(tab, params, "chrome.type"); if (params.selector || params.uid) { // Focus target by clicking it first. const resolved = await resolveTargetInTab(tab.id, params); const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y }; await cdpMoveTo(tab.id, point.x, point.y); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse", force: 0.5 }); await sleep(rng(45, 110)); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" }); await sleep(rng(50, 120)); // The click lands at a random point inside the field, which leaves the caret mid-text. Move it to // the end so a targeted chrome_type appends; replace=true selects everything below anyway. if (params.replace !== true) await moveCaretToEnd(tab.id); } const text = String(params.text || ""); // chrome_type inserts at the caret. Read the field before and after so a splice into existing // text is reported instead of hidden behind a bare character count. The "before" read precedes // replace's select-all/delete. Reads are best-effort and never block typing. const before = await readInputStateInTab(tab.id, params).catch(() => null); if (params.replace === true) await selectAllAndDelete(tab.id); const typing = await typeTextInTab(tab.id, text, params.perCharacter); const after = await readInputStateInTab(tab.id, params).catch(() => null); if (params.pressEnter) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" }); return { input: "chrome", length: text.length, typing, ...inputValueEvidence(before, after, { replaced: params.replace === true }), }; } // Select the focused field's whole contents with Chrome's editing command, then delete it with real // key events. The selectAll editing command is platform-neutral (Ctrl+A moves to line start on macOS). async function selectAllAndDelete(tabId) { const mac = /Mac/i.test(String(globalThis.navigator?.platform || globalThis.navigator?.userAgent || "")); const modifiers = mac ? 4 : 2; const key = mac ? { key: "Meta", code: "MetaLeft", vk: 91 } : { key: "Control", code: "ControlLeft", vk: 17 }; await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: key.key, code: key.code, windowsVirtualKeyCode: key.vk, modifiers }); await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers, commands: ["selectAll"] }); await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers }); await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: key.key, code: key.code, windowsVirtualKeyCode: key.vk, modifiers: 0 }); await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); await sleep(rng(20, 60)); } // Read the typing target's value and caret. Sensitive fields (password/OTP/card/etc., matching the // snapshot redaction convention) report only valueRedacted + length, never contents. Displayed values // are truncated to 120 chars like snapshots; lengths and caret offsets are exact. async function readInputStateInTab(tabId, params) { const results = await executeScriptWithFallback({ target: { tabId, frameIds: [0] }, world: "MAIN", func: (selector, uid) => { const state = window.__PI_CHROME_STATE__; const carrierOf = (node) => { if (!node) return null; if ("value" in node && typeof node.value === "string" && /^(INPUT|TEXTAREA)$/.test(node.tagName)) return "value"; if (node.isContentEditable === true) return "contenteditable"; return null; }; let el = uid ? (state && state.elements ? state.elements[uid] : null) : null; if (uid && (!el || !el.isConnected)) el = null; if (!el && selector) el = document.querySelector(selector); const active = document.activeElement; // The uid/selector can name a wrapper (role=textbox, label) rather than the element that // receives text. Prefer the focused carrier in that case so we do not report a false "" -> "". if (!carrierOf(el) && carrierOf(active)) el = active; const carrier = carrierOf(el); if (!carrier) return { found: false }; const raw = carrier === "value" ? el.value : String(el.textContent || ""); const attr = (name) => (el.getAttribute ? el.getAttribute(name) : null); const type = String(el.type || attr("type") || "").toLowerCase(); const haystack = [type, el.name, attr("name"), el.id, attr("autocomplete"), attr("aria-label"), attr("placeholder"), attr("data-testid")].filter(Boolean).join(" ").toLowerCase(); const sensitive = type === "password" || /password|passwd|\bpwd\b|secret|token|bearer|api[-_ ]?key|access[-_ ]?key|auth[-_ ]?code|one[-_ ]?time|otp|2fa|mfa|verification[-_ ]?code|recovery[-_ ]?code|credit[-_ ]?card|card[-_ ]?number|cc-number|cc-csc|cvc|cvv|security[-_ ]?code|ssn|social[-_ ]?security/.test(haystack); let selectionStart = null; let selectionEnd = null; try { if (typeof el.selectionStart === "number") selectionStart = el.selectionStart; if (typeof el.selectionEnd === "number") selectionEnd = el.selectionEnd; } catch {} let caretOffset = null; if (carrier === "contenteditable") { try { const selection = window.getSelection && window.getSelection(); if (selection && selection.rangeCount > 0 && selection.anchorNode && el.contains(selection.anchorNode)) { const range = document.createRange(); range.selectNodeContents(el); range.setEnd(selection.anchorNode, selection.anchorOffset); caretOffset = range.toString().length; if (!selection.isCollapsed) selectionEnd = caretOffset + String(selection.toString()).length; } } catch {} } return { found: true, carrier, value: sensitive ? undefined : raw.slice(0, 120), valueLength: raw.length, valueRedacted: sensitive ? true : undefined, selectionStart, selectionEnd, caretOffset, }; }, args: [params.selector ?? null, params.uid ?? null], }, `read input value in tab ${tabId}`); const v = results?.[0]?.result; return v && typeof v === "object" ? v : { found: false }; } // Where typed text landed relative to the field's existing contents. function inputInsertPosition(before) { const length = before.valueLength ?? 0; if (length === 0) return "empty"; if (typeof before.selectionStart === "number" && typeof before.selectionEnd === "number") { if (before.selectionEnd > before.selectionStart) { return before.selectionStart === 0 && before.selectionEnd >= length ? "replaced-all" : "replaced-selection"; } return before.selectionStart < length ? "caret-middle" : "caret-end"; } if (typeof before.caretOffset === "number") { if (typeof before.selectionEnd === "number" && before.selectionEnd > before.caretOffset) { return before.caretOffset === 0 && before.selectionEnd >= length ? "replaced-all" : "replaced-selection"; } return before.caretOffset < length ? "caret-middle" : "caret-end"; } return undefined; } function inputValueEvidence(before, after, { replaced = false } = {}) { const hasBefore = Boolean(before?.found); const hasAfter = Boolean(after?.found); if (!hasBefore && !hasAfter) return replaced ? { replaced: true } : {}; const redacted = Boolean(before?.valueRedacted || after?.valueRedacted); const evidence = { existingTextLengthBefore: hasBefore ? before.valueLength : undefined, valueLengthAfter: hasAfter ? after.valueLength : undefined, insertedAt: replaced ? "replaced-all" : hasBefore ? inputInsertPosition(before) : undefined, }; if (redacted) evidence.valueRedacted = true; else { if (hasBefore) evidence.valueBefore = before.value; if (hasAfter) evidence.valueAfter = after.value; } if (replaced) evidence.replaced = true; return evidence; } async function domFillFallback(tabId, params, cause) { if (!(params.selector || params.uid)) throw cause; const results = await executeScriptWithFallback({ target: { tabId, frameIds: [0] }, world: "MAIN", func: async (selector, uid, text, submit) => { const state = window.__PI_CHROME_STATE__; let el = uid && state && state.elements ? state.elements[uid] : null; if (uid && (!el || !el.isConnected)) return { staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href }; if (!el && selector) el = document.querySelector(selector); if (!el) throw new Error(`DOM fallback target not found: ${uid || selector}`); el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); if (typeof el.focus === "function") el.focus({ preventScroll: true }); const value = String(text ?? ""); if ("value" in el) { const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set; if (setter) setter.call(el, value); else el.value = value; } else if (el.isContentEditable) { el.textContent = value; } else { throw new Error(`DOM fallback target is not fillable: <${el.tagName.toLowerCase()}>`); } el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); el.dispatchEvent(new Event("change", { bubbles: true })); if (submit) { const form = el.closest("form"); if (form) form.requestSubmit ? form.requestSubmit() : form.submit(); else document.querySelector("button,[type=submit]")?.click(); } return { valueMatches: "value" in el ? el.value === value : el.textContent === value, tag: el.tagName, url: location.href }; }, args: [params.selector ?? null, params.uid ?? null, params.text ?? "", params.submit === true], }, `DOM fill fallback in tab ${tabId}`); const v = results?.[0]?.result; if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot"); return { input: "dom-fallback", length: String(params.text || "").length, valueMatches: v?.valueMatches, reason: String(cause?.message || cause).slice(0, 500), tag: v?.tag }; } async function chromeInputFill(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); try { await attachDebugger(tab.id); await assertTabVisibleForInput(tab, params, "chrome.fill"); if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required"); const resolved = await resolveTargetInTab(tab.id, params); const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y }; await cdpMoveTo(tab.id, point.x, point.y); // Triple-click selects all in input fields. for (let i = 1; i <= 3; i++) { await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: i, pointerType: "mouse", force: 0.5 }); await sleep(rng(20, 60)); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" }); await sleep(rng(20, 60)); } if (await contentEditableInTab(tab.id, params)) { // The requested editor's whole contents are already selected; delete the selection. await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); await sleep(rng(20, 60)); } else { // A triple-click selects only one line of a multi-line textarea. Select the whole value with // Chrome's selectAll editing command before deleting. await selectAllAndDelete(tab.id); } const text = String(params.text || ""); const typing = await typeTextInTab(tab.id, text, params.perCharacter); if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" }); return { input: "chrome", length: text.length, typing }; } catch (error) { if (params.domFallback === false) throw error; return domFillFallback(tab.id, params, error); } } async function chromeInputScroll(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); await assertTabVisibleForInput(tab, params, "chrome.scroll"); const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null }; const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x; const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y; const totalY = params.deltaY || 0, totalX = params.deltaX || 0; // Profile mimics a trackpad flick: short ramp-up (~15% of events), then geometric decay // with a ~12% drop per event. Gives momentum tail tests something to find, and the small // tail deltas (a handful of <20px events) put IntersectionObserver thresholds in range. const peak = Math.max(Math.abs(totalY), Math.abs(totalX)); // Aim peak event ~22px so cumulative wheel approach to target seeds low-ratio IO samples. const PEAK_TARGET = 22; const w = []; // Build weights for an arbitrary n, then iterate to find an n where peak * (w_peak/sum) <= PEAK_TARGET. function build(n) { const arr = []; const peakIdx = Math.max(1, Math.floor(n * 0.15)); for (let i = 0; i < n; i++) { if (i <= peakIdx) arr.push(0.5 + 0.5 * (i / peakIdx)); // 0.5 → 1.0 else arr.push(Math.pow(0.88, i - peakIdx)); // ~12% drop per step } return arr; } let n = Math.max(12, params.steps || 24); for (let attempt = 0; attempt < 8; attempt++) { const arr = build(n); const s = arr.reduce((a, b) => a + b, 0); const peakStep = peak * (Math.max(...arr) / s); if (peakStep <= PEAK_TARGET || n >= 240) { w.length = 0; w.push(...arr); break; } n = Math.ceil(n * 1.4); } if (w.length === 0) w.push(...build(n)); const sumW = w.reduce((a, b) => a + b, 0); for (let i = 0; i < n; i++) { const dy = totalY * (w[i] / sumW), dx = totalX * (w[i] / sumW); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: dx, deltaY: dy, pointerType: "mouse", }); // Sleep one+ frame so IntersectionObserver / rAF samples can run between events. await sleep(rng(22, 48)); } return { input: "chrome", deltaX: totalX, deltaY: totalY, steps: n }; } async function chromeInputTap(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); await assertTabVisibleForInput(tab, params, "chrome.tap"); const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number")) ? await resolveTargetInTab(tab.id, params) : null; if (!resolved || !resolved.found) throw new Error("chrome.tap: target not found"); const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y }; const tp = { x: point.x, y: point.y, radiusX: 8, radiusY: 8, rotationAngle: 0, force: 0.5, id: 1 }; await cdp(tab.id, "Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [tp] }); await sleep(rng(40, 110)); await cdp(tab.id, "Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag }; } async function chromeInputDrag(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); await assertTabVisibleForInput(tab, params, "chrome.drag"); const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null }); const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null }); const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y }; const tp = to.rect ? pickInsideRect(to.rect) : { x: to.x, y: to.y }; await cdpMoveTo(tab.id, fp.x, fp.y); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: fp.x, y: fp.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse", force: 0.5 }); await sleep(rng(60, 140)); const steps = params.steps || 20; for (let i = 1; i <= steps; i++) { const t = i / steps; const ease = t * t * (3 - 2 * t); const wobble = Math.sin(t * Math.PI) * 6; const x = fp.x + (tp.x - fp.x) * ease + rng(-wobble, wobble); const y = fp.y + (tp.y - fp.y) * ease + rng(-wobble, wobble); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "left", buttons: 1, pointerType: "mouse" }); await sleep(rng(10, 26)); } await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: tp.x, y: tp.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" }); return { input: "chrome", from: fp, to: tp, steps }; } // Pending native file-chooser intercepts, keyed by tabId. chrome_upload_file uses these when the // target is an upload trigger (button/dropzone) rather than a persistent : // Page.setInterceptFileChooserDialog -> click the trigger -> Page.fileChooserOpened -> // DOM.setFileInputFiles(backendNodeId). No OS dialog is shown. const pendingFileChoosers = new Map(); const FILE_CHOOSER_TIMEOUT_MS = 8_000; function fileChooserTabIdForSource(source) { if (typeof source?.tabId === "number") return source.tabId; if (source?.targetId) { for (const [tabId, entry] of attachedTabs) if (entry?.debuggee?.targetId === source.targetId) return tabId; } return undefined; } function handleDebuggerEvent(source, method, params) { if (method !== "Page.fileChooserOpened") return; const tabId = fileChooserTabIdForSource(source); const entry = tabId === undefined ? undefined : pendingFileChoosers.get(tabId); if (!entry) return; pendingFileChoosers.delete(tabId); entry.resolve({ backendNodeId: params?.backendNodeId, mode: params?.mode }); } if (chrome.debugger && chrome.debugger.onEvent) { chrome.debugger.onEvent.addListener(handleDebuggerEvent); } function waitForFileChooser(tabId, timeoutMs) { let entry; const promise = new Promise((resolve, reject) => { entry = { resolve, reject }; entry.timer = setTimeout(() => { if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId); reject(new Error("chrome.upload: no file chooser opened after clicking the target. Pass the itself, or an element that opens the file picker when clicked.")); }, timeoutMs); }); pendingFileChoosers.set(tabId, entry); return { promise: promise.finally(() => clearTimeout(entry.timer)), cancel: () => { if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId); clearTimeout(entry.timer); } }; } // Resolve the upload target in the page. Selectors also search same-origin iframes (editors often // host their file input in one). A label for a file input, or a wrapper/dropzone containing exactly // one file input, resolves to that input so no click is needed. function uploadTargetExpression(selector, uid) { return `(() => { const selector = ${JSON.stringify(selector ?? null)}; const uid = ${JSON.stringify(uid ?? null)}; const state = window.__PI_CHROME_STATE__; const findIn = (doc, depth) => { let found = null; try { found = doc.querySelector(selector); } catch (error) { throw new Error("Invalid selector: " + selector); } if (found || depth >= 3) return found; for (const frame of doc.querySelectorAll("iframe,frame")) { let inner = null; try { inner = frame.contentDocument; } catch {} if (!inner) continue; found = findIn(inner, depth + 1); if (found) return found; } return null; }; let el = uid ? state?.elements?.[uid] : (selector ? findIn(document, 0) : null); if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot"); if (!el) throw new Error("chrome.upload: target not found: " + (uid || selector)); const isFile = (node) => node && node.tagName === "INPUT" && String(node.type).toLowerCase() === "file"; if (!isFile(el)) { const control = el.tagName === "LABEL" && isFile(el.control) ? el.control : null; const nested = control ? null : Array.from(el.querySelectorAll ? el.querySelectorAll("input[type=file]") : []); if (control) el = control; else if (nested && nested.length === 1) el = nested[0]; } return el; })()`; } const UPLOAD_TARGET_INFO_FN = `function() { const isFile = this.tagName === "INPUT" && String(this.type).toLowerCase() === "file"; if (!isFile) this.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); const r = this.getBoundingClientRect(); let x = r.left + r.width / 2, y = r.top + r.height / 2; let win = this.ownerDocument.defaultView; // Convert iframe-relative coordinates to top-level viewport coordinates (same-origin frames only). while (win && win.frameElement) { const frame = win.frameElement; const fr = frame.getBoundingClientRect(); const cs = frame.ownerDocument.defaultView.getComputedStyle(frame); x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0); y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0); win = win.parent === win ? null : win.parent; } return { tag: this.tagName, isFile, multiple: isFile ? this.multiple === true : undefined, inFrame: this.ownerDocument !== document, x, y, width: r.width, height: r.height }; }`; async function chromeInputUpload(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required"); const paths = Array.isArray(params.paths) ? params.paths.map(String) : []; if (!paths.length) throw new Error("chrome.upload: no file paths provided"); const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression: uploadTargetExpression(params.selector, params.uid), objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false }); if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve upload target"); const objectId = evaluated.result?.objectId; if (!objectId) throw new Error("Could not resolve upload target object"); try { const infoResult = await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: UPLOAD_TARGET_INFO_FN, returnByValue: true }); const info = infoResult?.result?.value || {}; if (info.isFile) { if (paths.length > 1 && info.multiple === false) throw new Error(`chrome.upload: the file input accepts one file but ${paths.length} paths were given`); await cdp(tab.id, "DOM.enable", {}).catch(() => undefined); // Some DOM agents return nodeId:0 (or reject conversion) for a valid remote // element. CDP accepts that same objectId directly, before any file mutation. const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null); const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId }; // Chrome's setFileInputFiles normally fires input/change itself. Watch for that and only // dispatch our own events when it did not, so apps never see a duplicate change (double upload). await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: `function() { const el = this; el.__piChromeChangeSeen = false; el.addEventListener("change", () => { el.__piChromeChangeSeen = true; }, { once: true, capture: true }); }`, }).catch(() => undefined); await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths }); const notified = await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: `function() { const seen = this.__piChromeChangeSeen === true; delete this.__piChromeChangeSeen; if (seen) return "native"; this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return "dispatched"; }`, returnByValue: true, }).catch(() => null); const events = notified?.result?.value; return { input: "chrome", mode: "file-input", events: typeof events === "string" ? events : undefined, inFrame: info.inFrame || undefined, uploaded: paths.map((path) => ({ path })) }; } return await uploadViaFileChooser(tab, params, paths, objectId, info); } finally { await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined); } } // The target opens the native picker when clicked. Intercept the chooser so no OS dialog appears, // then attach the files to the input Chrome reports. The click is trusted Chrome input when the page // is visible; a hidden page drops trusted input, so there we call element.click() with a CDP user // gesture instead (an untrusted event, like chrome_click's DOM fallback) unless domFallback:false. async function uploadViaFileChooser(tab, params, paths, objectId, info) { const hidden = (await pageVisibilityState(tab.id)) === "hidden"; if (hidden && params.domFallback === false) await assertTabVisibleForInput(tab, params, "chrome.upload"); await cdp(tab.id, "Page.enable", {}); await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: true }); const chooser = waitForFileChooser(tab.id, FILE_CHOOSER_TIMEOUT_MS); let trigger; try { if (!hidden && typeof info.x === "number" && typeof info.y === "number" && info.width > 0 && info.height > 0) { trigger = "chrome"; await cdpMoveTo(tab.id, info.x, info.y); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: info.x, y: info.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" }); await sleep(rng(40, 110)); await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: info.x, y: info.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" }); } else { trigger = "activation-click"; await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: "function() { this.click(); }", userGesture: true, awaitPromise: false }); } const opened = await chooser.promise; if (typeof opened.backendNodeId !== "number") throw new Error("chrome.upload: file chooser opened without an input node"); if (paths.length > 1 && opened.mode === "selectSingle") throw new Error(`chrome.upload: the file chooser accepts one file but ${paths.length} paths were given`); await cdp(tab.id, "DOM.enable", {}).catch(() => undefined); await cdp(tab.id, "DOM.setFileInputFiles", { backendNodeId: opened.backendNodeId, files: paths }); return { input: "chrome", mode: "file-chooser", trigger, reason: trigger === "activation-click" ? "target tab is hidden; Chrome drops trusted input to hidden pages" : undefined, inFrame: info.inFrame || undefined, uploaded: paths.map((path) => ({ path })), }; } finally { chooser.cancel(); await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => undefined); } } // =============================================================== function armKeepaliveAlarm() { chrome.alarms.create("pi-bridge-keepalive", { periodInMinutes: 0.5 }); } chrome.runtime.onInstalled.addListener(() => { chrome.action.setBadgeText({ text: "pi" }); chrome.action.setBadgeBackgroundColor({ color: "#4f46e5" }); armKeepaliveAlarm(); void pollLoop(); }); chrome.runtime.onStartup.addListener(() => { // A new browser run: tab ids from the last run mean nothing now. Promise.resolve(chrome.storage?.local?.remove?.([AUTOMATION_STORAGE_KEY, SESSION_TABS_STORAGE_KEY])).catch(() => {}); armKeepaliveAlarm(); void pollLoop(); }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "pi-bridge-keepalive") void pollLoop(); }); chrome.action.onClicked.addListener(() => { armKeepaliveAlarm(); void pollLoop(); }); armKeepaliveAlarm(); setInterval(() => { void pollLoop(); }, 1000); async function pollLoop() { if (polling) return; polling = true; try { while (true) { const response = await fetchWithTimeout(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, { cache: "no-store", }, POLL_REQUEST_TIMEOUT_MS, "bridge /next"); if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`); const expected = response.headers.get("x-pi-chrome-version"); const ours = chrome.runtime.getManifest().version; if (expected && expected !== ours && isVersionOlder(ours, expected)) { console.warn(`[pi-chrome] extension v${ours} behind pi-chrome v${expected}; reloading extension`); try { chrome.runtime.reload(); } catch {} return; } const payload = await response.json(); if (payload.type === "command") await handleCommand(payload.command); } } catch (error) { await sleep(POLL_ERROR_BACKOFF_MS); } finally { polling = false; } } // fetch() with an abort deadline that covers connecting and receiving response headers. The timer is // cleared as soon as headers arrive so a late abort can never fail an already-delivered response. async function fetchWithTimeout(url, options, timeoutMs, label) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...(options || {}), signal: controller.signal }); } catch (error) { if (error?.name === "AbortError") throw new Error(`${label || "fetch"} timed out after ${timeoutMs}ms`); throw error; } finally { clearTimeout(timer); } } function commandTimeoutMs(action, params) { if (action !== "cdp.call") return COMMAND_TIMEOUT_MS; // Let the inner CDP deadline fire first so callers see "CDP timed out" instead of the // generic command wrapper timeout. return Math.max(COMMAND_TIMEOUT_MS, cdpCallTimeoutMs(params) + CDP_CALL_GRACE_MS); } async function handleCommand(command) { let payload; try { const result = await withTimeout( dispatch(command.action, command.params ?? {}), commandTimeoutMs(command.action, command.params ?? {}), command.action || "Chrome command", () => detachAll(), ); payload = { id: command.id, ok: true, result }; } catch (error) { payload = { id: command.id, ok: false, error: error?.message ?? String(error) }; } // Post exactly one result. A failed success-post used to fall into the catch above and post a // second (error) result for the same command id. try { await postResult(payload); } catch (error) { console.warn(`[pi-chrome] failed to post result for ${command.action}: ${error?.message ?? String(error)}`); } } async function postResult(result) { const body = JSON.stringify(result); let lastError; for (let attempt = 1; attempt <= RESULT_POST_ATTEMPTS; attempt++) { try { const response = await fetchWithTimeout(`${BRIDGE_URL}/result`, { method: "POST", headers: { "content-type": "application/json" }, body, }, RESULT_REQUEST_TIMEOUT_MS, "bridge /result"); // 4xx is final: the bridge no longer knows this command (it already timed out) or rejected // the request. Retrying cannot help. if (response.ok || (response.status >= 400 && response.status < 500)) return; lastError = new Error(`bridge /result HTTP ${response.status}`); } catch (error) { lastError = error; } if (attempt < RESULT_POST_ATTEMPTS) await sleep(250 * attempt); } throw lastError; } function isVersionOlder(a, b) { const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0); const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0); const n = Math.max(pa.length, pb.length); for (let i = 0; i < n; i++) { const x = pa[i] ?? 0, y = pb[i] ?? 0; if (x < y) return true; if (x > y) return false; } return false; } function cleanGroupTitle(value) { const text = String(value || "Pi").replace(/\s+/g, " ").trim().slice(0, 80); return text || "Pi"; } function cleanGroupColor(value) { const color = String(value || DEFAULT_GROUP_COLOR).toLowerCase(); return VALID_GROUP_COLORS.has(color) ? color : DEFAULT_GROUP_COLOR; } async function groupRecord(groupId) { if (typeof groupId !== "number" || groupId < 0 || !chrome.tabGroups) return null; const group = await chrome.tabGroups.get(groupId).catch(() => null); if (!group) return null; return { id: group.id, title: group.title || "", color: group.color || "", collapsed: Boolean(group.collapsed), windowId: group.windowId, piGroup: Boolean(group.title && PI_GROUP_RE.test(group.title)), }; } // Find existing tab groups whose title matches `title` (case-insensitive). // Same-window lookup is used when grouping an already-created tab. Any-window lookup is used before // creating a new Pi tab so one Pi session keeps one tab group and new tabs are created in that // group's window (Chrome tab groups cannot span windows). async function findGroupByTitle(windowId, title) { if (!chrome.tabGroups) return null; const wanted = cleanGroupTitle(title).toLowerCase(); const groups = await chrome.tabGroups.query({ windowId }).catch(() => []); const match = groups.find((g) => (g.title || "").trim().toLowerCase() === wanted); return match ? match.id : null; } async function findGroupRecordByTitle(title) { if (!chrome.tabGroups) return null; const wanted = cleanGroupTitle(title).toLowerCase(); const groups = await chrome.tabGroups.query({}).catch(() => []); return groups.find((g) => (g.title || "").trim().toLowerCase() === wanted) || null; } // Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an // existing same-title group in its window when present, otherwise create a new group. async function groupTab(tab, title, color) { if (!chrome.tabGroups) throw new Error("chrome.tabGroups API unavailable; reload the extension after granting the tabGroups permission"); if (!tab || typeof tab.id !== "number") throw new Error("No tab to group"); const groupTitle = cleanGroupTitle(title); let groupId = tab.groupId; if (typeof groupId !== "number" || groupId < 0) { // Some browsers omit windowId on freshly created tabs; read it back instead of guessing. const windowId = typeof tab.windowId === "number" ? tab.windowId : (await chrome.tabs.get(tab.id).catch(() => null))?.windowId; const existing = await findGroupByTitle(windowId, groupTitle); // Without createProperties.windowId Chrome creates the group in the *current* (user's) window // and moves the tab there, pulling Pi's automation tab out of its own window as a hidden tab. groupId = existing !== null ? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] }) : await chrome.tabs.group(typeof windowId === "number" ? { tabIds: [tab.id], createProperties: { windowId } } : { tabIds: [tab.id] }); } await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false }); const grouped = await chrome.tabs.get(tab.id); return { tab: await formatTab(grouped), group: await groupRecord(groupId) }; } async function dispatch(action, params) { switch (action) { case "tab.version": return { extensionId: chrome.runtime.id, extensionVersion: chrome.runtime.getManifest().version, bridgeUrl: BRIDGE_URL, userAgent: navigator.userAgent, capabilities: { hardBackground: true }, }; case "tab.list": { const tabs = await chrome.tabs.query({}); return Promise.all(tabs.map(formatTab)); } case "tab.new.background": case "page.screenshot.background": // Older workers reject these action names before touching tabs. Do not replace this with // a capability probe followed by an old action: a reload/profile change can race the probe. return dispatch(action.slice(0, -".background".length), { ...params, background: true, foreground: false }); case "tab.new": { // Every Pi-opened tab must join a tab group. There is intentionally no opt-out: an ungrouped // Pi-created tab is easy to lose among user tabs. If grouping fails after creation, close the // tab best-effort before surfacing the error so tab.new never leaves an ungrouped Pi tab. const groupTitle = params.groupTitle || "Pi"; const existingGroup = await findGroupRecordByTitle(groupTitle); const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) }; if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId; // Register the creation as in flight until the tab is tracked, so early capture (which only // runs in tracked tabs) waits for it instead of skipping the new tab's first page load. const creation = (async () => { const created = await chrome.tabs.create(createParams); await trackSessionTab(sessionKeyOf(params), created.id, true); return created; })(); pendingTabCreations.add(creation); let tab; try { tab = await creation; } finally { pendingTabCreations.delete(creation); } try { await bringToFront(tab, params); const grouped = await groupTab(tab, groupTitle, params.groupColor); // chrome.tabs.create returns the t=0 tab ({url:"", status:"loading"}), which reads as "the // URL never loaded". Wait (bounded, never throwing) and report the settled tab instead. if (!params.url || params.url === "about:blank") return grouped; const started = Date.now(); const settled = await waitForTabSettled(tab.id, SNAPSHOT_NAVIGATION_WAIT_MS); const latest = await chrome.tabs.get(tab.id).catch(() => null); return { ...grouped, tab: latest ? await formatTab(latest) : grouped.tab, loadStatus: settled ? "complete" : "timedOut", waitedMs: Date.now() - started, }; } catch (error) { if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {}); throw error; } } case "tab.activate": { if (!foregroundRequested(params)) { throw new Error("Tab activation is blocked by background mode. Ask the user to run /chrome background off to allow foreground work."); } // Management actions never auto-create an automation target (createOwnedTarget:false): with // no explicit target they act on an owned target if one exists, else error — they must never // fall back to (or spawn a tab just to touch) the user's active tab. const tab = await getTabByParams(params, { createOwnedTarget: false }); return formatTab(await bringToFront(tab, params)); } case "tab.group": { const tab = await getTabByParams(params, { createOwnedTarget: false }); const grouped = await groupTab(tab, params.groupTitle || "Pi", params.groupColor); if (!(tab.groupId >= 0)) await trackSessionTab(sessionKeyOf(params), tab.id, false, grouped.group?.id); return grouped; } case "tab.ungroup": { const tab = await getTabByParams(params, { createOwnedTarget: false }); if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id); return formatTab(await chrome.tabs.get(tab.id)); } case "tab.close": { const tab = await getTabByParams(params, { createOwnedTarget: false }); await chrome.tabs.remove(tab.id); return { closed: tab.id }; } case "page.snapshot": return snapshotInTab(params); case "page.inspect": return inspectInTab(params); case "page.evaluate": return evaluateInTab(params); case "page.click": return withOptionalSnapshot(params, chromeInputClick); case "page.hover": return chromeInputHover(params); case "page.drag": return chromeInputDrag(params); case "page.upload": return chromeInputUpload(params); case "page.type": return withOptionalSnapshot(params, chromeInputType); case "page.fill": return withOptionalSnapshot(params, chromeInputFill); case "page.key": return withOptionalSnapshot(params, chromeInputKey); case "page.scroll": return chromeInputScroll(params); case "page.tap": return chromeInputTap(params); case "input.status": return inputStatus(); case "input.debug": return inputDebug(params); case "page.console.list": return executeInTab(params, listConsoleMessages, [params.clear === true]); case "page.network.list": return executeInTab(params, listNetworkRequests, [params.includePreservedRequests === true, params.clear === true]); case "page.network.get": return executeInTab(params, getNetworkRequest, [params.requestId]); case "page.waitFor": { // Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling // loop in-page with new Function() for expression checks, which fails under strict CSP. const tab = await getTabByParams(params); await bringToFront(tab, params); const timeoutMs = params.timeoutMs || 10000; const intervalMs = params.intervalMs || 250; const started = Date.now(); while (Date.now() - started < timeoutMs) { let ok = false; try { const expr = params.kind === "selector" ? `!!document.querySelector(${JSON.stringify(params.value)})` : params.value; ok = Boolean(await evaluateInTab({ ...params, expression: expr, foreground: false })); } catch { ok = false; } if (ok) return { elapsedMs: Date.now() - started }; await sleep(intervalMs); } throw new Error(`Timed out after ${timeoutMs}ms waiting for ${params.kind}: ${params.value}`); } case "page.probe": // Lightweight capability probe for /chrome-doctor. Runs in MAIN world. return executeInTab(params, probePage, []); case "page.navigate": { const tab = await getTabByParams(params); await bringToFront(tab, params); if (params.initScript) { // Register a one-shot document_start content script. We register, navigate, wait, then unregister. await registerInitScript(tab.id, params.initScript); } const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined); const updated = await chrome.tabs.update(tab.id, { url: params.url }); try { await wait; } finally { if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined); } return await formatTab(await chrome.tabs.get(updated.id)); } case "page.screenshot": return takeScreenshot(params); case "cdp.targets": { // Diagnostics: CDP targets anchored to the resolved tab, including foreign overlay targets // (password managers, autofill, DevTools front-ends) that explain "Detached while handling // command" failures. Never creates an automation window. Only the resolved tab's targets are // echoed; targets on other tabs are counted, not listed. const hasSelector = params.targetId !== undefined || Boolean(params.urlIncludes) || Boolean(params.titleIncludes); const tab = await getTabByParams(params, { createOwnedTarget: false }).catch((error) => { if (hasSelector) throw error; return null; }); const allTargets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))).catch(() => []); const targets = tab ? allTargets.filter((t) => t.tabId === tab.id) : []; return { tab: tab ? { id: tab.id, windowId: tab.windowId, url: tab.url, title: tab.title } : null, targets: targets.map((t) => ({ id: t.id, tabId: t.tabId, type: t.type, url: t.url, title: t.title, attached: t.attached, extensionId: t.extensionId })), otherTabTargetCount: allTargets.filter((t) => typeof t.tabId === "number" && t.tabId !== tab?.id).length, }; } case "cdp.call": { // Validate before touching the debugger: a clear error beats Chrome's opaque // "Invalid parameters" for a missing method or non-object params. if (typeof params.method !== "string" || !params.method.trim()) { throw new Error('cdp.call requires a non-empty string "method" (for example "Runtime.evaluate" or "DOM.getDocument").'); } if (params.params !== undefined && params.params !== null && (typeof params.params !== "object" || Array.isArray(params.params))) { throw new Error('cdp.call "params" must be a plain object of CDP parameters when provided.'); } const method = params.method.trim(); // Background mode is a hard policy. Raw CDP must not become a way around it. if (!foregroundRequested(params) && CDP_FOCUS_METHODS.has(method)) { throw new Error(`${method} is blocked by background mode because it focuses or activates a tab. Ask the user to run /chrome background off to allow foreground work.`); } const tab = await getTabByParams(params); await bringToFront(tab, params); await attachDebugger(tab.id); return await cdp(tab.id, method, params.params ?? {}, { timeoutMs: cdpCallTimeoutMs(params) }); } case "automation.status": { // Report this session's owned automation target (ids only). Used for diagnostics/tests. await hydrateAutomationTargets(); const t = automationTargets.get(sessionKeyOf(params)); const status = { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null }; if (!params.includeVisibility || !t) return status; // For /chrome doctor: is this session's own tab visible? Never creates or activates anything. const tab = await chrome.tabs.get(t.tabId).catch(() => null); if (!tab) return { ...status, tabId: null }; const results = await executeScriptWithFallback({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", func: () => document.visibilityState, }, `read visibility of tab ${tab.id}`).catch(() => null); const visibility = typeof results?.[0]?.result === "string" ? results[0].result : "unknown"; const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(tab.windowId).catch(() => null) : null; return { ...status, visibility, url: tab.url, hiddenReason: visibility === "hidden" ? hiddenTabReason(tab, win) : undefined, }; } case "automation.cleanup": // Close recorded creations, and only ungroup user tabs still in their adopted group. // Group titles are not ownership evidence. return cleanupSessionTabs(sessionKeyOf(params)); default: throw new Error(`Unknown action: ${action}`); } } async function formatTab(tab) { return { id: tab.id, windowId: tab.windowId, active: tab.active, highlighted: tab.highlighted, title: tab.title || "", url: tab.url || "", status: tab.status, pinned: tab.pinned, incognito: tab.incognito, groupId: typeof tab.groupId === "number" ? tab.groupId : -1, group: await groupRecord(tab.groupId), }; } // Resolve which Chrome tab an action targets. // // Explicit targeting (targetId / urlIncludes / titleIncludes) is unchanged: callers can still act // on any existing tab, including a user tab, when they ask for it by name. Only the implicit // "no target given" case changed — it used to grab the user's *active* tab (and page.navigate // would then overwrite it); it now resolves to this Pi session's dedicated automation target. // // `createOwnedTarget` controls the implicit case: // - true (default): create the automation target on first use. Used by every page/content // action — page.navigate, click/type/fill/key/hover/drag/scroll/tap/upload, snapshot, // inspect, evaluate, screenshot, waitFor, console/network list, probe. These need a live // surface to drive, so auto-creating is correct and they no longer touch the user's tab. // - false: do NOT create. Used by tab.activate/close/group/ungroup (tab *management*): with no // explicit target they operate on an already-owned automation target if one exists, else // throw asking for an explicit target — so e.g. `chrome_tab close` can never silently close // the user's active tab the way it used to, and never spawns a throwaway tab just to close it. async function getTabByParams(params, { createOwnedTarget = true } = {}) { const tabs = await chrome.tabs.query({}); let tab; if (params.targetId !== undefined) { const id = Number(params.targetId); tab = await chrome.tabs.get(id).catch(() => null); if (!tab?.id) { // Chrome tab ids are not stable across reloads/navigations; a long session can hold a // stale id. Surface the current tabs so the caller can re-target instead of guessing. const listed = tabs .filter((candidate) => candidate.id !== undefined) .slice(0, 20) .map((candidate) => ` ${candidate.id}${candidate.active ? " *" : ""}\t${(candidate.title || "(untitled)").slice(0, 60)}\t${candidate.url || ""}`) .join("\n"); throw new Error( `No Chrome tab with id ${id} (it was likely closed or replaced). ` + `Re-target with chrome_tab list, or pass urlIncludes/titleIncludes instead of targetId.\n` + `Current tabs:\n${listed || " (none)"}`, ); } } else if (params.urlIncludes) { tab = tabs.find((candidate) => (candidate.url || "").includes(params.urlIncludes)); } else if (params.titleIncludes) { tab = tabs.find((candidate) => (candidate.title || "").includes(params.titleIncludes)); } else { // No explicit target: use this session's dedicated automation target instead of hijacking the // user's active tab. This keeps human browsing and Pi automation separated — navigating here // never replaces whatever the user currently has open. Callers that *want* a specific // existing tab pass targetId/urlIncludes/titleIncludes above. const sessionKey = sessionKeyOf(params); tab = createOwnedTarget ? await getOrCreateAutomationTarget(sessionKey, params.sessionGroupTitle) : await resolveOwnedAutomationTarget(sessionKey); if (!tab) { throw new Error( "No target tab specified and this Pi session has no automation tab yet. " + "Pass targetId/urlIncludes/titleIncludes, or run chrome_navigate first.", ); } } if (!tab?.id) throw new Error("No matching Chrome tab found"); const url = tab.url || ""; if (url.startsWith("chrome://") || url.startsWith("chrome-extension://") || url.startsWith("devtools://")) { throw new Error(`Chrome blocks extension automation on protected URL: tab=${tab.id} url=${url}`); } // Tabs Pi interacts with (page.* actions) join this session's group so the user can see exactly // which tabs Pi is driving. We only adopt *ungrouped* tabs — never hijack a tab the user (or // another Pi session) already grouped, since groupTab would otherwise rename that group. if (params.joinSessionGroup && params.sessionGroupTitle) { await joinSessionGroup(tab, params.sessionGroupTitle, sessionKeyOf(params)); } return tab; } // Add an ungrouped tab to the session's tab group (reusing it by title, else creating it). // No-op when the tab is already grouped or tabGroups is unavailable. async function joinSessionGroup(tab, title, sessionKey) { if (!chrome.tabGroups || typeof tab.id !== "number") return; if (typeof tab.groupId === "number" && tab.groupId >= 0) return; try { const grouped = await groupTab(tab, title); await trackSessionTab(sessionKey, tab.id, false, grouped.group?.id); } catch { // Grouping is best-effort; never block the actual page action on a grouping failure. } } // Helper sources that get concatenated into the injected MAIN-world script. Kept as separate // functions so callers below can reference them by `.toString()`. The helpers do not perform any // eval themselves — they're plain function declarations. const HELPER_FUNCS = [ getPiChromeState, rememberElement, elementBySelectorOrUid, installPiChromeInstrumentation, resolvePoint, dispatchInputEvents, setNativeValue, normalizeKey, isElementVisible, occluderAt, pageHash, pointerEventSequence, sleepPage, rand, dispatchPointerLikeEvent, humanMoveTo, humanClickPoint, usKeyLayoutForChar, printableKeyCode, dispatchKeyEvent, typeCharacter, pressKeyInPage, scrollPage, ]; async function executeInTab(params, func, args) { const tab = await getTabByParams(params); await bringToFront(tab, params); // Phase 1: define the helpers and the action function as page globals via CDP // Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the // root cause of snapshot/click/etc silently failing on `script-src 'self'` sites. // Each helper is a named function declaration, assigned to window. so the action // (which references helpers by bare name) resolves them as globals at call time. const assignments = HELPER_FUNCS.map((helper) => `window.${helper.name}=${helper.toString()}`).join(";\n"); const actionAssign = `window.__piAction=(${func.toString()})`; const defineRes = await cdpEval(tab.id, `(()=>{${assignments};\n${actionAssign};})()`); if (defineRes.exceptionDetails) { throw new Error(`Failed to inject Chrome page helpers: ${cdpExceptionText(defineRes.exceptionDetails) || "unknown error"}`); } // Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is // injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome // serialize the invocation args. The wrapper references window.__piAction defined above. const results = await executeScriptWithFallback({ target: { tabId: tab.id }, world: "MAIN", func: async (invocationArgs) => { try { return { ok: true, value: await window.__piAction(...invocationArgs) }; } catch (error) { return { ok: false, error: error?.stack || error?.message || String(error) }; } }, args: [args || []], }, `execute page action in tab ${tab.id}`); const first = results?.[0]; if (first?.error) { const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error)); throw new Error(message); } const envelope = first?.result; if (envelope && typeof envelope === "object" && envelope.ok === false) { throw new Error(envelope.error || "Chrome page script failed"); } return envelope?.value; } // Serializer for page.evaluate results. Embedded (via .toString()) into the CDP-evaluated // expression so we can return rich markers for values that don't survive returnByValue // (undefined/function/symbol/bigint/Error), plus expand DOMRect-like objects whose fields // are non-enumerable. Kept as a standalone function so it stays editable/lintable. function piEvalStringify(v) { if (v === undefined) return { kind: "undefined" }; if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) }; if (typeof v === "symbol") return { kind: "symbol", description: v.description }; if (typeof v === "bigint") return { kind: "bigint", value: v.toString() }; if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack }; // DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable // properties, so JSON.stringify yields `{}`. Expand the fields explicitly. if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) || (typeof DOMRect !== "undefined" && v instanceof DOMRect) || (v && typeof v === "object" && typeof v.toJSON === "function" && typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) { return { x: v.x, y: v.y, width: v.width, height: v.height, top: v.top, right: v.right, bottom: v.bottom, left: v.left }; } return v; } // Dedicated executor for page.evaluate. Uses CDP Runtime.evaluate (via cdpEval) which is not // subject to the page's CSP, fixing `chrome_evaluate` silently returning null / failing on // pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`). async function evaluateInTab(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); const expression = String(params.expression ?? ""); const stringifySrc = `(${piEvalStringify.toString()})`; // Wrap the user expression so the result is run through piEvalStringify in-page before it // crosses the returnByValue boundary. Try expression form first (so `1+1` / `document.title` // work without `return`); on a SyntaxError fall back to statement form for multi-statement // bodies (loops, var decls, etc), matching the previous new Function() two-form behavior. const buildWrapper = (form) => `(async () => { const __s=${stringifySrc}; const __v = await ${form}; return __s(__v); })()`; const exprForm = `(async () => (${expression}))()`; const stmtForm = `(async () => { ${expression} })()`; let res = await cdpEval(tab.id, buildWrapper(exprForm)); if (res.exceptionDetails && cdpIsSyntaxError(res.exceptionDetails)) { res = await cdpEval(tab.id, buildWrapper(stmtForm)); } if (res.exceptionDetails) { throw new Error(`chrome_evaluate failed: ${cdpExceptionText(res.exceptionDetails) || "evaluation failed"}`); } const result = res.result; if (!result || result.type === "undefined") return undefined; const v = result.value; // Unwrap special markers produced by piEvalStringify. if (v && typeof v === "object" && !Array.isArray(v)) { if (v.kind === "undefined") return undefined; if (v.kind === "function") return `[Function: ${v.source}]`; if (v.kind === "symbol") return `[Symbol: ${v.description}]`; if (v.kind === "bigint") return v.value; if (v.kind === "error") throw new Error(`${v.name}: ${v.message}\n${v.stack || ""}`); } return v; } // Run an input action, then optionally snapshot. An action that starts a navigation (Enter submits a // form, a click follows a link) used to be snapshotted immediately, so the snapshot described the // OUTGOING document and read as "the action did nothing". Anchor the tab's url/status first; when // the action plausibly started a load, wait (bounded) for it before snapshotting and report // navigation {from, to, settled, waitedMs}. A timeout never fails the action. A tab that was already // loading with the same URL is reported but not waited on, so a hanging subresource cannot add a wait // to every action. const SNAPSHOT_NAVIGATION_WAIT_MS = 5_000; async function withOptionalSnapshot(params, actionFn) { if (!params.includeSnapshot) return actionFn(params); let anchor = null; try { const tab = await getTabByParams(params); if (tab && typeof tab.id === "number") { anchor = { tabId: tab.id, params: { ...params, targetId: String(tab.id) }, url: String(tab.url ?? ""), status: String(tab.status ?? "") }; } } catch { anchor = null; // Let the action surface the targeting error itself. } const result = await actionFn(anchor ? anchor.params : params); let navigation; if (anchor) { const after = await chrome.tabs.get(anchor.tabId).catch(() => null); const afterUrl = String(after?.url ?? ""); const urlChanged = Boolean(afterUrl && afterUrl !== anchor.url); const loading = after?.status === "loading"; if (urlChanged || loading) { const started = Date.now(); const startedLoading = loading && (urlChanged || anchor.status === "complete"); let settled = !loading; if (startedLoading) { settled = await waitForTabSettled(anchor.tabId, SNAPSHOT_NAVIGATION_WAIT_MS); } const finalTab = await chrome.tabs.get(anchor.tabId).catch(() => after); navigation = { from: anchor.url, to: String(finalTab?.url ?? afterUrl), settled, waitedMs: Date.now() - started }; } } const snapshot = await snapshotInTab({ ...(anchor ? anchor.params : params), foreground: false }); return navigation ? { result, snapshot, navigation } : { result, snapshot }; } // Snapshot/inspect run from a packaged MAIN-world script (snapshot_injected.js) injected via // chrome.scripting.executeScript({ files }). That file is free of eval/new Function, so it works // on strict-CSP pages, and it installs globalThis.__piChromeSnapshotPage / __piChromeInspectTarget. // It shares window.__PI_CHROME_STATE__ (same el- uid scheme) with the CDP-injected input helpers. async function snapshotInTab(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); const args = [ params.maxElements || 80, params.containingText ?? null, params.roleFilter ?? null, params.nearUid ?? null, params.mode || "auto", params.query ?? null, params.maxTextChars ?? null, ]; await executeScriptWithFallback({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", files: ["snapshot_injected.js"], }, `inject snapshot script in tab ${tab.id}`); const results = await executeScriptWithFallback({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", func: async (invocationArgs) => { try { const snapshotPage = globalThis.__piChromeSnapshotPage; if (typeof snapshotPage !== "function") throw new Error("snapshot_injected.js did not install __piChromeSnapshotPage"); return { ok: true, value: await snapshotPage(...invocationArgs) }; } catch (error) { return { ok: false, error: error?.stack || error?.message || String(error) }; } }, args: [args], }, `run snapshot script in tab ${tab.id}`); const first = results?.[0]; if (first?.error) { const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error)); throw new Error(message); } const envelope = first?.result; if (envelope && typeof envelope === "object" && envelope.ok === false) { throw new Error(envelope.error || "Chrome snapshot script failed"); } return envelope?.value; } async function inspectInTab(params) { if (!params.uid && !params.selector) throw new Error("chrome_inspect requires uid or selector"); const tab = await getTabByParams(params); await bringToFront(tab, params); const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true]; await executeScriptWithFallback({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", files: ["snapshot_injected.js"], }, `inject inspect script in tab ${tab.id}`); const results = await executeScriptWithFallback({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", func: async (invocationArgs) => { try { const inspectTarget = globalThis.__piChromeInspectTarget; if (typeof inspectTarget !== "function") throw new Error("snapshot_injected.js did not install __piChromeInspectTarget"); return { ok: true, value: await inspectTarget(...invocationArgs) }; } catch (error) { return { ok: false, error: error?.stack || error?.message || String(error) }; } }, args: [args], }, `run inspect script in tab ${tab.id}`); const first = results?.[0]; if (first?.error) { const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error)); throw new Error(message); } const envelope = first?.result; if (envelope && typeof envelope === "object" && envelope.ok === false) { throw new Error(envelope.error || "Chrome inspect script failed"); } return envelope?.value; } // One-shot init script registry, scoped per tab. The source is registered with CDP // Page.addScriptToEvaluateOnNewDocument, which runs it at document_start in the page's MAIN // world and is NOT subject to page CSP (the old func:(code)=>new Function(code) path was // blocked by `script-src 'self'`). page.navigate registers before the nav and unregisters // after load, so only the intended navigation receives the script. const initScriptIds = new Map(); // tabId -> CDP script identifier async function registerInitScript(tabId, source) { await attachDebugger(tabId); await cdp(tabId, "Page.enable", {}).catch(() => undefined); const result = await cdp(tabId, "Page.addScriptToEvaluateOnNewDocument", { source }); if (result && result.identifier !== undefined) initScriptIds.set(tabId, result.identifier); } async function unregisterInitScript(tabId) { const identifier = initScriptIds.get(tabId); if (identifier === undefined) return; initScriptIds.delete(tabId); await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined); } // Inject early console/network capture at document_start, but only into tabs Pi owns or has used // (its automation tab and tabs tracked for a session). Your other tabs are never patched. Catches // console messages, errors, and network requests that fire during page load, before chrome_snapshot // or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped // flags so the post-hoc installPiChromeInstrumentation() call is idempotent. const pendingTabCreations = new Set(); // in-flight Pi tab creations, resolved once tracked async function isPiTrackedTab(tabId) { await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]); // A tab Pi is creating right now can commit its first page before tab.new has tracked it. if (pendingTabCreations.size) await Promise.allSettled([...pendingTabCreations]); if (isPiChromeOwnedTarget(tabId)) return true; for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true; return false; } if (chrome.webNavigation && chrome.webNavigation.onCommitted) { chrome.webNavigation.onCommitted.addListener(async (details) => { if (details.frameId !== 0) return; if (!(await isPiTrackedTab(details.tabId).catch(() => false))) return; chrome.scripting.executeScript({ target: { tabId: details.tabId, frameIds: [0] }, world: "MAIN", injectImmediately: true, func: installEarlyCapture, args: [], }).catch(() => undefined); }); } function foregroundRequested(params) { // Fail quiet when unspecified, and let background veto even a contradictory foreground flag. return params?.foreground === true && params.background !== true; } async function bringToFront(tab, params) { if (!foregroundRequested(params)) return tab; await chrome.windows.update(tab.windowId, { focused: true }); return chrome.tabs.update(tab.id, { active: true }); } function waitForTabComplete(tabId, timeoutMs) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); reject(new Error(`Timed out after ${timeoutMs}ms waiting for tab ${tabId} to load`)); }, timeoutMs); const listener = (updatedTabId, changeInfo) => { if (updatedTabId === tabId && changeInfo.status === "complete") { clearTimeout(timer); chrome.tabs.onUpdated.removeListener(listener); resolve(true); } }; chrome.tabs.onUpdated.addListener(listener); }); } // Like waitForTabComplete, but for a load that may already be underway (or already finished): // re-checks the tab after subscribing so a completion between the caller's read and the listener // registration is not missed. Resolves true when complete, false on timeout; never throws. function waitForTabSettled(tabId, timeoutMs) { return new Promise((resolve) => { let done = false; const finish = (value) => { if (done) return; done = true; clearTimeout(timer); chrome.tabs.onUpdated.removeListener(listener); resolve(value); }; const timer = setTimeout(() => finish(false), timeoutMs); const listener = (updatedTabId, changeInfo) => { if (updatedTabId === tabId && changeInfo.status === "complete") finish(true); }; chrome.tabs.onUpdated.addListener(listener); chrome.tabs.get(tabId).then((tab) => { if (!tab) finish(false); else if (tab.status === "complete") finish(true); }, () => finish(false)); }); } async function captureTabScreenshot(tabId, params) { const format = params.format || "png"; try { await attachDebugger(tabId); const captureParams = { format, fromSurface: true, captureBeyondViewport: false }; if (format === "jpeg" && params.quality !== undefined) captureParams.quality = params.quality; const result = await cdp(tabId, "Page.captureScreenshot", captureParams); if (typeof result?.data !== "string" || !result.data) throw new Error("CDP returned no screenshot data"); return `data:image/${format};base64,${result.data}`; } catch (error) { // captureVisibleTab requires activation and can race with the user switching tabs. Never // use it as a fallback, even when debugger attachment or background rendering fails. throw new Error(`Chrome screenshot via CDP failed; no tab-activation fallback was attempted. ${error?.message || error}`); } } async function takeScreenshot(params) { const tab = await bringToFront(await getTabByParams(params), params); if (params.fullPage) { // Preserve the existing tile + manifest contract. Every tile captures the same resolved tab, // without activation; selector/title changes during capture must not retarget later tiles. const targetParams = { ...params, targetId: tab.id, foreground: false }; const tiles = await executeInTab(targetParams, captureFullPageTiles, []); const captured = []; try { for (const tile of tiles.tiles) { await executeInTab(targetParams, scrollToY, [tile.scrollY]); await sleep(120); // Let scroll/lazy-load handlers settle. captured.push({ y: tile.y, dataUrl: await captureTabScreenshot(tab.id, params) }); } } finally { // A failed tile must not strand the page at a new scroll position. Restore both axes // best-effort, without masking the capture error if the tab/debugger is gone. await executeInTab(targetParams, scrollToY, [tiles.originalScrollY, tiles.originalScrollX]).catch(() => undefined); } return { fullPage: true, method: "cdp", tab: await formatTab(tab), dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr }, tiles: captured, }; } const dataUrl = await captureTabScreenshot(tab.id, params); return { dataUrl, method: "cdp", tab: await formatTab(tab) }; } // --------------------------------------------------------------------------- // MAIN-world helpers (function declarations injected into the page). // --------------------------------------------------------------------------- function getPiChromeState() { const state = window.__PI_CHROME_STATE__ || { nextElementUid: 1, elements: {}, console: [], network: [], nextRequestId: 1, instrumentationInstalled: false, }; window.__PI_CHROME_STATE__ = state; return state; } function rememberElement(element) { const state = getPiChromeState(); if (!element.__piChromeUid) element.__piChromeUid = "el-" + state.nextElementUid++; state.elements[element.__piChromeUid] = element; return element.__piChromeUid; } function elementBySelectorOrUid(selector, uid) { if (uid) { const element = getPiChromeState().elements[uid]; if (!element || !element.isConnected) throw new Error(`No live element for uid: ${uid}. Take a fresh chrome_snapshot.`); return element; } if (selector) { const element = document.querySelector(selector); if (!element) throw new Error(`No element matches selector: ${selector}`); return element; } return null; } function isElementVisible(element) { if (!element || !element.getBoundingClientRect) return false; const style = getComputedStyle(element); if (style.visibility === "hidden" || style.display === "none") return false; const rect = element.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return false; if (rect.bottom < 0 || rect.right < 0) return false; if (rect.top > innerHeight || rect.left > innerWidth) return false; return true; } function occluderAt(x, y, expected) { const top = document.elementFromPoint(x, y); if (!top || top === expected) return null; if (expected && expected.contains(top)) return null; if (top.contains(expected)) return null; return { tag: top.tagName.toLowerCase(), id: top.id || undefined, className: typeof top.className === "string" ? top.className : undefined, }; } function pageHash() { // Cheap rolling hash used for `pageMutated`. Combines first 4kb of body innerText with the // current values of inputs/textareas (which are not part of innerText) and the count of // descendants of . This catches: text changes, input value edits, and DOM structure // changes — the three things a click/type/fill might cause. const body = document.body; const text = (body ? body.innerText : "").slice(0, 4000); let h = 0; for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0; if (body) { const inputs = body.querySelectorAll("input,textarea,select"); let valueBlob = ""; for (let i = 0; i < inputs.length && valueBlob.length < 4000; i++) { const v = inputs[i].value; if (typeof v === "string") valueBlob += v + "\x00"; } for (let i = 0; i < valueBlob.length; i++) h = (h * 31 + valueBlob.charCodeAt(i)) | 0; h = (h * 31 + body.getElementsByTagName("*").length) | 0; } return h; } function sleepPage(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function rand(min, max) { return min + Math.random() * (max - min); } function dispatchPointerLikeEvent(element, type, x, y, prevX, prevY, opts = {}) { const isPointer = type.startsWith("pointer"); const Ctor = isPointer ? PointerEvent : MouseEvent; const isMove = type === "pointermove" || type === "mousemove"; const isUpOrClick = type === "pointerup" || type === "mouseup" || type === "click"; const init = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x + (window.screenX || 0), screenY: y + (window.screenY || 0), movementX: Number.isFinite(prevX) ? x - prevX : 0, movementY: Number.isFinite(prevY) ? y - prevY : 0, button: 0, buttons: isMove || isUpOrClick ? 0 : 1, }; if (isPointer) { init.pointerType = "mouse"; init.pointerId = 1; init.isPrimary = true; init.width = 1; init.height = 1; init.pressure = opts.pressure ?? (type === "pointerdown" ? 0.5 : 0); init.tangentialPressure = 0; init.tiltX = 0; init.tiltY = 0; } const ev = new Ctor(type, init); element.dispatchEvent(ev); return ev.defaultPrevented; } function pointerEventSequence(element, x, y, sequence) { let defaultPrevented = false; const state = getPiChromeState(); const prevX = state.pointer?.x; const prevY = state.pointer?.y; for (const type of sequence) { defaultPrevented = dispatchPointerLikeEvent(element, type, x, y, prevX, prevY) || defaultPrevented; } state.pointer = { x, y, t: performance.now() }; return defaultPrevented; } async function humanMoveTo(x, y, steps) { const state = getPiChromeState(); const startX = Number.isFinite(state.pointer?.x) ? state.pointer.x : rand(12, Math.max(24, innerWidth - 12)); const startY = Number.isFinite(state.pointer?.y) ? state.pointer.y : rand(12, Math.max(24, innerHeight - 12)); const n = steps || Math.max(12, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18))); let prevX = startX, prevY = startY; let defaultPrevented = false; for (let i = 1; i <= n; i++) { const t = i / n; const ease = t * t * (3 - 2 * t); const wobble = Math.sin(t * Math.PI) * 8; const px = startX + (x - startX) * ease + rand(-wobble, wobble); const py = startY + (y - startY) * ease + rand(-wobble, wobble); const el = document.elementFromPoint(px, py) || document.body || document.documentElement; defaultPrevented = dispatchPointerLikeEvent(el, "pointermove", px, py, prevX, prevY) || defaultPrevented; defaultPrevented = dispatchPointerLikeEvent(el, "mousemove", px, py, prevX, prevY) || defaultPrevented; prevX = px; prevY = py; await sleepPage(rand(4, 18)); } state.pointer = { x, y, t: performance.now() }; return defaultPrevented; } function humanClickPoint(point) { if (!point.rect) return { x: point.x, y: point.y }; const rect = point.rect; const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1)); const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1)); return { x: rect.left + rect.width / 2 + rand(-insetX, insetX), y: rect.top + rect.height / 2 + rand(-insetY, insetY), }; } function installPiChromeInstrumentation() { const state = getPiChromeState(); if (state.instrumentationInstalled) return; state.instrumentationInstalled = true; const pushConsole = (level, args) => { state.console.push({ id: state.console.length + 1, level, timestamp: Date.now(), url: location.href, // Keep the raw values and serialize only when Pi lists messages. Serializing here reads every // logged object (getters, toString), which is exactly what anti-DevTools checks watch for. args: Array.from(args), }); if (state.console.length > 500) state.console.splice(0, state.console.length - 500); }; for (const level of ["debug", "log", "info", "warn", "error"]){ const original = console[level]; if (typeof original !== "function" || original.__piChromeWrapped) continue; const wrapped = function(...args) { pushConsole(level, args); return original.apply(this, args); }; wrapped.__piChromeWrapped = true; console[level] = wrapped; } window.addEventListener("error", (event) => pushConsole("pageerror", [event.message, event.filename + ":" + event.lineno + ":" + event.colno])); window.addEventListener("unhandledrejection", (event) => pushConsole("unhandledrejection", [event.reason])); const trimBody = (text) => typeof text === "string" && text.length > 200000 ? text.slice(0, 200000) + `\n[truncated ${text.length - 200000} chars]` : text; const record = (entry) => { state.network.push(entry); if (state.network.length > 1000) state.network.splice(0, state.network.length - 1000); return entry; }; if (window.fetch && !window.fetch.__piChromeWrapped) { const originalFetch = window.fetch.bind(window); const wrappedFetch = async (...args) => { const id = "req-" + state.nextRequestId++; const startedAt = Date.now(); const input = args[0]; const init = args[1] || {}; const url = typeof input === "string" ? input : input?.url; const method = (init.method || input?.method || "GET").toUpperCase(); const entry = record({ id, type: "fetch", method, url: String(url || ""), startedAt, pageUrl: location.href, status: "pending" }); try { const response = await originalFetch(...args); entry.status = response.status; entry.statusText = response.statusText; entry.ok = response.ok; entry.responseUrl = response.url; entry.durationMs = Date.now() - startedAt; entry.responseHeaders = Array.from(response.headers.entries()); response.clone().text().then((text) => { entry.responseBody = trimBody(text); entry.responseBodyTruncated = typeof text === "string" && text.length > 200000; }).catch((error) => { entry.responseBodyError = error?.message || String(error); }); return response; } catch (error) { entry.error = error?.message || String(error); entry.durationMs = Date.now() - startedAt; throw error; } }; wrappedFetch.__piChromeWrapped = true; window.fetch = wrappedFetch; } if (window.XMLHttpRequest && !XMLHttpRequest.prototype.open.__piChromeWrapped) { const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url, ...rest) { this.__piChromeRequest = { method: String(method || "GET").toUpperCase(), url: String(url || "") }; return originalOpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.open.__piChromeWrapped = true; XMLHttpRequest.prototype.send = function(body) { const id = "req-" + state.nextRequestId++; const startedAt = Date.now(); const info = this.__piChromeRequest || {}; const entry = record({ id, type: "xhr", method: info.method || "GET", url: info.url || "", startedAt, pageUrl: location.href, status: "pending" }); this.addEventListener("loadend", () => { entry.status = this.status; entry.statusText = this.statusText; entry.responseUrl = this.responseURL; entry.durationMs = Date.now() - startedAt; try { entry.responseHeadersText = this.getAllResponseHeaders(); } catch {} try { if (typeof this.responseText === "string") { entry.responseBody = trimBody(this.responseText); entry.responseBodyTruncated = this.responseText.length > 200000; } } catch (error) { entry.responseBodyError = error?.message || String(error); } }); this.addEventListener("error", () => { entry.error = "XMLHttpRequest error"; entry.durationMs = Date.now() - startedAt; }); return originalSend.call(this, body); }; } } // Early-capture version of installPiChromeInstrumentation, designed to be injected // at document_start via webNavigation.onCommitted. Wraps console, fetch, and XHR // before the page's own JavaScript runs, so page-load errors are captured. // Sets __piChromeWrapped flags so the post-hoc installPiChromeInstrumentation() // sees them and skips (idempotent). // NOTE: This function is self-contained — it does NOT close over any outer scope // because it gets serialized by chrome.scripting.executeScript({func: ...}). function installEarlyCapture() { if (window.__piChromeEarlyCaptureInstalled) return; window.__piChromeEarlyCaptureInstalled = true; var state = window.__PI_CHROME_STATE__; if (!state) { state = { nextElementUid: 1, elements: {}, console: [], network: [], nextRequestId: 1, instrumentationInstalled: false, }; window.__PI_CHROME_STATE__ = state; } function pushConsole(level, args) { state.console.push({ id: state.console.length + 1, level: level, timestamp: Date.now(), url: location.href, args: Array.from(args), // raw; serialized on read (see listConsoleMessages) }); if (state.console.length > 500) state.console.splice(0, state.console.length - 500); } for (var i = 0; i < 5; i++) { var levels = ["debug", "log", "info", "warn", "error"]; var level = levels[i]; var original = console[level]; if (typeof original !== "function" || original.__piChromeWrapped) continue; var wrapped = function(lvl, orig) { return function() { pushConsole(lvl, arguments); return orig.apply(this, arguments); }; }(level, original); wrapped.__piChromeWrapped = true; console[level] = wrapped; } window.addEventListener("error", function(event) { pushConsole("pageerror", [event.message, event.filename + ":" + event.lineno + ":" + event.colno]); }); window.addEventListener("unhandledrejection", function(event) { pushConsole("unhandledrejection", [event.reason]); }); var trimBody = function(text) { return typeof text === "string" && text.length > 200000 ? text.slice(0, 200000) + "\n[truncated " + (text.length - 200000) + " chars]" : text; }; var record = function(entry) { state.network.push(entry); if (state.network.length > 1000) state.network.splice(0, state.network.length - 1000); return entry; }; if (window.fetch && !window.fetch.__piChromeWrapped) { var originalFetch = window.fetch.bind(window); var wrappedFetch = async function() { var args = []; for (var k = 0; k < arguments.length; k++) args.push(arguments[k]); var id = "req-" + state.nextRequestId++; var startedAt = Date.now(); var input = args[0]; var init = args[1] || {}; var url = typeof input === "string" ? input : (input ? input.url : ""); var method = (init.method || (input ? input.method : null) || "GET").toUpperCase(); var entry = record({ id: id, type: "fetch", method: method, url: String(url || ""), startedAt: startedAt, pageUrl: location.href, status: "pending" }); try { var response = await originalFetch.apply(window, args); entry.status = response.status; entry.statusText = response.statusText; entry.ok = response.ok; entry.responseUrl = response.url; entry.durationMs = Date.now() - startedAt; entry.responseHeaders = Array.from(response.headers.entries()); response.clone().text().then(function(text) { entry.responseBody = trimBody(text); entry.responseBodyTruncated = typeof text === "string" && text.length > 200000; }).catch(function(error) { entry.responseBodyError = error ? error.message : String(error); }); return response; } catch (error) { entry.error = error ? error.message : String(error); entry.durationMs = Date.now() - startedAt; throw error; } }; wrappedFetch.__piChromeWrapped = true; window.fetch = wrappedFetch; } if (window.XMLHttpRequest && !XMLHttpRequest.prototype.open.__piChromeWrapped) { var originalOpen = XMLHttpRequest.prototype.open; var originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url) { this.__piChromeRequest = { method: String(method || "GET").toUpperCase(), url: String(url || "") }; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.open.__piChromeWrapped = true; XMLHttpRequest.prototype.send = function(body) { var id = "req-" + state.nextRequestId++; var startedAt = Date.now(); var info = this.__piChromeRequest || {}; var entry = record({ id: id, type: "xhr", method: info.method || "GET", url: info.url || "", startedAt: startedAt, pageUrl: location.href, status: "pending" }); this.addEventListener("loadend", function() { entry.status = this.status; entry.statusText = this.statusText; entry.responseUrl = this.responseURL; entry.durationMs = Date.now() - startedAt; try { entry.responseHeadersText = this.getAllResponseHeaders(); } catch (e) {} try { if (typeof this.responseText === "string") { entry.responseBody = trimBody(this.responseText); entry.responseBodyTruncated = this.responseText.length > 200000; } } catch (error) { entry.responseBodyError = error ? error.message : String(error); } }); this.addEventListener("error", function() { entry.error = "XMLHttpRequest error"; entry.durationMs = Date.now() - startedAt; }); return originalSend.apply(this, arguments); }; } state.instrumentationInstalled = true; } function probePage() { // Sanity probe used by /chrome-doctor. Returns evidence that MAIN-world execution works. return { arithmetic: 1 + 1, location: location.href, title: document.title, documentReady: document.readyState, userAgent: navigator.userAgent.slice(0, 200), webdriver: !!navigator.webdriver, }; } function captureFullPageTiles() { // Returns the plan for CDP tile capture in the worker: scroll positions and page metrics. const html = document.documentElement; const body = document.body; const width = Math.max(html.scrollWidth, body ? body.scrollWidth : 0, innerWidth); const height = Math.max(html.scrollHeight, body ? body.scrollHeight : 0, innerHeight); const viewportHeight = innerHeight; const dpr = window.devicePixelRatio || 1; const originalScrollY = scrollY; const originalScrollX = scrollX; const tiles = []; let y = 0; while (y < height) { tiles.push({ y, scrollY: y }); y += viewportHeight; } return { width, height, viewportHeight, dpr, originalScrollY, originalScrollX, tiles }; } function scrollToY(y, x = 0) { window.scrollTo({ top: y, left: x, behavior: "instant" }); return { scrollY }; } function resolvePoint(selector, uid, x, y) { const element = elementBySelectorOrUid(selector, uid); if (element) { element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); const rect = element.getBoundingClientRect(); return { element, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, rect }; } if (typeof x !== "number" || typeof y !== "number") throw new Error("Provide selector, uid, or x/y"); return { element: document.elementFromPoint(x, y), x, y, rect: undefined }; } async function clickPage(selector, uid, x, y) { installPiChromeInstrumentation(); const before = pageHash(); const point = resolvePoint(selector, uid, x, y); if (!point.element) throw new Error("No element at click point"); const clickPoint = humanClickPoint(point); point.x = clickPoint.x; point.y = clickPoint.y; point.element = document.elementFromPoint(point.x, point.y) || point.element; const visible = isElementVisible(point.element); const occluded = occluderAt(point.x, point.y, point.element); let defaultPrevented = await humanMoveTo(point.x, point.y); const state = getPiChromeState(); const prevX = state.pointer?.x; const prevY = state.pointer?.y; defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerdown", point.x, point.y, prevX, prevY, { pressure: 0.5 }) || defaultPrevented; defaultPrevented = dispatchPointerLikeEvent(point.element, "mousedown", point.x, point.y, prevX, prevY) || defaultPrevented; if (typeof point.element.focus === "function" && /^(A|BUTTON|INPUT|TEXTAREA|SELECT|SUMMARY)$/.test(point.element.tagName)) { try { point.element.focus({ preventScroll: true }); } catch { try { point.element.focus(); } catch {} } } await sleepPage(rand(45, 140)); defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerup", point.x, point.y, prevX, prevY) || defaultPrevented; defaultPrevented = dispatchPointerLikeEvent(point.element, "mouseup", point.x, point.y, prevX, prevY) || defaultPrevented; defaultPrevented = dispatchPointerLikeEvent(point.element, "click", point.x, point.y, prevX, prevY) || defaultPrevented; state.pointer = { x: point.x, y: point.y, t: performance.now() }; // Heuristic: if the clicked thing looks like a media play affordance and the page has paused // audio/video, the DOM-event click may not unlock autoplay. Surface a warning. let autoplayHint; const labelRaw = (point.element.getAttribute("aria-label") || point.element.textContent || "").trim(); const label = labelRaw.toLowerCase(); if (/^(play|start|begin|next|continue|unmute)/.test(label)) { const idleMedia = Array.from(document.querySelectorAll("audio,video")).some((m) => m.paused); if (idleMedia) autoplayHint = "This element looks like a media affordance and the page has paused media. DOM-event clicks do not satisfy user-activation gates; audio/video may not start."; } const pageMutated = pageHash() !== before; // Smart-auto retry hint: only set when DOM-event path produced no observable change AND the // element looks gated, OR the page just emitted a user-activation rejection. The dispatcher // uses this to decide whether to retry with Chrome input. let suggestChromeInput = false; let suggestReason; if (!pageMutated) { if (autoplayHint) { suggestChromeInput = true; suggestReason = "play/media affordance + idle media"; } else if (/copy(\s|$)|paste|share|download|fullscreen|sign in with|continue with|allow|enable/i.test(label)) { suggestChromeInput = true; suggestReason = `label '${labelRaw.slice(0, 40)}' looks gated`; } else { // Inspect recent console errors for activation-gate rejections. const recent = (state.console || []).slice(-8); const hit = recent.find((e) => /NotAllowedError|Document is not focused|requires transient activation|gesture is required/.test( (e.args || []).map((a) => typeof a === "string" ? a : a instanceof Error ? a.message : "").join(" ") )); if (hit) { suggestChromeInput = true; suggestReason = "recent console error indicates user-activation gate"; } } } return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, label: labelRaw.slice(0, 80) || undefined, input: "dom", defaultPrevented, elementVisible: visible, occludedBy: occluded || undefined, pageMutated, autoplayHint, suggestChromeInput: suggestChromeInput || undefined, suggestReason, }; } async function hoverPage(selector, uid, x, y) { installPiChromeInstrumentation(); const point = resolvePoint(selector, uid, x, y); if (!point.element) throw new Error("No element to hover"); await humanMoveTo(point.x, point.y); const state = getPiChromeState(); const prevX = state.pointer?.x, prevY = state.pointer?.y; let defaultPrevented = false; for (const type of ["pointerover", "mouseover", "pointerenter", "mouseenter"]) { defaultPrevented = dispatchPointerLikeEvent(point.element, type, point.x, point.y, prevX, prevY) || defaultPrevented; } // Small dwell so hover-intent handlers fire. await sleepPage(rand(80, 220)); return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, input: "dom" }; } async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) { installPiChromeInstrumentation(); const before = pageHash(); const from = resolvePoint(fromSelector, fromUid, fromX, fromY); const to = resolvePoint(toSelector, toUid, toX, toY); if (!from.element) throw new Error("Drag source element not found"); if (!to.element) throw new Error("Drag target element not found"); // Move to source. await humanMoveTo(from.x, from.y); const state = getPiChromeState(); let prevX = state.pointer?.x, prevY = state.pointer?.y; // Build a shared DataTransfer so HTML5 drag-and-drop handlers can populate / read it. const dt = new DataTransfer(); const dragInit = (type, target, x, y) => { const ev = new DragEvent(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, screenX: x + (window.screenX || 0), screenY: y + (window.screenY || 0), button: 0, buttons: 1, view: window, dataTransfer: dt, }); target.dispatchEvent(ev); return ev; }; dispatchPointerLikeEvent(from.element, "pointerover", from.x, from.y, prevX, prevY); dispatchPointerLikeEvent(from.element, "pointerdown", from.x, from.y, prevX, prevY, { pressure: 0.5 }); dispatchPointerLikeEvent(from.element, "mousedown", from.x, from.y, prevX, prevY); await sleepPage(rand(40, 110)); dragInit("dragstart", from.element, from.x, from.y); dragInit("drag", from.element, from.x, from.y); let lastOver = from.element; const n = steps || 18; for (let i = 1; i <= n; i++) { const t = i / n; const ease = t * t * (3 - 2 * t); const wobble = Math.sin(t * Math.PI) * 6; const x = from.x + (to.x - from.x) * ease + rand(-wobble, wobble); const y = from.y + (to.y - from.y) * ease + rand(-wobble, wobble); const overEl = document.elementFromPoint(x, y) || to.element; dispatchPointerLikeEvent(overEl, "pointermove", x, y, prevX, prevY); dispatchPointerLikeEvent(overEl, "mousemove", x, y, prevX, prevY); if (overEl !== lastOver) { dragInit("dragleave", lastOver, x, y); dragInit("dragenter", overEl, x, y); lastOver = overEl; } dragInit("dragover", overEl, x, y); dragInit("drag", from.element, x, y); prevX = x; prevY = y; await sleepPage(rand(8, 26)); } dispatchPointerLikeEvent(to.element, "pointerover", to.x, to.y, prevX, prevY); dispatchPointerLikeEvent(to.element, "mouseover", to.x, to.y, prevX, prevY); dragInit("drop", to.element, to.x, to.y); dragInit("dragend", from.element, to.x, to.y); dispatchPointerLikeEvent(to.element, "pointerup", to.x, to.y, prevX, prevY); dispatchPointerLikeEvent(to.element, "mouseup", to.x, to.y, prevX, prevY); state.pointer = { x: to.x, y: to.y, t: performance.now() }; return { from: { x: from.x, y: from.y }, to: { x: to.x, y: to.y }, steps: n, pageMutated: pageHash() !== before, note: "DOM-event drag with HTML5 DragEvent + shared DataTransfer.", }; } async function scrollPage(selector, uid, deltaY, deltaX, steps) { installPiChromeInstrumentation(); const before = pageHash(); let target; if (selector || uid) { target = elementBySelectorOrUid(selector, uid); } else { target = document.scrollingElement || document.documentElement || document.body; } if (!target) throw new Error("No scroll target"); const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight }; const cx = Math.max(0, Math.min(innerWidth - 1, rect.left + Math.min(rect.width, innerWidth) / 2)); const cy = Math.max(0, Math.min(innerHeight - 1, rect.top + Math.min(rect.height, innerHeight) / 2)); const n = Math.max(3, Math.min(40, steps || Math.max(3, Math.ceil(Math.abs(deltaY || 0) / 100)))); // Front-loaded wheel deltas, momentum-style. const totalY = deltaY || 0; const totalX = deltaX || 0; const weights = []; for (let i = 1; i <= n; i++) weights.push(1 / i); const sumW = weights.reduce((a, b) => a + b, 0); let movedY = 0, movedX = 0; for (let i = 0; i < n; i++) { const dy = totalY * (weights[i] / sumW); const dx = totalX * (weights[i] / sumW); const ev = new WheelEvent("wheel", { bubbles: true, cancelable: true, composed: true, view: window, clientX: cx, clientY: cy, deltaX: dx, deltaY: dy, deltaMode: 0, }); target.dispatchEvent(ev); if (!ev.defaultPrevented) { // Apply scroll ourselves; mirrors what the browser would do. if (target === document.scrollingElement || target === document.documentElement || target === document.body) { window.scrollBy({ left: dx, top: dy, behavior: "instant" }); } else { target.scrollTop += dy; target.scrollLeft += dx; } } movedY += dy; movedX += dx; await sleepPage(rand(12, 28)); } return { deltaX: movedX, deltaY: movedY, steps: n, scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, pageMutated: pageHash() !== before, input: "dom", }; } function uploadFiles(selector, uid, files) { installPiChromeInstrumentation(); const element = elementBySelectorOrUid(selector, uid); if (!element || element.tagName !== "INPUT" || element.type !== "file") { throw new Error("Target must be "); } const dt = new DataTransfer(); for (const f of files) { const bytes = Uint8Array.from(atob(f.base64 || ""), (c) => c.charCodeAt(0)); dt.items.add(new File([bytes], f.name, { type: f.type || "application/octet-stream" })); } element.files = dt.files; element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); return { uploaded: files.map((f) => ({ name: f.name, type: f.type, size: (f.base64 || "").length })) }; } function dispatchInputEvents(element, data, inputType = "insertText") { element.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType, data })); element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data })); element.dispatchEvent(new Event("change", { bubbles: true })); } function setNativeValue(element, value) { const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); if (descriptor?.set) descriptor.set.call(element, value); else element.value = value; } function printableKeyCode(ch) { return ch.length === 1 ? usKeyLayoutForChar(ch).keyCode : 0; } function dispatchKeyEvent(element, type, key, mods = {}) { const SPECIAL = { Enter: 13, Tab: 9, Backspace: 8, Delete: 46, Escape: 27, ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, " ": 32, Shift: 16, Control: 17, Alt: 18, Meta: 91 }; const code = key.length === 1 ? usKeyLayoutForChar(key).code : (key === " " ? "Space" : key); const keyCode = key.length === 1 ? printableKeyCode(key) : (SPECIAL[key] ?? 0); const ev = new KeyboardEvent(type, { key, code, keyCode, which: keyCode, charCode: type === "keypress" && key.length === 1 ? key.charCodeAt(0) : 0, shiftKey: !!mods.shiftKey, ctrlKey: !!mods.ctrlKey, altKey: !!mods.altKey, metaKey: !!mods.metaKey, bubbles: true, cancelable: true, composed: true, view: window, }); element.dispatchEvent(ev); return ev; } async function typeCharacter(element, ch) { const needShift = ch.length === 1 && (/^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch)); if (needShift) { dispatchKeyEvent(element, "keydown", "Shift", { shiftKey: true }); await sleepPage(rand(8, 24)); } const mods = { shiftKey: needShift }; const down = dispatchKeyEvent(element, "keydown", ch, mods); if (down.defaultPrevented) { if (needShift) dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false }); return { defaultPrevented: true }; } if (ch.length === 1) dispatchKeyEvent(element, "keypress", ch, mods); if (element.isContentEditable) { // execCommand("insertText") fires its own beforeinput + input. Don't double-dispatch. document.execCommand("insertText", false, ch); } else if ("value" in element) { const start = element.selectionStart ?? element.value.length; const end = element.selectionEnd ?? element.value.length; const next = element.value.slice(0, start) + ch + element.value.slice(end); const before = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: ch }); element.dispatchEvent(before); if (!before.defaultPrevented) { setNativeValue(element, next); try { element.selectionStart = element.selectionEnd = start + ch.length; } catch {} element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ch })); } } else { throw new Error("Focused element is not text-editable"); } await sleepPage(rand(25, 95)); dispatchKeyEvent(element, "keyup", ch, mods); if (needShift) { await sleepPage(rand(5, 18)); dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false }); } await sleepPage(rand(35, 140)); return { defaultPrevented: false }; } async function typeIntoPage(selector, uid, text, pressEnter) { installPiChromeInstrumentation(); const before = pageHash(); let element = elementBySelectorOrUid(selector, uid) || document.activeElement; if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element"); const initialValue = "value" in element ? element.value : (element.isContentEditable ? element.textContent : null); element.focus(); if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable"); for (const ch of Array.from(text)) await typeCharacter(element, ch); if (pressEnter) await pressKeyInPage("Enter"); const finalValue = "value" in element ? element.value : element.textContent; const valueMatches = "value" in element ? element.value.includes(text) : (element.textContent || "").includes(text); const pageMutated = pageHash() !== before; // Smart-auto retry hint when typing didn't land at all (e.g., editor blocks DOM-event input). let suggestChromeInput = false, suggestReason; if (text.length > 0 && initialValue === finalValue) { suggestChromeInput = true; suggestReason = "value did not change — editor likely rejects DOM-event input"; } return { selector, uid, length: text.length, pressEnter, input: "dom", valueMatches, pageMutated, suggestChromeInput: suggestChromeInput || undefined, suggestReason, }; } async function fillPage(selector, uid, text, submit) { installPiChromeInstrumentation(); const before = pageHash(); let element = elementBySelectorOrUid(selector, uid) || document.activeElement; if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element"); element.focus(); if (element.isContentEditable) { element.textContent = ""; document.execCommand("insertText", false, text); } else if ("value" in element) { setNativeValue(element, text); const length = String(text).length; try { element.selectionStart = element.selectionEnd = length; } catch {} dispatchInputEvents(element, text, "insertReplacementText"); } else { throw new Error("Focused element is not text-editable"); } if (submit) await pressKeyInPage("Enter"); return { selector, uid, length: String(text).length, submit, input: "dom", valueMatches: "value" in element ? element.value === String(text) : undefined, pageMutated: pageHash() !== before, }; } async function pressKeyInPage(key) { const normalized = normalizeKey(key); const target = document.activeElement || document.body; const before = pageHash(); const down = dispatchKeyEvent(target, "keydown", normalized); if (normalized.length === 1) dispatchKeyEvent(target, "keypress", normalized); // Character insertion for printable keys when focus is in an editable. if (normalized.length === 1 && !down.defaultPrevented && (target.isContentEditable || ("value" in target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")))) { if (target.isContentEditable) { const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized }); target.dispatchEvent(bi); if (!bi.defaultPrevented) { document.execCommand("insertText", false, normalized); target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized })); } } else { const start = target.selectionStart ?? target.value.length; const end = target.selectionEnd ?? target.value.length; const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized }); target.dispatchEvent(bi); if (!bi.defaultPrevented) { setNativeValue(target, target.value.slice(0, start) + normalized + target.value.slice(end)); try { target.selectionStart = target.selectionEnd = start + 1; } catch {} target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized })); } } } else if (normalized === "Backspace" && "value" in target) { const start = target.selectionStart ?? target.value.length; const end = target.selectionEnd ?? target.value.length; if (start > 0 || end > start) { const from = start === end ? start - 1 : start; const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "deleteContentBackward" }); target.dispatchEvent(bi); if (!bi.defaultPrevented) { setNativeValue(target, target.value.slice(0, from) + target.value.slice(end)); try { target.selectionStart = target.selectionEnd = from; } catch {} target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); } } } await sleepPage(rand(25, 95)); const up = dispatchKeyEvent(target, "keyup", normalized); if (normalized === "Enter") { const form = target.closest?.("form"); if (form) form.requestSubmit?.(); } return { key: normalized, input: "dom", defaultPrevented: down.defaultPrevented || up.defaultPrevented, pageMutated: pageHash() !== before, }; } function listConsoleMessages(clear) { installPiChromeInstrumentation(); const state = getPiChromeState(); // Serialize without running page code. Anti-DevTools checks log objects with getters or a custom // toString and treat any call as "DevTools is open" (issue #9). Copy data properties only, report // accessors as "[getter]" without calling them, and describe built-ins through native getters. const nativeSource = Function.prototype.toString; const isNative = (fn) => { try { return typeof fn === "function" && nativeSource.call(fn).includes("[native code]"); } catch { return false; } }; const protoGetter = (proto, key) => { try { return Object.getOwnPropertyDescriptor(proto, key)?.get; } catch { return undefined; } }; const callGetter = (proto, key, value) => { const get = protoGetter(proto, key); return get ? get.call(value) : undefined; }; const dataProp = (value, key) => { for (let obj = value, hops = 0; obj && hops < 20; obj = Object.getPrototypeOf(obj), hops++) { const desc = Object.getOwnPropertyDescriptor(obj, key); if (!desc) continue; if ("value" in desc) return { ok: true, value: desc.value }; return isNative(desc.get) ? { ok: true, value: desc.get.call(value) } : { ok: false }; } return { ok: true, value: undefined }; }; const serialize = (value, depth = 0, seen = new Set()) => { try { const type = typeof value; if (value === null || type === "string" || type === "boolean") return value; if (type === "number") return Number.isFinite(value) ? value : String(value); if (type === "undefined") return "undefined"; if (type === "bigint") return `${BigInt.prototype.toString.call(value)}n`; if (type === "symbol") return Symbol.prototype.toString.call(value); if (type === "function") { const name = dataProp(value, "name"); return `[function ${name.ok && typeof name.value === "string" && name.value ? name.value : "anonymous"}]`; } if (seen.has(value)) return "[circular]"; if (value instanceof Error) { const out = {}; for (const key of ["name", "message"]) { const prop = dataProp(value, key); out[key] = prop.ok ? (typeof prop.value === "string" ? prop.value : serialize(prop.value, depth + 1, seen)) : "[getter]"; } // V8 formats `stack` lazily from name/message, so only read it when both are plain data. const stack = out.name !== "[getter]" && out.message !== "[getter]" ? dataProp(value, "stack") : { ok: false }; out.stack = stack.ok ? (typeof stack.value === "string" ? stack.value : undefined) : "[getter]"; return out; } if (value instanceof Date) return Date.prototype.toISOString.call(value); if (value instanceof RegExp) return `/${callGetter(RegExp.prototype, "source", value)}/${callGetter(RegExp.prototype, "flags", value)}`; if (typeof Node !== "undefined" && value instanceof Node) { const name = String(callGetter(Node.prototype, "nodeName", value) || "node").toLowerCase(); if (typeof Element === "undefined" || !(value instanceof Element)) return `<${name}>`; const id = callGetter(Element.prototype, "id", value); const cls = String(callGetter(Element.prototype, "className", value) || "").trim().split(/\s+/).filter(Boolean).slice(0, 3); return `<${name}${id ? `#${id}` : ""}${cls.map((c) => `.${c}`).join("")}>`; } if (value instanceof Map) return `[Map(${callGetter(Map.prototype, "size", value)})]`; if (value instanceof Set) return `[Set(${callGetter(Set.prototype, "size", value)})]`; if (depth >= 4) return Array.isArray(value) ? "[array]" : "[object]"; seen.add(value); if (Array.isArray(value)) { const length = dataProp(value, "length"); const n = length.ok ? Math.min(Number(length.value) || 0, 50) : 0; const out = []; for (let i = 0; i < n; i++) { const item = dataProp(value, String(i)); out.push(item.ok ? serialize(item.value, depth + 1, seen) : "[getter]"); } if (length.ok && length.value > 50) out.push(`[${length.value - 50} more]`); seen.delete(value); return out; } const out = {}; const keys = Object.keys(value); for (const key of keys.slice(0, 50)) { const desc = Object.getOwnPropertyDescriptor(value, key); out[key] = desc && "value" in desc ? serialize(desc.value, depth + 1, seen) : "[getter]"; } if (keys.length > 50) out["…"] = `${keys.length - 50} more keys`; seen.delete(value); return out; } catch { return "[unserializable]"; } }; const messages = state.console.map((entry) => ({ ...entry, args: Array.from(entry.args || [], (arg) => serialize(arg)) })); if (clear) state.console = []; return { messages, count: messages.length }; } function listNetworkRequests(includePreservedRequests, clear) { installPiChromeInstrumentation(); const state = getPiChromeState(); const currentUrl = location.href; const requests = state.network .filter((request) => includePreservedRequests || request.pageUrl === currentUrl) .map(({ responseBody, ...summary }) => ({ ...summary, hasResponseBody: responseBody !== undefined })); if (clear) state.network = []; return { requests, count: requests.length, note: "Captures fetch/XHR after instrumentation is installed. Browser-initiated document/static asset requests are not captured." }; } function getNetworkRequest(requestId) { installPiChromeInstrumentation(); const request = getPiChromeState().network.find((entry) => entry.id === requestId); if (!request) throw new Error(`No network request with id ${requestId}`); return request; } function normalizeKey(key) { const table = { enter: "Enter", escape: "Escape", tab: "Tab", backspace: "Backspace", delete: "Delete", arrowup: "ArrowUp", arrowdown: "ArrowDown", arrowleft: "ArrowLeft", arrowright: "ArrowRight", }; return table[String(key).toLowerCase()] || key; }