const BRIDGE_URL = "http://127.0.0.1:17318"; const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`; const POLL_ERROR_BACKOFF_MS = 2000; 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; 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 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 = false; function sessionKeyOf(params) { return params && typeof params.sessionKey === "string" && params.sessionKey ? params.sessionKey : DEFAULT_SESSION_KEY; } // 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 = true; try { const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY); const saved = stored && stored[AUTOMATION_STORAGE_KEY]; if (saved && typeof saved === "object") { for (const [key, value] of Object.entries(saved)) { if (value && typeof value.tabId === "number") { automationTargets.set(key, { windowId: typeof value.windowId === "number" ? value.windowId : undefined, tabId: value.tabId, }); } } } } catch { // Ignore: treat as "no persisted state". } } async function persistAutomationTargets() { try { const obj = {}; for (const [key, value] of automationTargets) { obj[key] = { windowId: typeof value.windowId === "number" ? value.windowId : null, tabId: value.tabId }; } await chrome.storage?.session?.set?.({ [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); automationTargets.delete(sessionKey); await persistAutomationTargets(); if (!t) return { closedWindowId: null, closedTabId: null }; const { windowId, tabId } = t; if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") { const win = await chrome.windows.get(windowId).catch(() => null); if (win) { await chrome.windows.remove(windowId).catch(() => {}); return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null }; } } if (typeof tabId === "number") { const tab = await chrome.tabs.get(tabId).catch(() => null); if (tab) { await chrome.tabs.remove(tabId).catch(() => {}); return { closedWindowId: null, closedTabId: tabId }; } } return { closedWindowId: null, closedTabId: null }; } function withTimeout(promise, ms, label, onTimeout) { let timer; return Promise.race([ Promise.resolve(promise).finally(() => clearTimeout(timer)), new Promise((_, reject) => { timer = setTimeout(async () => { try { await onTimeout?.(); } catch {} reject(new Error(`${label} timed out after ${ms}ms`)); }, 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 tab = await chrome.tabs.get(Number(tabId)).catch(() => 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) { const requested = params?.targetId ? await describeInputTarget(Number(params.targetId)) : await describeInputTarget(-1); return { extensionVersion: chrome.runtime.getManifest().version, extensionId: chrome.runtime.id, ...requested, recentAttachEvents: attachDebugLog.slice(), }; } 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); function cdpRaw(tabId, method, params) { const debuggee = attachedTabs.get(tabId)?.debuggee || { tabId }; 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); }); }), CDP_COMMAND_TIMEOUT_MS, `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"); } // 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) { try { return await cdpRaw(tabId, method, params); } 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); } 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); } } // 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 executeScriptTimed({ 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) { const ch = 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 executeScriptTimed({ 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); if (params.foreground) await bringToFront(tab); try { await attachDebugger(tab.id); 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 executeScriptTimed({ 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); if (params.foreground) await bringToFront(tab); await attachDebugger(tab.id); 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); if (params.foreground) await bringToFront(tab); await attachDebugger(tab.id); 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); // When modifiers are active, browsers usually emit "rawKeyDown" (no text) so chords like Cmd+V don't insert the literal char. const downType = modBits ? "rawKeyDown" : "keyDown"; await cdp(tab.id, "Input.dispatchKeyEvent", { type: downType, key: info.key, code: info.code, windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode, text: modBits ? "" : info.text, unmodifiedText: modBits ? "" : 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 }; } async function chromeInputType(params) { const tab = await getTabByParams(params); if (params.foreground) await bringToFront(tab); await attachDebugger(tab.id); 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)); } const text = String(params.text || ""); for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch); if (params.pressEnter) { await cdpTypeChar(tab.id, "\r").catch(() => undefined); await chromeInputKey({ ...params, key: "Enter" }); } return { input: "chrome", length: text.length }; } async function domFillFallback(tabId, params, cause) { if (!(params.selector || params.uid)) throw cause; const results = await executeScriptTimed({ 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); if (params.foreground) await bringToFront(tab); try { await attachDebugger(tab.id); 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)); } // Delete 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)); const text = String(params.text || ""); for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch); if (params.submit) await chromeInputKey({ ...params, key: "Enter" }); return { input: "chrome", length: text.length }; } catch (error) { if (params.domFallback === false) throw error; return domFillFallback(tab.id, params, error); } } async function chromeInputScroll(params) { const tab = await getTabByParams(params); if (params.foreground) await bringToFront(tab); await attachDebugger(tab.id); 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); if (params.foreground) await bringToFront(tab); await attachDebugger(tab.id); 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); if (params.foreground) await bringToFront(tab); await attachDebugger(tab.id); 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 }; } async function chromeInputUpload(params) { const tab = await getTabByParams(params); if (params.foreground) await bringToFront(tab); 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 expression = `(() => { const selector = ${JSON.stringify(params.selector ?? null)}; const uid = ${JSON.stringify(params.uid ?? null)}; const state = window.__PI_CHROME_STATE__; const el = uid && state && state.elements ? state.elements[uid] : (selector ? document.querySelector(selector) : null); if (!el || el.tagName !== "INPUT" || el.type !== "file") throw new Error("Target must be "); el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); return el; })()`; const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression, objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false }); if (evaluated.exceptionDetails) throw new Error(evaluated.exceptionDetails.text || "Could not resolve file input"); const objectId = evaluated.result?.objectId; if (!objectId) throw new Error("Could not resolve file input object"); await cdp(tab.id, "DOM.enable", {}).catch(() => undefined); const requested = await cdp(tab.id, "DOM.requestNode", { objectId }); if (!requested.nodeId) throw new Error("Could not resolve file input node"); await cdp(tab.id, "DOM.setFileInputFiles", { nodeId: requested.nodeId, files: paths }); await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: `function() { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return this.files ? this.files.length : 0; }`, returnByValue: true, }).catch(() => undefined); await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined); return { input: "chrome", uploaded: paths.map((path) => ({ path })) }; } // =============================================================== 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(() => { 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 fetch(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, { cache: "no-store", }); 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; } } async function handleCommand(command) { try { const result = await withTimeout( dispatch(command.action, command.params ?? {}), COMMAND_TIMEOUT_MS, command.action || "Chrome command", () => detachAll(), ); await postResult({ id: command.id, ok: true, result }); } catch (error) { await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) }); } } async function postResult(result) { await fetch(`${BRIDGE_URL}/result`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(result), }); } 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) { const existing = await findGroupByTitle(tab.windowId, groupTitle); groupId = existing !== null ? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] }) : await chrome.tabs.group({ 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, }; case "tab.list": { const tabs = await chrome.tabs.query({}); return Promise.all(tabs.map(formatTab)); } 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: true }; if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId; const tab = await chrome.tabs.create(createParams); try { return await groupTab(tab, groupTitle, params.groupColor); } catch (error) { if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {}); throw error; } } case "tab.activate": { // 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 }); await chrome.windows.update(tab.windowId, { focused: true }); return formatTab(await chrome.tabs.update(tab.id, { active: true })); } case "tab.group": { const tab = await getTabByParams(params, { createOwnedTarget: false }); return groupTab(tab, params.groupTitle || "Pi", params.groupColor); } 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); if (params.foreground) await bringToFront(tab); 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); if (params.foreground) await bringToFront(tab); 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 "automation.status": { // Report this session's owned automation target (ids only). Used for diagnostics/tests. await hydrateAutomationTargets(); const t = automationTargets.get(sessionKeyOf(params)); return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null }; } case "automation.cleanup": // Close only THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or // another Pi session's target. return cleanupAutomationTarget(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); } 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) { if (!chrome.tabGroups || typeof tab.id !== "number") return; if (typeof tab.groupId === "number" && tab.groupId >= 0) return; try { await groupTab(tab, title); } 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); if (params.foreground) await bringToFront(tab); // 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 executeScriptTimed({ 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); if (params.foreground) await bringToFront(tab); 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; } async function withOptionalSnapshot(params, actionFn) { const result = await actionFn(params); if (params.includeSnapshot) { const snapshot = await snapshotInTab({ ...params, foreground: false }); return { result, snapshot }; } return result; } // 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); if (params.foreground) await bringToFront(tab); const args = [ params.maxElements || 80, params.containingText ?? null, params.roleFilter ?? null, params.nearUid ?? null, params.mode || "auto", params.query ?? null, params.maxTextChars ?? null, ]; await executeScriptTimed({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", files: ["snapshot_injected.js"], }, `inject snapshot script in tab ${tab.id}`); const results = await executeScriptTimed({ 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"); } const value = envelope?.value; return value && typeof value === "object" ? { ...value, targetId: tab.id } : value; } async function inspectInTab(params) { if (!params.uid && !params.selector) throw new Error("chrome_inspect requires uid or selector"); const tab = await getTabByParams(params); if (params.foreground) await bringToFront(tab); const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true]; await executeScriptTimed({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", files: ["snapshot_injected.js"], }, `inject inspect script in tab ${tab.id}`); const results = await executeScriptTimed({ 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); } // Always inject early console/network capture at document_start on every navigation. // Catches console messages, errors, and network requests that fire during page load, // before chrome_snapshot or chrome_evaluate install the instrumentation normally. // The function installEarlyCapture sets __piChromeWrapped flags so the post-hoc // installPiChromeInstrumentation() call is idempotent. if (chrome.webNavigation && chrome.webNavigation.onCommitted) { chrome.webNavigation.onCommitted.addListener((details) => { if (details.frameId !== 0) return; chrome.scripting.executeScript({ target: { tabId: details.tabId, frameIds: [0] }, world: "MAIN", injectImmediately: true, func: installEarlyCapture, args: [], }).catch(() => undefined); }); } async function bringToFront(tab) { await chrome.windows.update(tab.windowId, { focused: true }); await 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); }); } async function takeScreenshot(params) { const tab = await getTabByParams(params); if (params.foreground) await bringToFront(tab); let previousActiveId; if (!tab.active) { const activeBefore = await chrome.tabs.query({ active: true, windowId: tab.windowId }); previousActiveId = activeBefore[0]?.id; await chrome.tabs.update(tab.id, { active: true }); } try { if (params.fullPage) { // Tile-stitched full page capture: scroll, capture, paste, repeat. const tiles = await executeInTab({ ...params, foreground: false }, captureFullPageTiles, []); // captureFullPageTiles only computes scroll positions / metrics; we capture per scroll here // (chrome.tabs.captureVisibleTab can't be called from MAIN world). const captured = []; for (const tile of tiles.tiles) { await executeInTab({ ...params, foreground: false }, scrollToY, [tile.scrollY]); // Small settle delay; many sites have on-scroll animations / lazy-load. await sleep(120); const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: params.format || "png", quality: params.format === "jpeg" ? params.quality : undefined, }); captured.push({ y: tile.y, dataUrl }); } await executeInTab({ ...params, foreground: false }, scrollToY, [tiles.originalScrollY]); return { fullPage: true, tab: await formatTab(tab), dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr }, tiles: captured, }; } const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: params.format || "png", quality: params.format === "jpeg" ? params.quality : undefined, }); return { dataUrl, tab: await formatTab(tab) }; } finally { if (previousActiveId !== undefined && previousActiveId !== tab.id) { await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined); } } } // --------------------------------------------------------------------------- // 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, args: Array.from(args).map((arg) => { try { if (typeof arg === "string") return arg; if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack }; return JSON.parse(JSON.stringify(arg)); } catch { return String(arg); } }), }); 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).map(function(arg) { try { if (typeof arg === "string") return arg; if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack }; return JSON.parse(JSON.stringify(arg)); } catch (e) { return String(arg); } }), }); 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 tile capture; the actual chrome.tabs.captureVisibleTab calls happen // in the SW. We just report the scroll positions and 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 tiles = []; let y = 0; while (y < height) { tiles.push({ y, scrollY: y }); y += viewportHeight; } return { width, height, viewportHeight, dpr, originalScrollY, tiles }; } function scrollToY(y) { window.scrollTo({ top: y, left: 0, 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 && a.message) || JSON.stringify(a)).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(); const messages = state.console.slice(); 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; }