Files

2604 lines
117 KiB
JavaScript

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://<otherId>/... 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 <input type=file>");
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.<name> 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 <body>. 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 <input type=file>");
}
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;
}