mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
2604 lines
117 KiB
JavaScript
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;
|
|
}
|