@phi-code-admin/phi-code 0.82.3 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/docs/usage.md +1 -1
- package/extensions/phi/btw/LICENSE +21 -0
- package/extensions/phi/btw/btw-ui.ts +238 -0
- package/extensions/phi/btw/btw.ts +363 -0
- package/extensions/phi/btw/index.ts +17 -0
- package/extensions/phi/btw/prompts/btw-system.txt +9 -0
- package/extensions/phi/chrome/LICENSE +21 -0
- package/extensions/phi/chrome/browser-extension/manifest.json +26 -0
- package/extensions/phi/chrome/browser-extension/service_worker.js +2388 -0
- package/extensions/phi/chrome/browser-extension/snapshot_injected.js +677 -0
- package/extensions/phi/chrome/index.ts +1799 -0
- package/extensions/phi/goal/LICENSE +21 -0
- package/extensions/phi/goal/index.ts +784 -0
- package/extensions/phi/mcp/LICENSE +21 -0
- package/extensions/phi/mcp/callback-server.ts +263 -0
- package/extensions/phi/mcp/config.ts +195 -0
- package/extensions/phi/mcp/errors.ts +35 -0
- package/extensions/phi/mcp/index.ts +376 -0
- package/extensions/phi/mcp/oauth-provider.ts +367 -0
- package/extensions/phi/mcp/server-manager.ts +464 -0
- package/extensions/phi/mcp/tool-bridge.ts +494 -0
- package/extensions/phi/todo/LICENSE +21 -0
- package/extensions/phi/todo/config.ts +14 -0
- package/extensions/phi/todo/index.ts +113 -0
- package/extensions/phi/todo/locales/de.json +17 -0
- package/extensions/phi/todo/locales/en.json +15 -0
- package/extensions/phi/todo/locales/es.json +17 -0
- package/extensions/phi/todo/locales/fr.json +17 -0
- package/extensions/phi/todo/locales/pt-BR.json +17 -0
- package/extensions/phi/todo/locales/pt.json +17 -0
- package/extensions/phi/todo/locales/ru.json +17 -0
- package/extensions/phi/todo/locales/uk.json +17 -0
- package/extensions/phi/todo/rpiv-config/config.ts +223 -0
- package/extensions/phi/todo/rpiv-config/index.ts +12 -0
- package/extensions/phi/todo/state/i18n-bridge.ts +65 -0
- package/extensions/phi/todo/state/invariants.ts +20 -0
- package/extensions/phi/todo/state/replay.ts +38 -0
- package/extensions/phi/todo/state/selectors.ts +107 -0
- package/extensions/phi/todo/state/state-reducer.ts +187 -0
- package/extensions/phi/todo/state/state.ts +18 -0
- package/extensions/phi/todo/state/store.ts +54 -0
- package/extensions/phi/todo/state/task-graph.ts +57 -0
- package/extensions/phi/todo/todo-overlay.ts +194 -0
- package/extensions/phi/todo/todo.ts +146 -0
- package/extensions/phi/todo/tool/response-envelope.ts +94 -0
- package/extensions/phi/todo/tool/types.ts +128 -0
- package/extensions/phi/todo/view/format.ts +162 -0
- package/package.json +4 -2
|
@@ -0,0 +1,2388 @@
|
|
|
1
|
+
const BRIDGE_URL = "http://127.0.0.1:17318";
|
|
2
|
+
const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`;
|
|
3
|
+
const POLL_ERROR_BACKOFF_MS = 2000;
|
|
4
|
+
const DEFAULT_GROUP_COLOR = "blue";
|
|
5
|
+
const PI_GROUP_RE = /^Pi(\b|\s*-)/i;
|
|
6
|
+
const VALID_GROUP_COLORS = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
|
|
7
|
+
const COMMAND_TIMEOUT_MS = 25_000;
|
|
8
|
+
const CDP_COMMAND_TIMEOUT_MS = 5_000;
|
|
9
|
+
const SCRIPTING_TIMEOUT_MS = 8_000;
|
|
10
|
+
const ATTACH_TIMEOUT_MS = 3_000;
|
|
11
|
+
let polling = false;
|
|
12
|
+
|
|
13
|
+
function withTimeout(promise, ms, label, onTimeout) {
|
|
14
|
+
let timer;
|
|
15
|
+
return Promise.race([
|
|
16
|
+
Promise.resolve(promise).finally(() => clearTimeout(timer)),
|
|
17
|
+
new Promise((_, reject) => {
|
|
18
|
+
timer = setTimeout(async () => {
|
|
19
|
+
try { await onTimeout?.(); } catch {}
|
|
20
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
21
|
+
}, ms);
|
|
22
|
+
}),
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// =================== Chrome input (CDP) layer ===================
|
|
27
|
+
// Tracks which tabs we have attached chrome.debugger to.
|
|
28
|
+
const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
|
|
29
|
+
const INPUT_IDLE_DETACH_MS = 15_000;
|
|
30
|
+
const CDP_VERSION = "1.3";
|
|
31
|
+
|
|
32
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
33
|
+
function rng(min, max) { return min + Math.random() * (max - min); }
|
|
34
|
+
|
|
35
|
+
function inputStatus() {
|
|
36
|
+
return {
|
|
37
|
+
attachedTabs: Array.from(attachedTabs.keys()),
|
|
38
|
+
permissionGranted: typeof chrome !== "undefined" && !!chrome.debugger,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Last few attach failures, kept for diagnostics.
|
|
43
|
+
const attachDebugLog = [];
|
|
44
|
+
function recordAttachEvent(entry) {
|
|
45
|
+
attachDebugLog.push({ ...entry, t: Date.now() });
|
|
46
|
+
if (attachDebugLog.length > 20) attachDebugLog.shift();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalPageTarget(target, tabId) {
|
|
50
|
+
const url = String(target?.url || "");
|
|
51
|
+
return target?.tabId === tabId && target?.type === "page" && !url.startsWith("chrome://") && !url.startsWith("chrome-extension://") && !url.startsWith("devtools://");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function pageDebuggeeForTab(tabId) {
|
|
55
|
+
const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))).catch(() => []);
|
|
56
|
+
const target = targets.find((t) => normalPageTarget(t, tabId));
|
|
57
|
+
return target?.id ? { targetId: target.id } : { tabId };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function debuggerAttachRaw(tabId, preferredDebuggee) {
|
|
61
|
+
const debuggee = preferredDebuggee || { tabId };
|
|
62
|
+
await withTimeout(
|
|
63
|
+
chrome.debugger.attach(debuggee, CDP_VERSION),
|
|
64
|
+
ATTACH_TIMEOUT_MS,
|
|
65
|
+
`Chrome debugger attach to tab ${tabId}`,
|
|
66
|
+
async () => {
|
|
67
|
+
attachedTabs.delete(tabId);
|
|
68
|
+
try { await chrome.debugger.detach(debuggee); } catch {}
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
return debuggee;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function attachDebugger(tabId) {
|
|
75
|
+
if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
|
|
76
|
+
if (attachedTabs.has(tabId)) {
|
|
77
|
+
const entry = attachedTabs.get(tabId);
|
|
78
|
+
entry.detachAt = Date.now() + INPUT_IDLE_DETACH_MS;
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
// Before each attach, force-detach any stale CDP target this extension owns on the tab.
|
|
82
|
+
// Chrome sometimes keeps a half-dead session around (extension reload mid-attach, etc.) and
|
|
83
|
+
// surfaces it as "Cannot access a chrome-extension://" on the next attach attempt.
|
|
84
|
+
try {
|
|
85
|
+
const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || [])));
|
|
86
|
+
for (const tgt of targets) {
|
|
87
|
+
if (tgt.tabId === tabId && tgt.attached) {
|
|
88
|
+
recordAttachEvent({ kind: "stale-target-found", tabId, target: { id: tgt.id, type: tgt.type, url: tgt.url, extensionId: tgt.extensionId } });
|
|
89
|
+
try { await chrome.debugger.detach({ tabId }); } catch {}
|
|
90
|
+
await sleep(80);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
let attachedDebuggee = null;
|
|
96
|
+
const attemptAttach = async (debuggee) => {
|
|
97
|
+
try {
|
|
98
|
+
attachedDebuggee = await debuggerAttachRaw(tabId, debuggee);
|
|
99
|
+
return null;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return error;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const retryPageTargetIfExtensionBlocked = async (err, kind) => {
|
|
105
|
+
if (!/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(String(err?.message || err))) return err;
|
|
106
|
+
const pageDebuggee = await pageDebuggeeForTab(tabId);
|
|
107
|
+
recordAttachEvent({ kind, tabId, debuggee: pageDebuggee });
|
|
108
|
+
return attemptAttach(pageDebuggee);
|
|
109
|
+
};
|
|
110
|
+
let err = await attemptAttach();
|
|
111
|
+
if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry");
|
|
112
|
+
if (err) {
|
|
113
|
+
const msg = String(err?.message || err);
|
|
114
|
+
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);
|
|
115
|
+
const tabSnapshot = await chrome.tabs.get(tabId).catch(() => null);
|
|
116
|
+
recordAttachEvent({ kind: "attach-failed", tabId, message: msg, tabUrl: tabSnapshot?.url, transient });
|
|
117
|
+
if (!transient) throw err;
|
|
118
|
+
if (!tabSnapshot || (tabSnapshot.url || "").startsWith("chrome://") || (tabSnapshot.url || "").startsWith("chrome-extension://")) {
|
|
119
|
+
throw new Error(`Chrome can't attach the debugger to this tab (${tabSnapshot?.url ?? "unknown"}). Open a normal http(s) tab and try again.`);
|
|
120
|
+
}
|
|
121
|
+
await sleep(180);
|
|
122
|
+
err = await attemptAttach();
|
|
123
|
+
if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry2");
|
|
124
|
+
if (err) {
|
|
125
|
+
recordAttachEvent({ kind: "attach-retry-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
|
|
126
|
+
// One more try after a longer settle. Some Chrome builds need ~500ms after a navigation
|
|
127
|
+
// for content-script registration on the tab to drain before chrome.debugger.attach
|
|
128
|
+
// will accept the target.
|
|
129
|
+
await sleep(500);
|
|
130
|
+
err = await attemptAttach();
|
|
131
|
+
if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry3");
|
|
132
|
+
if (err) {
|
|
133
|
+
recordAttachEvent({ kind: "attach-retry2-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
|
|
134
|
+
const meta = await describeInputTarget(tabId);
|
|
135
|
+
throw new Error(`Chrome debugger attach failed for tab ${tabId}: ${String(err.message || err)}${targetMetaSuffix(meta)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
recordAttachEvent({ kind: "attached", tabId, debuggee: attachedDebuggee });
|
|
140
|
+
// Seed pointer in a plausible "just left the address bar" location.
|
|
141
|
+
const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 }, debuggee: attachedDebuggee || { tabId } };
|
|
142
|
+
attachedTabs.set(tabId, entry);
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function describeInputTarget(tabId) {
|
|
147
|
+
const tab = await chrome.tabs.get(Number(tabId)).catch(() => null);
|
|
148
|
+
const active = (await chrome.tabs.query({ active: true, lastFocusedWindow: true }).catch(() => []))[0] || null;
|
|
149
|
+
let targets = [];
|
|
150
|
+
try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
|
|
151
|
+
return {
|
|
152
|
+
resolvedTab: tab ? { id: tab.id, windowId: tab.windowId, url: tab.url, status: tab.status, title: tab.title, active: tab.active } : null,
|
|
153
|
+
activeTab: active ? { id: active.id, windowId: active.windowId, url: active.url, status: active.status, title: active.title, active: active.active } : null,
|
|
154
|
+
attachedTabs: Array.from(attachedTabs.keys()),
|
|
155
|
+
cdpTargets: targets.map((t) => ({ id: t.id, tabId: t.tabId, type: t.type, url: t.url, attached: t.attached, extensionId: t.extensionId })),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function targetMetaSuffix(meta) {
|
|
160
|
+
return `\nTarget metadata: ${JSON.stringify(meta).slice(0, 4000)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function inputDebug(params) {
|
|
164
|
+
const requested = params?.targetId ? await describeInputTarget(Number(params.targetId)) : await describeInputTarget(-1);
|
|
165
|
+
return {
|
|
166
|
+
extensionVersion: chrome.runtime.getManifest().version,
|
|
167
|
+
extensionId: chrome.runtime.id,
|
|
168
|
+
...requested,
|
|
169
|
+
recentAttachEvents: attachDebugLog.slice(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function detachDebugger(tabId) {
|
|
174
|
+
const entry = attachedTabs.get(tabId);
|
|
175
|
+
if (!entry) return;
|
|
176
|
+
attachedTabs.delete(tabId);
|
|
177
|
+
try { await chrome.debugger.detach(entry.debuggee || { tabId }); } catch {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function detachAll() {
|
|
181
|
+
const ids = Array.from(attachedTabs.keys());
|
|
182
|
+
await Promise.all(ids.map(detachDebugger));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (chrome.debugger && chrome.debugger.onDetach) {
|
|
186
|
+
chrome.debugger.onDetach.addListener(({ tabId }, reason) => {
|
|
187
|
+
if (tabId !== undefined) attachedTabs.delete(tabId);
|
|
188
|
+
if (reason === "canceled_by_user") {
|
|
189
|
+
console.warn(`[pi-chrome] debugger canceled by user on tab ${tabId}; Chrome input will reattach on next call`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setInterval(() => {
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
for (const [tabId, entry] of attachedTabs) {
|
|
197
|
+
if (entry.detachAt && entry.detachAt < now) {
|
|
198
|
+
void detachDebugger(tabId);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}, 5000);
|
|
202
|
+
|
|
203
|
+
function cdpRaw(tabId, method, params) {
|
|
204
|
+
const debuggee = attachedTabs.get(tabId)?.debuggee || { tabId };
|
|
205
|
+
return withTimeout(new Promise((resolve, reject) => {
|
|
206
|
+
chrome.debugger.sendCommand(debuggee, method, params || {}, (result) => {
|
|
207
|
+
if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
|
|
208
|
+
else resolve(result);
|
|
209
|
+
});
|
|
210
|
+
}), CDP_COMMAND_TIMEOUT_MS, `CDP ${method}`, async () => {
|
|
211
|
+
attachedTabs.delete(tabId);
|
|
212
|
+
try { await chrome.debugger.detach(debuggee); } catch {}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function executeScriptTimed(options, label) {
|
|
217
|
+
return withTimeout(chrome.scripting.executeScript(options), SCRIPTING_TIMEOUT_MS, label || "chrome.scripting.executeScript");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Wraps cdpRaw with one auto-recover on detached/closed sessions:
|
|
221
|
+
// chrome.debugger.attach can stay cached in attachedTabs even after Chrome killed
|
|
222
|
+
// the session (tab nav, devtools opened/closed, etc). Recover by detaching the
|
|
223
|
+
// stale entry and re-attaching, then retry the command once.
|
|
224
|
+
// Find foreign chrome-extension targets currently anchored to the tab. Password managers,
|
|
225
|
+
// autofill helpers, and other input-attached extensions create type:"other" CDP targets
|
|
226
|
+
// whose URL is chrome-extension://<otherId>/... When that target is in focus, CDP refuses
|
|
227
|
+
// our Input.dispatchMouseEvent calls with "Cannot access a chrome-extension:// URL of
|
|
228
|
+
// different extension" — surfacing a cryptic error to the user.
|
|
229
|
+
async function findForeignExtensionTargets() {
|
|
230
|
+
try {
|
|
231
|
+
const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || [])));
|
|
232
|
+
return targets.filter((t) => {
|
|
233
|
+
const url = String(t.url || "");
|
|
234
|
+
if (!url.startsWith("chrome-extension://")) return false;
|
|
235
|
+
if (t.extensionId === chrome.runtime.id) return false;
|
|
236
|
+
return true;
|
|
237
|
+
});
|
|
238
|
+
} catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function extractForeignExtId(targets) {
|
|
244
|
+
for (const t of targets) {
|
|
245
|
+
if (t.extensionId && t.extensionId !== chrome.runtime.id) return t.extensionId;
|
|
246
|
+
const m = String(t.url || "").match(/chrome-extension:\/\/([a-p]+)\//);
|
|
247
|
+
if (m && m[1] !== chrome.runtime.id) return m[1];
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function dismissOverlayViaEscape(tabId) {
|
|
253
|
+
// Esc routes through key dispatcher (target-by-focus), not by mouse coordinates, so it
|
|
254
|
+
// works even when a foreign chrome-extension popup is intercepting pointer events.
|
|
255
|
+
try {
|
|
256
|
+
await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
257
|
+
await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
258
|
+
await sleep(120);
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function cdp(tabId, method, params) {
|
|
263
|
+
try {
|
|
264
|
+
return await cdpRaw(tabId, method, params);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const msg = String(error?.message || error);
|
|
267
|
+
const isStale = /Debugger is not attached|Detached while|Target closed|No tab with id/i.test(msg);
|
|
268
|
+
const isForeignExtBlock = /Cannot access a chrome-extension:\/\/ URL of different extension/i.test(msg);
|
|
269
|
+
if (isForeignExtBlock && /Input\./.test(method)) {
|
|
270
|
+
// Foreign chrome-extension popup (autofill, password manager) is hijacking input.
|
|
271
|
+
// Try once: dismiss via Esc, then retry.
|
|
272
|
+
const before = await findForeignExtensionTargets();
|
|
273
|
+
recordAttachEvent({ kind: "foreign-ext-detected", tabId, method, foreignExtId: extractForeignExtId(before), targetCount: before.length });
|
|
274
|
+
await dismissOverlayViaEscape(tabId);
|
|
275
|
+
try {
|
|
276
|
+
return await cdpRaw(tabId, method, params);
|
|
277
|
+
} catch (retryErr) {
|
|
278
|
+
const retryMsg = String(retryErr?.message || retryErr);
|
|
279
|
+
if (/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(retryMsg)) {
|
|
280
|
+
const after = await findForeignExtensionTargets();
|
|
281
|
+
const id = extractForeignExtId(after) || extractForeignExtId(before) || "unknown";
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Another Chrome extension (${id}) has an input overlay on this page (e.g. a password manager / autofill popup). \n` +
|
|
284
|
+
`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.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
throw retryErr;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!isStale) throw error;
|
|
291
|
+
attachedTabs.delete(tabId);
|
|
292
|
+
await attachDebugger(tabId).catch(() => undefined);
|
|
293
|
+
return cdpRaw(tabId, method, params);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// cdpEval: evaluate a JavaScript expression string in the page's MAIN world via CDP
|
|
298
|
+
// Runtime.evaluate. Runtime.evaluate is a DevTools protocol command and is NOT subject to
|
|
299
|
+
// the page's Content-Security-Policy, so it works on pages that ship `script-src 'self'`
|
|
300
|
+
// without `'unsafe-eval'` (which blocks `eval`/`new Function`). Ensures the debugger is
|
|
301
|
+
// attached first. Returns the raw CDP result ({ result, exceptionDetails }).
|
|
302
|
+
async function cdpEval(tabId, expression, opts) {
|
|
303
|
+
await attachDebugger(tabId);
|
|
304
|
+
return cdp(tabId, "Runtime.evaluate", {
|
|
305
|
+
expression,
|
|
306
|
+
returnByValue: true,
|
|
307
|
+
awaitPromise: true,
|
|
308
|
+
userGesture: true,
|
|
309
|
+
...(opts || {}),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function cdpExceptionText(details) {
|
|
314
|
+
if (!details) return "";
|
|
315
|
+
return String(
|
|
316
|
+
details.exception?.description ||
|
|
317
|
+
details.exception?.value ||
|
|
318
|
+
details.text ||
|
|
319
|
+
"",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function cdpIsSyntaxError(details) {
|
|
324
|
+
if (!details) return false;
|
|
325
|
+
const className = String(details.exception?.className || "");
|
|
326
|
+
return className === "SyntaxError" || /SyntaxError/.test(cdpExceptionText(details));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
|
|
330
|
+
async function resolveTargetInTab(tabId, params) {
|
|
331
|
+
const results = await executeScriptTimed({
|
|
332
|
+
target: { tabId, frameIds: [0] },
|
|
333
|
+
world: "MAIN",
|
|
334
|
+
func: (selector, uid, x, y) => {
|
|
335
|
+
const state = window.__PI_CHROME_STATE__;
|
|
336
|
+
let el = null;
|
|
337
|
+
if (uid) {
|
|
338
|
+
el = state && state.elements ? state.elements[uid] : null;
|
|
339
|
+
if (!el || !el.isConnected) return { found: false, staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
|
|
340
|
+
} else if (selector) {
|
|
341
|
+
el = document.querySelector(selector);
|
|
342
|
+
}
|
|
343
|
+
if (el) {
|
|
344
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
345
|
+
const r = el.getBoundingClientRect();
|
|
346
|
+
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 };
|
|
347
|
+
}
|
|
348
|
+
if (typeof x === "number" && typeof y === "number") return { x, y, rect: null, tag: null, found: true };
|
|
349
|
+
return { found: false };
|
|
350
|
+
},
|
|
351
|
+
args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
|
|
352
|
+
}, `resolve input target in tab ${tabId}`);
|
|
353
|
+
const v = results?.[0]?.result;
|
|
354
|
+
if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
|
|
355
|
+
if (!v || !v.found) throw new Error("Could not resolve target element for Chrome input");
|
|
356
|
+
return v;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function pickInsideRect(rect) {
|
|
360
|
+
if (!rect) return null;
|
|
361
|
+
const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
|
|
362
|
+
const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
|
|
363
|
+
return {
|
|
364
|
+
x: rect.left + rect.width / 2 + rng(-insetX, insetX),
|
|
365
|
+
y: rect.top + rect.height / 2 + rng(-insetY, insetY),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function cdpMoveTo(tabId, x, y) {
|
|
370
|
+
const entry = attachedTabs.get(tabId);
|
|
371
|
+
const startX = entry?.pointer?.x ?? Math.max(20, Math.min(400, x - 200));
|
|
372
|
+
const startY = entry?.pointer?.y ?? Math.max(20, Math.min(400, y - 200));
|
|
373
|
+
const n = Math.max(18, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18)));
|
|
374
|
+
for (let i = 1; i <= n; i++) {
|
|
375
|
+
const t = i / n;
|
|
376
|
+
const ease = t * t * (3 - 2 * t);
|
|
377
|
+
const wobble = Math.sin(t * Math.PI) * 8;
|
|
378
|
+
const px = startX + (x - startX) * ease + rng(-wobble, wobble);
|
|
379
|
+
const py = startY + (y - startY) * ease + rng(-wobble, wobble);
|
|
380
|
+
await cdp(tabId, "Input.dispatchMouseEvent", {
|
|
381
|
+
type: "mouseMoved", x: px, y: py, button: "none", buttons: 0, pointerType: "mouse",
|
|
382
|
+
});
|
|
383
|
+
await sleep(rng(5, 16));
|
|
384
|
+
}
|
|
385
|
+
if (entry) entry.pointer = { x, y };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function cdpModifiersFor(mods) {
|
|
389
|
+
let m = 0;
|
|
390
|
+
if (mods?.altKey) m |= 1;
|
|
391
|
+
if (mods?.ctrlKey) m |= 2;
|
|
392
|
+
if (mods?.metaKey) m |= 4;
|
|
393
|
+
if (mods?.shiftKey) m |= 8;
|
|
394
|
+
return m;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Resolve a single printable character to { code, keyCode, needShift } on a US layout.
|
|
398
|
+
// Self-contained (maps defined inline) so it can be serialized into the page via
|
|
399
|
+
// HELPER_FUNCS for the DOM-event fallback as well as used by the CDP path.
|
|
400
|
+
// Using charCodeAt() for punctuation is wrong: e.g. "." is charCode 46 which collides
|
|
401
|
+
// with VK_DELETE, "-" is 45 (VK_INSERT), so app keydown handlers misfire and drop input.
|
|
402
|
+
function usKeyLayoutForChar(ch) {
|
|
403
|
+
const PUNCT = {
|
|
404
|
+
"`": { code: "Backquote", keyCode: 192 }, "~": { code: "Backquote", keyCode: 192, shift: true },
|
|
405
|
+
"-": { code: "Minus", keyCode: 189 }, "_": { code: "Minus", keyCode: 189, shift: true },
|
|
406
|
+
"=": { code: "Equal", keyCode: 187 }, "+": { code: "Equal", keyCode: 187, shift: true },
|
|
407
|
+
"[": { code: "BracketLeft", keyCode: 219 }, "{": { code: "BracketLeft", keyCode: 219, shift: true },
|
|
408
|
+
"]": { code: "BracketRight", keyCode: 221 }, "}": { code: "BracketRight", keyCode: 221, shift: true },
|
|
409
|
+
"\\": { code: "Backslash", keyCode: 220 }, "|": { code: "Backslash", keyCode: 220, shift: true },
|
|
410
|
+
";": { code: "Semicolon", keyCode: 186 }, ":": { code: "Semicolon", keyCode: 186, shift: true },
|
|
411
|
+
"'": { code: "Quote", keyCode: 222 }, "\"": { code: "Quote", keyCode: 222, shift: true },
|
|
412
|
+
",": { code: "Comma", keyCode: 188 }, "<": { code: "Comma", keyCode: 188, shift: true },
|
|
413
|
+
".": { code: "Period", keyCode: 190 }, ">": { code: "Period", keyCode: 190, shift: true },
|
|
414
|
+
"/": { code: "Slash", keyCode: 191 }, "?": { code: "Slash", keyCode: 191, shift: true },
|
|
415
|
+
" ": { code: "Space", keyCode: 32 },
|
|
416
|
+
};
|
|
417
|
+
// Shifted digit symbols share the digit's physical code + keyCode.
|
|
418
|
+
const SHIFT_DIGIT = { ")": "0", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9" };
|
|
419
|
+
if (/^[a-z]$/.test(ch)) return { code: `Key${ch.toUpperCase()}`, keyCode: ch.toUpperCase().charCodeAt(0), needShift: false };
|
|
420
|
+
if (/^[A-Z]$/.test(ch)) return { code: `Key${ch}`, keyCode: ch.charCodeAt(0), needShift: true };
|
|
421
|
+
if (/^[0-9]$/.test(ch)) return { code: `Digit${ch}`, keyCode: ch.charCodeAt(0), needShift: false };
|
|
422
|
+
if (SHIFT_DIGIT[ch]) { const d = SHIFT_DIGIT[ch]; return { code: `Digit${d}`, keyCode: d.charCodeAt(0), needShift: true }; }
|
|
423
|
+
const p = PUNCT[ch];
|
|
424
|
+
if (p) return { code: p.code, keyCode: p.keyCode, needShift: !!p.shift };
|
|
425
|
+
// Unknown char (e.g. unicode): keep text-driven insertion, avoid bogus keyCode collisions.
|
|
426
|
+
return { code: ch, keyCode: 0, needShift: false };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function cdpKeyInfo(key, shifted) {
|
|
430
|
+
// Map common keys to CDP key event init fields. Returns { code, key, windowsVirtualKeyCode, text }.
|
|
431
|
+
const SPECIAL = {
|
|
432
|
+
Enter: { code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
|
|
433
|
+
Tab: { code: "Tab", windowsVirtualKeyCode: 9, text: "\t" },
|
|
434
|
+
Backspace: { code: "Backspace", windowsVirtualKeyCode: 8, text: "" },
|
|
435
|
+
Delete: { code: "Delete", windowsVirtualKeyCode: 46, text: "" },
|
|
436
|
+
Escape: { code: "Escape", windowsVirtualKeyCode: 27, text: "" },
|
|
437
|
+
ArrowLeft: { code: "ArrowLeft", windowsVirtualKeyCode: 37, text: "" },
|
|
438
|
+
ArrowUp: { code: "ArrowUp", windowsVirtualKeyCode: 38, text: "" },
|
|
439
|
+
ArrowRight: { code: "ArrowRight", windowsVirtualKeyCode: 39, text: "" },
|
|
440
|
+
ArrowDown: { code: "ArrowDown", windowsVirtualKeyCode: 40, text: "" },
|
|
441
|
+
Shift: { code: "ShiftLeft", windowsVirtualKeyCode: 16, text: "" },
|
|
442
|
+
Control: { code: "ControlLeft", windowsVirtualKeyCode: 17, text: "" },
|
|
443
|
+
Alt: { code: "AltLeft", windowsVirtualKeyCode: 18, text: "" },
|
|
444
|
+
Meta: { code: "MetaLeft", windowsVirtualKeyCode: 91, text: "" },
|
|
445
|
+
" ": { code: "Space", windowsVirtualKeyCode: 32, text: " " },
|
|
446
|
+
};
|
|
447
|
+
if (SPECIAL[key]) return { key, ...SPECIAL[key] };
|
|
448
|
+
if (key.length === 1) {
|
|
449
|
+
const ch = key;
|
|
450
|
+
const layout = usKeyLayoutForChar(ch);
|
|
451
|
+
return { key: ch, code: layout.code, windowsVirtualKeyCode: layout.keyCode, text: ch };
|
|
452
|
+
}
|
|
453
|
+
return { key, code: key, windowsVirtualKeyCode: 0, text: "" };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function cdpTypeChar(tabId, ch) {
|
|
457
|
+
const needShift = /^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch);
|
|
458
|
+
let modifiers = 0;
|
|
459
|
+
if (needShift) {
|
|
460
|
+
await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 8 });
|
|
461
|
+
modifiers = 8;
|
|
462
|
+
await sleep(rng(8, 22));
|
|
463
|
+
}
|
|
464
|
+
const info = cdpKeyInfo(ch);
|
|
465
|
+
await cdp(tabId, "Input.dispatchKeyEvent", {
|
|
466
|
+
type: "keyDown", key: info.key, code: info.code,
|
|
467
|
+
windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
|
|
468
|
+
text: info.text, unmodifiedText: info.text, modifiers,
|
|
469
|
+
});
|
|
470
|
+
await sleep(rng(25, 90));
|
|
471
|
+
await cdp(tabId, "Input.dispatchKeyEvent", {
|
|
472
|
+
type: "keyUp", key: info.key, code: info.code,
|
|
473
|
+
windowsVirtualKeyCode: info.windowsVirtualKeyCode, modifiers,
|
|
474
|
+
});
|
|
475
|
+
if (needShift) {
|
|
476
|
+
await sleep(rng(5, 18));
|
|
477
|
+
await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 0 });
|
|
478
|
+
}
|
|
479
|
+
await sleep(rng(35, 130));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function domClickFallback(tabId, params, cause) {
|
|
483
|
+
const results = await executeScriptTimed({
|
|
484
|
+
target: { tabId, frameIds: [0] },
|
|
485
|
+
world: "MAIN",
|
|
486
|
+
func: (selector, uid, x, y) => {
|
|
487
|
+
const state = window.__PI_CHROME_STATE__;
|
|
488
|
+
let el = uid && state && state.elements ? state.elements[uid] : null;
|
|
489
|
+
if (uid && (!el || !el.isConnected)) return { staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
|
|
490
|
+
if (!el && selector) el = document.querySelector(selector);
|
|
491
|
+
if (!el && typeof x === "number" && typeof y === "number") el = document.elementFromPoint(x, y);
|
|
492
|
+
if (!el) throw new Error(`DOM fallback target not found: ${uid || selector || `${x},${y}`}`);
|
|
493
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
494
|
+
const rect = el.getBoundingClientRect();
|
|
495
|
+
const eventInit = { bubbles: true, cancelable: true, view: window, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, button: 0, buttons: 1 };
|
|
496
|
+
el.dispatchEvent(new PointerEvent("pointerdown", { ...eventInit, pointerId: 1, pointerType: "mouse", isPrimary: true }));
|
|
497
|
+
el.dispatchEvent(new MouseEvent("mousedown", eventInit));
|
|
498
|
+
if (typeof el.focus === "function") el.focus({ preventScroll: true });
|
|
499
|
+
el.dispatchEvent(new PointerEvent("pointerup", { ...eventInit, pointerId: 1, pointerType: "mouse", isPrimary: true, buttons: 0 }));
|
|
500
|
+
el.dispatchEvent(new MouseEvent("mouseup", { ...eventInit, buttons: 0 }));
|
|
501
|
+
el.click();
|
|
502
|
+
return { tag: el.tagName, url: location.href };
|
|
503
|
+
},
|
|
504
|
+
args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
|
|
505
|
+
}, `DOM click fallback in tab ${tabId}`);
|
|
506
|
+
const v = results?.[0]?.result;
|
|
507
|
+
if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
|
|
508
|
+
return { input: "dom-fallback", reason: String(cause?.message || cause).slice(0, 500), tag: v?.tag };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function chromeInputClick(params) {
|
|
512
|
+
const tab = await getTabByParams(params);
|
|
513
|
+
if (params.foreground) await bringToFront(tab);
|
|
514
|
+
try {
|
|
515
|
+
await attachDebugger(tab.id);
|
|
516
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
517
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
518
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
519
|
+
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 });
|
|
520
|
+
await sleep(rng(45, 140));
|
|
521
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
522
|
+
// Reset :focus-visible if the click landed on a focusable element. CDP-driven pointer
|
|
523
|
+
// focus can leave :focus-visible=true in Chromium, which trips heuristics that expect
|
|
524
|
+
// Reset focus styling after pointer click when possible.
|
|
525
|
+
if (params.selector || params.uid) {
|
|
526
|
+
await executeScriptTimed({
|
|
527
|
+
target: { tabId: tab.id, frameIds: [0] },
|
|
528
|
+
world: "MAIN",
|
|
529
|
+
func: (sel, uid) => {
|
|
530
|
+
const state = window.__PI_CHROME_STATE__;
|
|
531
|
+
let el = null;
|
|
532
|
+
if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid];
|
|
533
|
+
else if (sel) el = document.querySelector(sel);
|
|
534
|
+
if (el && typeof el.focus === "function" && el === document.activeElement) {
|
|
535
|
+
try { el.blur(); el.focus({ preventScroll: true, focusVisible: false }); } catch {}
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
args: [params.selector ?? null, params.uid ?? null],
|
|
539
|
+
}, `reset focus style in tab ${tab.id}`).catch(() => undefined);
|
|
540
|
+
}
|
|
541
|
+
return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (params.domFallback === false) throw error;
|
|
544
|
+
return domClickFallback(tab.id, params, error);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function chromeInputHover(params) {
|
|
549
|
+
const tab = await getTabByParams(params);
|
|
550
|
+
if (params.foreground) await bringToFront(tab);
|
|
551
|
+
await attachDebugger(tab.id);
|
|
552
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
553
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
554
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
555
|
+
await sleep(rng(80, 220));
|
|
556
|
+
return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function chromeInputKey(params) {
|
|
560
|
+
const tab = await getTabByParams(params);
|
|
561
|
+
if (params.foreground) await bringToFront(tab);
|
|
562
|
+
await attachDebugger(tab.id);
|
|
563
|
+
const key = String(params.key || "");
|
|
564
|
+
if (!key) throw new Error("chrome.key: missing key");
|
|
565
|
+
const mods = params.modifiers || {};
|
|
566
|
+
const modBits = cdpModifiersFor(mods);
|
|
567
|
+
// Press modifiers in standard order, then key, then release in reverse.
|
|
568
|
+
const modOrder = [];
|
|
569
|
+
if (mods.metaKey) modOrder.push({ key: "Meta", code: "MetaLeft", vk: 91 });
|
|
570
|
+
if (mods.ctrlKey) modOrder.push({ key: "Control", code: "ControlLeft", vk: 17 });
|
|
571
|
+
if (mods.altKey) modOrder.push({ key: "Alt", code: "AltLeft", vk: 18 });
|
|
572
|
+
if (mods.shiftKey) modOrder.push({ key: "Shift", code: "ShiftLeft", vk: 16 });
|
|
573
|
+
for (const m of modOrder) {
|
|
574
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: modBits });
|
|
575
|
+
await sleep(rng(6, 18));
|
|
576
|
+
}
|
|
577
|
+
const info = cdpKeyInfo(key);
|
|
578
|
+
// When modifiers are active, browsers usually emit "rawKeyDown" (no text) so chords like Cmd+V don't insert the literal char.
|
|
579
|
+
const downType = modBits ? "rawKeyDown" : "keyDown";
|
|
580
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", {
|
|
581
|
+
type: downType, key: info.key, code: info.code,
|
|
582
|
+
windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
|
|
583
|
+
text: modBits ? "" : info.text, unmodifiedText: modBits ? "" : info.text, modifiers: modBits,
|
|
584
|
+
});
|
|
585
|
+
await sleep(rng(25, 90));
|
|
586
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", {
|
|
587
|
+
type: "keyUp", key: info.key, code: info.code,
|
|
588
|
+
windowsVirtualKeyCode: info.windowsVirtualKeyCode, modifiers: modBits,
|
|
589
|
+
});
|
|
590
|
+
for (const m of modOrder.reverse()) {
|
|
591
|
+
await sleep(rng(5, 18));
|
|
592
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: 0 });
|
|
593
|
+
}
|
|
594
|
+
return { input: "chrome", key: info.key, modifiers: mods };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function chromeInputType(params) {
|
|
598
|
+
const tab = await getTabByParams(params);
|
|
599
|
+
if (params.foreground) await bringToFront(tab);
|
|
600
|
+
await attachDebugger(tab.id);
|
|
601
|
+
if (params.selector || params.uid) {
|
|
602
|
+
// Focus target by clicking it first.
|
|
603
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
604
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
605
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
606
|
+
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 });
|
|
607
|
+
await sleep(rng(45, 110));
|
|
608
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
609
|
+
await sleep(rng(50, 120));
|
|
610
|
+
}
|
|
611
|
+
const text = String(params.text || "");
|
|
612
|
+
for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
|
|
613
|
+
if (params.pressEnter) {
|
|
614
|
+
await cdpTypeChar(tab.id, "\r").catch(() => undefined);
|
|
615
|
+
await chromeInputKey({ ...params, key: "Enter" });
|
|
616
|
+
}
|
|
617
|
+
return { input: "chrome", length: text.length };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function domFillFallback(tabId, params, cause) {
|
|
621
|
+
if (!(params.selector || params.uid)) throw cause;
|
|
622
|
+
const results = await executeScriptTimed({
|
|
623
|
+
target: { tabId, frameIds: [0] },
|
|
624
|
+
world: "MAIN",
|
|
625
|
+
func: async (selector, uid, text, submit) => {
|
|
626
|
+
const state = window.__PI_CHROME_STATE__;
|
|
627
|
+
let el = uid && state && state.elements ? state.elements[uid] : null;
|
|
628
|
+
if (uid && (!el || !el.isConnected)) return { staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
|
|
629
|
+
if (!el && selector) el = document.querySelector(selector);
|
|
630
|
+
if (!el) throw new Error(`DOM fallback target not found: ${uid || selector}`);
|
|
631
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
632
|
+
if (typeof el.focus === "function") el.focus({ preventScroll: true });
|
|
633
|
+
const value = String(text ?? "");
|
|
634
|
+
if ("value" in el) {
|
|
635
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
636
|
+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
|
637
|
+
if (setter) setter.call(el, value);
|
|
638
|
+
else el.value = value;
|
|
639
|
+
} else if (el.isContentEditable) {
|
|
640
|
+
el.textContent = value;
|
|
641
|
+
} else {
|
|
642
|
+
throw new Error(`DOM fallback target is not fillable: <${el.tagName.toLowerCase()}>`);
|
|
643
|
+
}
|
|
644
|
+
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
645
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
646
|
+
if (submit) {
|
|
647
|
+
const form = el.closest("form");
|
|
648
|
+
if (form) form.requestSubmit ? form.requestSubmit() : form.submit();
|
|
649
|
+
else document.querySelector("button,[type=submit]")?.click();
|
|
650
|
+
}
|
|
651
|
+
return { valueMatches: "value" in el ? el.value === value : el.textContent === value, tag: el.tagName, url: location.href };
|
|
652
|
+
},
|
|
653
|
+
args: [params.selector ?? null, params.uid ?? null, params.text ?? "", params.submit === true],
|
|
654
|
+
}, `DOM fill fallback in tab ${tabId}`);
|
|
655
|
+
const v = results?.[0]?.result;
|
|
656
|
+
if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
|
|
657
|
+
return { input: "dom-fallback", length: String(params.text || "").length, valueMatches: v?.valueMatches, reason: String(cause?.message || cause).slice(0, 500), tag: v?.tag };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function chromeInputFill(params) {
|
|
661
|
+
const tab = await getTabByParams(params);
|
|
662
|
+
if (params.foreground) await bringToFront(tab);
|
|
663
|
+
try {
|
|
664
|
+
await attachDebugger(tab.id);
|
|
665
|
+
if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
|
|
666
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
667
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
668
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
669
|
+
// Triple-click selects all in input fields.
|
|
670
|
+
for (let i = 1; i <= 3; i++) {
|
|
671
|
+
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 });
|
|
672
|
+
await sleep(rng(20, 60));
|
|
673
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
|
|
674
|
+
await sleep(rng(20, 60));
|
|
675
|
+
}
|
|
676
|
+
// Delete selection.
|
|
677
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
678
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
679
|
+
await sleep(rng(20, 60));
|
|
680
|
+
const text = String(params.text || "");
|
|
681
|
+
for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
|
|
682
|
+
if (params.submit) await chromeInputKey({ ...params, key: "Enter" });
|
|
683
|
+
return { input: "chrome", length: text.length };
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (params.domFallback === false) throw error;
|
|
686
|
+
return domFillFallback(tab.id, params, error);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function chromeInputScroll(params) {
|
|
691
|
+
const tab = await getTabByParams(params);
|
|
692
|
+
if (params.foreground) await bringToFront(tab);
|
|
693
|
+
await attachDebugger(tab.id);
|
|
694
|
+
const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
|
|
695
|
+
const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
|
|
696
|
+
const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y;
|
|
697
|
+
const totalY = params.deltaY || 0, totalX = params.deltaX || 0;
|
|
698
|
+
// Profile mimics a trackpad flick: short ramp-up (~15% of events), then geometric decay
|
|
699
|
+
// with a ~12% drop per event. Gives momentum tail tests something to find, and the small
|
|
700
|
+
// tail deltas (a handful of <20px events) put IntersectionObserver thresholds in range.
|
|
701
|
+
const peak = Math.max(Math.abs(totalY), Math.abs(totalX));
|
|
702
|
+
// Aim peak event ~22px so cumulative wheel approach to target seeds low-ratio IO samples.
|
|
703
|
+
const PEAK_TARGET = 22;
|
|
704
|
+
const w = [];
|
|
705
|
+
// Build weights for an arbitrary n, then iterate to find an n where peak * (w_peak/sum) <= PEAK_TARGET.
|
|
706
|
+
function build(n) {
|
|
707
|
+
const arr = [];
|
|
708
|
+
const peakIdx = Math.max(1, Math.floor(n * 0.15));
|
|
709
|
+
for (let i = 0; i < n; i++) {
|
|
710
|
+
if (i <= peakIdx) arr.push(0.5 + 0.5 * (i / peakIdx)); // 0.5 → 1.0
|
|
711
|
+
else arr.push(Math.pow(0.88, i - peakIdx)); // ~12% drop per step
|
|
712
|
+
}
|
|
713
|
+
return arr;
|
|
714
|
+
}
|
|
715
|
+
let n = Math.max(12, params.steps || 24);
|
|
716
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
717
|
+
const arr = build(n);
|
|
718
|
+
const s = arr.reduce((a, b) => a + b, 0);
|
|
719
|
+
const peakStep = peak * (Math.max(...arr) / s);
|
|
720
|
+
if (peakStep <= PEAK_TARGET || n >= 240) {
|
|
721
|
+
w.length = 0;
|
|
722
|
+
w.push(...arr);
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
n = Math.ceil(n * 1.4);
|
|
726
|
+
}
|
|
727
|
+
if (w.length === 0) w.push(...build(n));
|
|
728
|
+
const sumW = w.reduce((a, b) => a + b, 0);
|
|
729
|
+
for (let i = 0; i < n; i++) {
|
|
730
|
+
const dy = totalY * (w[i] / sumW), dx = totalX * (w[i] / sumW);
|
|
731
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", {
|
|
732
|
+
type: "mouseWheel", x, y, deltaX: dx, deltaY: dy, pointerType: "mouse",
|
|
733
|
+
});
|
|
734
|
+
// Sleep one+ frame so IntersectionObserver / rAF samples can run between events.
|
|
735
|
+
await sleep(rng(22, 48));
|
|
736
|
+
}
|
|
737
|
+
return { input: "chrome", deltaX: totalX, deltaY: totalY, steps: n };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function chromeInputTap(params) {
|
|
741
|
+
const tab = await getTabByParams(params);
|
|
742
|
+
if (params.foreground) await bringToFront(tab);
|
|
743
|
+
await attachDebugger(tab.id);
|
|
744
|
+
const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number"))
|
|
745
|
+
? await resolveTargetInTab(tab.id, params)
|
|
746
|
+
: null;
|
|
747
|
+
if (!resolved || !resolved.found) throw new Error("chrome.tap: target not found");
|
|
748
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
749
|
+
const tp = { x: point.x, y: point.y, radiusX: 8, radiusY: 8, rotationAngle: 0, force: 0.5, id: 1 };
|
|
750
|
+
await cdp(tab.id, "Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [tp] });
|
|
751
|
+
await sleep(rng(40, 110));
|
|
752
|
+
await cdp(tab.id, "Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
753
|
+
return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function chromeInputDrag(params) {
|
|
757
|
+
const tab = await getTabByParams(params);
|
|
758
|
+
if (params.foreground) await bringToFront(tab);
|
|
759
|
+
await attachDebugger(tab.id);
|
|
760
|
+
const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
|
|
761
|
+
const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
|
|
762
|
+
const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y };
|
|
763
|
+
const tp = to.rect ? pickInsideRect(to.rect) : { x: to.x, y: to.y };
|
|
764
|
+
await cdpMoveTo(tab.id, fp.x, fp.y);
|
|
765
|
+
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 });
|
|
766
|
+
await sleep(rng(60, 140));
|
|
767
|
+
const steps = params.steps || 20;
|
|
768
|
+
for (let i = 1; i <= steps; i++) {
|
|
769
|
+
const t = i / steps;
|
|
770
|
+
const ease = t * t * (3 - 2 * t);
|
|
771
|
+
const wobble = Math.sin(t * Math.PI) * 6;
|
|
772
|
+
const x = fp.x + (tp.x - fp.x) * ease + rng(-wobble, wobble);
|
|
773
|
+
const y = fp.y + (tp.y - fp.y) * ease + rng(-wobble, wobble);
|
|
774
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "left", buttons: 1, pointerType: "mouse" });
|
|
775
|
+
await sleep(rng(10, 26));
|
|
776
|
+
}
|
|
777
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: tp.x, y: tp.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
778
|
+
return { input: "chrome", from: fp, to: tp, steps };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function chromeInputUpload(params) {
|
|
782
|
+
const tab = await getTabByParams(params);
|
|
783
|
+
if (params.foreground) await bringToFront(tab);
|
|
784
|
+
await attachDebugger(tab.id);
|
|
785
|
+
if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required");
|
|
786
|
+
const paths = Array.isArray(params.paths) ? params.paths.map(String) : [];
|
|
787
|
+
if (!paths.length) throw new Error("chrome.upload: no file paths provided");
|
|
788
|
+
const expression = `(() => {
|
|
789
|
+
const selector = ${JSON.stringify(params.selector ?? null)};
|
|
790
|
+
const uid = ${JSON.stringify(params.uid ?? null)};
|
|
791
|
+
const state = window.__PI_CHROME_STATE__;
|
|
792
|
+
const el = uid && state && state.elements ? state.elements[uid] : (selector ? document.querySelector(selector) : null);
|
|
793
|
+
if (!el || el.tagName !== "INPUT" || el.type !== "file") throw new Error("Target must be <input type=file>");
|
|
794
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
795
|
+
return el;
|
|
796
|
+
})()`;
|
|
797
|
+
const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression, objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
|
|
798
|
+
if (evaluated.exceptionDetails) throw new Error(evaluated.exceptionDetails.text || "Could not resolve file input");
|
|
799
|
+
const objectId = evaluated.result?.objectId;
|
|
800
|
+
if (!objectId) throw new Error("Could not resolve file input object");
|
|
801
|
+
await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
|
|
802
|
+
const requested = await cdp(tab.id, "DOM.requestNode", { objectId });
|
|
803
|
+
if (!requested.nodeId) throw new Error("Could not resolve file input node");
|
|
804
|
+
await cdp(tab.id, "DOM.setFileInputFiles", { nodeId: requested.nodeId, files: paths });
|
|
805
|
+
await cdp(tab.id, "Runtime.callFunctionOn", {
|
|
806
|
+
objectId,
|
|
807
|
+
functionDeclaration: `function() { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return this.files ? this.files.length : 0; }`,
|
|
808
|
+
returnByValue: true,
|
|
809
|
+
}).catch(() => undefined);
|
|
810
|
+
await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
|
|
811
|
+
return { input: "chrome", uploaded: paths.map((path) => ({ path })) };
|
|
812
|
+
}
|
|
813
|
+
// ===============================================================
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
function armKeepaliveAlarm() {
|
|
817
|
+
chrome.alarms.create("pi-bridge-keepalive", { periodInMinutes: 0.5 });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
821
|
+
chrome.action.setBadgeText({ text: "pi" });
|
|
822
|
+
chrome.action.setBadgeBackgroundColor({ color: "#4f46e5" });
|
|
823
|
+
armKeepaliveAlarm();
|
|
824
|
+
void pollLoop();
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
chrome.runtime.onStartup.addListener(() => {
|
|
828
|
+
armKeepaliveAlarm();
|
|
829
|
+
void pollLoop();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
833
|
+
if (alarm.name === "pi-bridge-keepalive") void pollLoop();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
chrome.action.onClicked.addListener(() => {
|
|
837
|
+
armKeepaliveAlarm();
|
|
838
|
+
void pollLoop();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
armKeepaliveAlarm();
|
|
842
|
+
|
|
843
|
+
setInterval(() => {
|
|
844
|
+
void pollLoop();
|
|
845
|
+
}, 1000);
|
|
846
|
+
|
|
847
|
+
async function pollLoop() {
|
|
848
|
+
if (polling) return;
|
|
849
|
+
polling = true;
|
|
850
|
+
try {
|
|
851
|
+
while (true) {
|
|
852
|
+
const response = await fetch(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, {
|
|
853
|
+
cache: "no-store",
|
|
854
|
+
});
|
|
855
|
+
if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
|
|
856
|
+
const expected = response.headers.get("x-pi-chrome-version");
|
|
857
|
+
const ours = chrome.runtime.getManifest().version;
|
|
858
|
+
if (expected && expected !== ours && isVersionOlder(ours, expected)) {
|
|
859
|
+
console.warn(`[pi-chrome] extension v${ours} behind pi-chrome v${expected}; reloading extension`);
|
|
860
|
+
try { chrome.runtime.reload(); } catch {}
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const payload = await response.json();
|
|
864
|
+
if (payload.type === "command") await handleCommand(payload.command);
|
|
865
|
+
}
|
|
866
|
+
} catch (error) {
|
|
867
|
+
await sleep(POLL_ERROR_BACKOFF_MS);
|
|
868
|
+
} finally {
|
|
869
|
+
polling = false;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function handleCommand(command) {
|
|
874
|
+
try {
|
|
875
|
+
const result = await withTimeout(
|
|
876
|
+
dispatch(command.action, command.params ?? {}),
|
|
877
|
+
COMMAND_TIMEOUT_MS,
|
|
878
|
+
command.action || "Chrome command",
|
|
879
|
+
() => detachAll(),
|
|
880
|
+
);
|
|
881
|
+
await postResult({ id: command.id, ok: true, result });
|
|
882
|
+
} catch (error) {
|
|
883
|
+
await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function postResult(result) {
|
|
888
|
+
await fetch(`${BRIDGE_URL}/result`, {
|
|
889
|
+
method: "POST",
|
|
890
|
+
headers: { "content-type": "application/json" },
|
|
891
|
+
body: JSON.stringify(result),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function isVersionOlder(a, b) {
|
|
896
|
+
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
897
|
+
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
898
|
+
const n = Math.max(pa.length, pb.length);
|
|
899
|
+
for (let i = 0; i < n; i++) {
|
|
900
|
+
const x = pa[i] ?? 0, y = pb[i] ?? 0;
|
|
901
|
+
if (x < y) return true;
|
|
902
|
+
if (x > y) return false;
|
|
903
|
+
}
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function cleanGroupTitle(value) {
|
|
908
|
+
const text = String(value || "Pi").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
909
|
+
return text || "Pi";
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function cleanGroupColor(value) {
|
|
913
|
+
const color = String(value || DEFAULT_GROUP_COLOR).toLowerCase();
|
|
914
|
+
return VALID_GROUP_COLORS.has(color) ? color : DEFAULT_GROUP_COLOR;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function groupRecord(groupId) {
|
|
918
|
+
if (typeof groupId !== "number" || groupId < 0 || !chrome.tabGroups) return null;
|
|
919
|
+
const group = await chrome.tabGroups.get(groupId).catch(() => null);
|
|
920
|
+
if (!group) return null;
|
|
921
|
+
return {
|
|
922
|
+
id: group.id,
|
|
923
|
+
title: group.title || "",
|
|
924
|
+
color: group.color || "",
|
|
925
|
+
collapsed: Boolean(group.collapsed),
|
|
926
|
+
windowId: group.windowId,
|
|
927
|
+
piGroup: Boolean(group.title && PI_GROUP_RE.test(group.title)),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Find an existing tab group in `windowId` whose title matches `title` (case-insensitive).
|
|
932
|
+
// Used so all Pi-opened tabs collect into one group per window instead of spawning new ones.
|
|
933
|
+
async function findGroupByTitle(windowId, title) {
|
|
934
|
+
if (!chrome.tabGroups) return null;
|
|
935
|
+
const wanted = cleanGroupTitle(title).toLowerCase();
|
|
936
|
+
const groups = await chrome.tabGroups.query({ windowId }).catch(() => []);
|
|
937
|
+
const match = groups.find((g) => (g.title || "").trim().toLowerCase() === wanted);
|
|
938
|
+
return match ? match.id : null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an
|
|
942
|
+
// existing same-title group in its window when present, otherwise create a new group.
|
|
943
|
+
async function groupTab(tab, title, color) {
|
|
944
|
+
if (!chrome.tabGroups) throw new Error("chrome.tabGroups API unavailable; reload the extension after granting the tabGroups permission");
|
|
945
|
+
if (!tab || typeof tab.id !== "number") throw new Error("No tab to group");
|
|
946
|
+
const groupTitle = cleanGroupTitle(title);
|
|
947
|
+
let groupId = tab.groupId;
|
|
948
|
+
if (typeof groupId !== "number" || groupId < 0) {
|
|
949
|
+
const existing = await findGroupByTitle(tab.windowId, groupTitle);
|
|
950
|
+
groupId = existing !== null
|
|
951
|
+
? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] })
|
|
952
|
+
: await chrome.tabs.group({ tabIds: [tab.id] });
|
|
953
|
+
}
|
|
954
|
+
await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false });
|
|
955
|
+
const grouped = await chrome.tabs.get(tab.id);
|
|
956
|
+
return { tab: await formatTab(grouped), group: await groupRecord(groupId) };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async function dispatch(action, params) {
|
|
960
|
+
switch (action) {
|
|
961
|
+
case "tab.version":
|
|
962
|
+
return {
|
|
963
|
+
extensionId: chrome.runtime.id,
|
|
964
|
+
extensionVersion: chrome.runtime.getManifest().version,
|
|
965
|
+
bridgeUrl: BRIDGE_URL,
|
|
966
|
+
userAgent: navigator.userAgent,
|
|
967
|
+
};
|
|
968
|
+
case "tab.list": {
|
|
969
|
+
const tabs = await chrome.tabs.query({});
|
|
970
|
+
return Promise.all(tabs.map(formatTab));
|
|
971
|
+
}
|
|
972
|
+
case "tab.new": {
|
|
973
|
+
const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: true });
|
|
974
|
+
// Every Pi-opened tab joins a group by default. Pass groupTitle:"" (or group:false) to opt out.
|
|
975
|
+
const optOut = params.groupTitle === "" || params.group === false;
|
|
976
|
+
if (optOut && !params.groupColor) return formatTab(tab);
|
|
977
|
+
return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
|
|
978
|
+
}
|
|
979
|
+
case "tab.activate": {
|
|
980
|
+
const tab = await getTabByParams(params);
|
|
981
|
+
await chrome.windows.update(tab.windowId, { focused: true });
|
|
982
|
+
return formatTab(await chrome.tabs.update(tab.id, { active: true }));
|
|
983
|
+
}
|
|
984
|
+
case "tab.group": {
|
|
985
|
+
const tab = await getTabByParams(params);
|
|
986
|
+
return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
|
|
987
|
+
}
|
|
988
|
+
case "tab.ungroup": {
|
|
989
|
+
const tab = await getTabByParams(params);
|
|
990
|
+
if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id);
|
|
991
|
+
return formatTab(await chrome.tabs.get(tab.id));
|
|
992
|
+
}
|
|
993
|
+
case "tab.close": {
|
|
994
|
+
const tab = await getTabByParams(params);
|
|
995
|
+
await chrome.tabs.remove(tab.id);
|
|
996
|
+
return { closed: tab.id };
|
|
997
|
+
}
|
|
998
|
+
case "page.snapshot":
|
|
999
|
+
return snapshotInTab(params);
|
|
1000
|
+
case "page.inspect":
|
|
1001
|
+
return inspectInTab(params);
|
|
1002
|
+
case "page.evaluate":
|
|
1003
|
+
return evaluateInTab(params);
|
|
1004
|
+
case "page.click":
|
|
1005
|
+
return withOptionalSnapshot(params, chromeInputClick);
|
|
1006
|
+
case "page.hover":
|
|
1007
|
+
return chromeInputHover(params);
|
|
1008
|
+
case "page.drag":
|
|
1009
|
+
return chromeInputDrag(params);
|
|
1010
|
+
case "page.upload":
|
|
1011
|
+
return chromeInputUpload(params);
|
|
1012
|
+
case "page.type":
|
|
1013
|
+
return withOptionalSnapshot(params, chromeInputType);
|
|
1014
|
+
case "page.fill":
|
|
1015
|
+
return withOptionalSnapshot(params, chromeInputFill);
|
|
1016
|
+
case "page.key":
|
|
1017
|
+
return withOptionalSnapshot(params, chromeInputKey);
|
|
1018
|
+
case "page.scroll":
|
|
1019
|
+
return chromeInputScroll(params);
|
|
1020
|
+
case "page.tap":
|
|
1021
|
+
return chromeInputTap(params);
|
|
1022
|
+
case "input.status":
|
|
1023
|
+
return inputStatus();
|
|
1024
|
+
case "input.debug":
|
|
1025
|
+
return inputDebug(params);
|
|
1026
|
+
case "page.console.list":
|
|
1027
|
+
return executeInTab(params, listConsoleMessages, [params.clear === true]);
|
|
1028
|
+
case "page.network.list":
|
|
1029
|
+
return executeInTab(params, listNetworkRequests, [params.includePreservedRequests === true, params.clear === true]);
|
|
1030
|
+
case "page.network.get":
|
|
1031
|
+
return executeInTab(params, getNetworkRequest, [params.requestId]);
|
|
1032
|
+
case "page.waitFor": {
|
|
1033
|
+
// Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling
|
|
1034
|
+
// loop in-page with new Function() for expression checks, which fails under strict CSP.
|
|
1035
|
+
const tab = await getTabByParams(params);
|
|
1036
|
+
if (params.foreground) await bringToFront(tab);
|
|
1037
|
+
const timeoutMs = params.timeoutMs || 10000;
|
|
1038
|
+
const intervalMs = params.intervalMs || 250;
|
|
1039
|
+
const started = Date.now();
|
|
1040
|
+
while (Date.now() - started < timeoutMs) {
|
|
1041
|
+
let ok = false;
|
|
1042
|
+
try {
|
|
1043
|
+
const expr = params.kind === "selector"
|
|
1044
|
+
? `!!document.querySelector(${JSON.stringify(params.value)})`
|
|
1045
|
+
: params.value;
|
|
1046
|
+
ok = Boolean(await evaluateInTab({ ...params, expression: expr, foreground: false }));
|
|
1047
|
+
} catch {
|
|
1048
|
+
ok = false;
|
|
1049
|
+
}
|
|
1050
|
+
if (ok) return { elapsedMs: Date.now() - started };
|
|
1051
|
+
await sleep(intervalMs);
|
|
1052
|
+
}
|
|
1053
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${params.kind}: ${params.value}`);
|
|
1054
|
+
}
|
|
1055
|
+
case "page.probe":
|
|
1056
|
+
// Lightweight capability probe for /chrome-doctor. Runs in MAIN world.
|
|
1057
|
+
return executeInTab(params, probePage, []);
|
|
1058
|
+
case "page.navigate": {
|
|
1059
|
+
const tab = await getTabByParams(params);
|
|
1060
|
+
if (params.foreground) await bringToFront(tab);
|
|
1061
|
+
if (params.initScript) {
|
|
1062
|
+
// Register a one-shot document_start content script. We register, navigate, wait, then unregister.
|
|
1063
|
+
await registerInitScript(tab.id, params.initScript);
|
|
1064
|
+
}
|
|
1065
|
+
const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
|
|
1066
|
+
const updated = await chrome.tabs.update(tab.id, { url: params.url });
|
|
1067
|
+
try {
|
|
1068
|
+
await wait;
|
|
1069
|
+
} finally {
|
|
1070
|
+
if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined);
|
|
1071
|
+
}
|
|
1072
|
+
return await formatTab(await chrome.tabs.get(updated.id));
|
|
1073
|
+
}
|
|
1074
|
+
case "page.screenshot":
|
|
1075
|
+
return takeScreenshot(params);
|
|
1076
|
+
default:
|
|
1077
|
+
throw new Error(`Unknown action: ${action}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function formatTab(tab) {
|
|
1082
|
+
return {
|
|
1083
|
+
id: tab.id,
|
|
1084
|
+
windowId: tab.windowId,
|
|
1085
|
+
active: tab.active,
|
|
1086
|
+
highlighted: tab.highlighted,
|
|
1087
|
+
title: tab.title || "",
|
|
1088
|
+
url: tab.url || "",
|
|
1089
|
+
status: tab.status,
|
|
1090
|
+
pinned: tab.pinned,
|
|
1091
|
+
incognito: tab.incognito,
|
|
1092
|
+
groupId: typeof tab.groupId === "number" ? tab.groupId : -1,
|
|
1093
|
+
group: await groupRecord(tab.groupId),
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async function getTabByParams(params) {
|
|
1098
|
+
const tabs = await chrome.tabs.query({});
|
|
1099
|
+
let tab;
|
|
1100
|
+
if (params.targetId !== undefined) {
|
|
1101
|
+
const id = Number(params.targetId);
|
|
1102
|
+
tab = await chrome.tabs.get(id).catch(() => null);
|
|
1103
|
+
if (!tab?.id) {
|
|
1104
|
+
// Chrome tab ids are not stable across reloads/navigations; a long session can hold a
|
|
1105
|
+
// stale id. Surface the current tabs so the caller can re-target instead of guessing.
|
|
1106
|
+
const listed = tabs
|
|
1107
|
+
.filter((candidate) => candidate.id !== undefined)
|
|
1108
|
+
.slice(0, 20)
|
|
1109
|
+
.map((candidate) => ` ${candidate.id}${candidate.active ? " *" : ""}\t${(candidate.title || "(untitled)").slice(0, 60)}\t${candidate.url || ""}`)
|
|
1110
|
+
.join("\n");
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
`No Chrome tab with id ${id} (it was likely closed or replaced). ` +
|
|
1113
|
+
`Re-target with chrome_tab list, or pass urlIncludes/titleIncludes instead of targetId.\n` +
|
|
1114
|
+
`Current tabs:\n${listed || " (none)"}`,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
} else if (params.urlIncludes) {
|
|
1118
|
+
tab = tabs.find((candidate) => (candidate.url || "").includes(params.urlIncludes));
|
|
1119
|
+
} else if (params.titleIncludes) {
|
|
1120
|
+
tab = tabs.find((candidate) => (candidate.title || "").includes(params.titleIncludes));
|
|
1121
|
+
} else {
|
|
1122
|
+
const active = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
1123
|
+
tab = active[0] || tabs.find((candidate) => candidate.active) || tabs[0];
|
|
1124
|
+
}
|
|
1125
|
+
if (!tab?.id) throw new Error("No matching Chrome tab found");
|
|
1126
|
+
const url = tab.url || "";
|
|
1127
|
+
if (url.startsWith("chrome://") || url.startsWith("chrome-extension://") || url.startsWith("devtools://")) {
|
|
1128
|
+
throw new Error(`Chrome blocks extension automation on protected URL: tab=${tab.id} url=${url}`);
|
|
1129
|
+
}
|
|
1130
|
+
// Tabs Pi interacts with (page.* actions) join this session's group so the user can see exactly
|
|
1131
|
+
// which tabs Pi is driving. We only adopt *ungrouped* tabs — never hijack a tab the user (or
|
|
1132
|
+
// another Pi session) already grouped, since groupTab would otherwise rename that group.
|
|
1133
|
+
if (params.joinSessionGroup && params.sessionGroupTitle) {
|
|
1134
|
+
await joinSessionGroup(tab, params.sessionGroupTitle);
|
|
1135
|
+
}
|
|
1136
|
+
return tab;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Add an ungrouped tab to the session's tab group (reusing it by title, else creating it).
|
|
1140
|
+
// No-op when the tab is already grouped or tabGroups is unavailable.
|
|
1141
|
+
async function joinSessionGroup(tab, title) {
|
|
1142
|
+
if (!chrome.tabGroups || typeof tab.id !== "number") return;
|
|
1143
|
+
if (typeof tab.groupId === "number" && tab.groupId >= 0) return;
|
|
1144
|
+
try {
|
|
1145
|
+
await groupTab(tab, title);
|
|
1146
|
+
} catch {
|
|
1147
|
+
// Grouping is best-effort; never block the actual page action on a grouping failure.
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Helper sources that get concatenated into the injected MAIN-world script. Kept as separate
|
|
1152
|
+
// functions so callers below can reference them by `.toString()`. The helpers do not perform any
|
|
1153
|
+
// eval themselves — they're plain function declarations.
|
|
1154
|
+
const HELPER_FUNCS = [
|
|
1155
|
+
getPiChromeState,
|
|
1156
|
+
rememberElement,
|
|
1157
|
+
elementBySelectorOrUid,
|
|
1158
|
+
installPiChromeInstrumentation,
|
|
1159
|
+
resolvePoint,
|
|
1160
|
+
dispatchInputEvents,
|
|
1161
|
+
setNativeValue,
|
|
1162
|
+
normalizeKey,
|
|
1163
|
+
isElementVisible,
|
|
1164
|
+
occluderAt,
|
|
1165
|
+
pageHash,
|
|
1166
|
+
pointerEventSequence,
|
|
1167
|
+
sleepPage,
|
|
1168
|
+
rand,
|
|
1169
|
+
dispatchPointerLikeEvent,
|
|
1170
|
+
humanMoveTo,
|
|
1171
|
+
humanClickPoint,
|
|
1172
|
+
usKeyLayoutForChar,
|
|
1173
|
+
printableKeyCode,
|
|
1174
|
+
dispatchKeyEvent,
|
|
1175
|
+
typeCharacter,
|
|
1176
|
+
pressKeyInPage,
|
|
1177
|
+
scrollPage,
|
|
1178
|
+
];
|
|
1179
|
+
|
|
1180
|
+
async function executeInTab(params, func, args) {
|
|
1181
|
+
const tab = await getTabByParams(params);
|
|
1182
|
+
if (params.foreground) await bringToFront(tab);
|
|
1183
|
+
|
|
1184
|
+
// Phase 1: define the helpers and the action function as page globals via CDP
|
|
1185
|
+
// Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the
|
|
1186
|
+
// root cause of snapshot/click/etc silently failing on `script-src 'self'` sites.
|
|
1187
|
+
// Each helper is a named function declaration, assigned to window.<name> so the action
|
|
1188
|
+
// (which references helpers by bare name) resolves them as globals at call time.
|
|
1189
|
+
const assignments = HELPER_FUNCS.map((helper) => `window.${helper.name}=${helper.toString()}`).join(";\n");
|
|
1190
|
+
const actionAssign = `window.__piAction=(${func.toString()})`;
|
|
1191
|
+
const defineRes = await cdpEval(tab.id, `(()=>{${assignments};\n${actionAssign};})()`);
|
|
1192
|
+
if (defineRes.exceptionDetails) {
|
|
1193
|
+
throw new Error(`Failed to inject Chrome page helpers: ${cdpExceptionText(defineRes.exceptionDetails) || "unknown error"}`);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
|
|
1197
|
+
// injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
|
|
1198
|
+
// serialize the invocation args. The wrapper references window.__piAction defined above.
|
|
1199
|
+
const results = await executeScriptTimed({
|
|
1200
|
+
target: { tabId: tab.id },
|
|
1201
|
+
world: "MAIN",
|
|
1202
|
+
func: async (invocationArgs) => {
|
|
1203
|
+
try {
|
|
1204
|
+
return { ok: true, value: await window.__piAction(...invocationArgs) };
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
1207
|
+
}
|
|
1208
|
+
},
|
|
1209
|
+
args: [args || []],
|
|
1210
|
+
}, `execute page action in tab ${tab.id}`);
|
|
1211
|
+
const first = results?.[0];
|
|
1212
|
+
if (first?.error) {
|
|
1213
|
+
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|
|
1214
|
+
throw new Error(message);
|
|
1215
|
+
}
|
|
1216
|
+
const envelope = first?.result;
|
|
1217
|
+
if (envelope && typeof envelope === "object" && envelope.ok === false) {
|
|
1218
|
+
throw new Error(envelope.error || "Chrome page script failed");
|
|
1219
|
+
}
|
|
1220
|
+
return envelope?.value;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Serializer for page.evaluate results. Embedded (via .toString()) into the CDP-evaluated
|
|
1224
|
+
// expression so we can return rich markers for values that don't survive returnByValue
|
|
1225
|
+
// (undefined/function/symbol/bigint/Error), plus expand DOMRect-like objects whose fields
|
|
1226
|
+
// are non-enumerable. Kept as a standalone function so it stays editable/lintable.
|
|
1227
|
+
function piEvalStringify(v) {
|
|
1228
|
+
if (v === undefined) return { kind: "undefined" };
|
|
1229
|
+
if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) };
|
|
1230
|
+
if (typeof v === "symbol") return { kind: "symbol", description: v.description };
|
|
1231
|
+
if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
|
|
1232
|
+
if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
|
|
1233
|
+
// DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable
|
|
1234
|
+
// properties, so JSON.stringify yields `{}`. Expand the fields explicitly.
|
|
1235
|
+
if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) ||
|
|
1236
|
+
(typeof DOMRect !== "undefined" && v instanceof DOMRect) ||
|
|
1237
|
+
(v && typeof v === "object" && typeof v.toJSON === "function" &&
|
|
1238
|
+
typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) {
|
|
1239
|
+
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 };
|
|
1240
|
+
}
|
|
1241
|
+
return v;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Dedicated executor for page.evaluate. Uses CDP Runtime.evaluate (via cdpEval) which is not
|
|
1245
|
+
// subject to the page's CSP, fixing `chrome_evaluate` silently returning null / failing on
|
|
1246
|
+
// pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`).
|
|
1247
|
+
async function evaluateInTab(params) {
|
|
1248
|
+
const tab = await getTabByParams(params);
|
|
1249
|
+
if (params.foreground) await bringToFront(tab);
|
|
1250
|
+
const expression = String(params.expression ?? "");
|
|
1251
|
+
const stringifySrc = `(${piEvalStringify.toString()})`;
|
|
1252
|
+
// Wrap the user expression so the result is run through piEvalStringify in-page before it
|
|
1253
|
+
// crosses the returnByValue boundary. Try expression form first (so `1+1` / `document.title`
|
|
1254
|
+
// work without `return`); on a SyntaxError fall back to statement form for multi-statement
|
|
1255
|
+
// bodies (loops, var decls, etc), matching the previous new Function() two-form behavior.
|
|
1256
|
+
const buildWrapper = (form) => `(async () => { const __s=${stringifySrc}; const __v = await ${form}; return __s(__v); })()`;
|
|
1257
|
+
const exprForm = `(async () => (${expression}))()`;
|
|
1258
|
+
const stmtForm = `(async () => { ${expression} })()`;
|
|
1259
|
+
|
|
1260
|
+
let res = await cdpEval(tab.id, buildWrapper(exprForm));
|
|
1261
|
+
if (res.exceptionDetails && cdpIsSyntaxError(res.exceptionDetails)) {
|
|
1262
|
+
res = await cdpEval(tab.id, buildWrapper(stmtForm));
|
|
1263
|
+
}
|
|
1264
|
+
if (res.exceptionDetails) {
|
|
1265
|
+
throw new Error(`chrome_evaluate failed: ${cdpExceptionText(res.exceptionDetails) || "evaluation failed"}`);
|
|
1266
|
+
}
|
|
1267
|
+
const result = res.result;
|
|
1268
|
+
if (!result || result.type === "undefined") return undefined;
|
|
1269
|
+
const v = result.value;
|
|
1270
|
+
// Unwrap special markers produced by piEvalStringify.
|
|
1271
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
1272
|
+
if (v.kind === "undefined") return undefined;
|
|
1273
|
+
if (v.kind === "function") return `[Function: ${v.source}]`;
|
|
1274
|
+
if (v.kind === "symbol") return `[Symbol: ${v.description}]`;
|
|
1275
|
+
if (v.kind === "bigint") return v.value;
|
|
1276
|
+
if (v.kind === "error") throw new Error(`${v.name}: ${v.message}\n${v.stack || ""}`);
|
|
1277
|
+
}
|
|
1278
|
+
return v;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function withOptionalSnapshot(params, actionFn) {
|
|
1282
|
+
const result = await actionFn(params);
|
|
1283
|
+
if (params.includeSnapshot) {
|
|
1284
|
+
const snapshot = await snapshotInTab({ ...params, foreground: false });
|
|
1285
|
+
return { result, snapshot };
|
|
1286
|
+
}
|
|
1287
|
+
return result;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Snapshot/inspect run from a packaged MAIN-world script (snapshot_injected.js) injected via
|
|
1291
|
+
// chrome.scripting.executeScript({ files }). That file is free of eval/new Function, so it works
|
|
1292
|
+
// on strict-CSP pages, and it installs globalThis.__piChromeSnapshotPage / __piChromeInspectTarget.
|
|
1293
|
+
// It shares window.__PI_CHROME_STATE__ (same el- uid scheme) with the CDP-injected input helpers.
|
|
1294
|
+
async function snapshotInTab(params) {
|
|
1295
|
+
const tab = await getTabByParams(params);
|
|
1296
|
+
if (params.foreground) await bringToFront(tab);
|
|
1297
|
+
const args = [
|
|
1298
|
+
params.maxElements || 80,
|
|
1299
|
+
params.containingText ?? null,
|
|
1300
|
+
params.roleFilter ?? null,
|
|
1301
|
+
params.nearUid ?? null,
|
|
1302
|
+
params.mode || "auto",
|
|
1303
|
+
params.query ?? null,
|
|
1304
|
+
params.maxTextChars ?? null,
|
|
1305
|
+
];
|
|
1306
|
+
await executeScriptTimed({
|
|
1307
|
+
target: { tabId: tab.id, frameIds: [0] },
|
|
1308
|
+
world: "MAIN",
|
|
1309
|
+
files: ["snapshot_injected.js"],
|
|
1310
|
+
}, `inject snapshot script in tab ${tab.id}`);
|
|
1311
|
+
const results = await executeScriptTimed({
|
|
1312
|
+
target: { tabId: tab.id, frameIds: [0] },
|
|
1313
|
+
world: "MAIN",
|
|
1314
|
+
func: async (invocationArgs) => {
|
|
1315
|
+
try {
|
|
1316
|
+
const snapshotPage = globalThis.__piChromeSnapshotPage;
|
|
1317
|
+
if (typeof snapshotPage !== "function") throw new Error("snapshot_injected.js did not install __piChromeSnapshotPage");
|
|
1318
|
+
return { ok: true, value: await snapshotPage(...invocationArgs) };
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
1321
|
+
}
|
|
1322
|
+
},
|
|
1323
|
+
args: [args],
|
|
1324
|
+
}, `run snapshot script in tab ${tab.id}`);
|
|
1325
|
+
const first = results?.[0];
|
|
1326
|
+
if (first?.error) {
|
|
1327
|
+
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|
|
1328
|
+
throw new Error(message);
|
|
1329
|
+
}
|
|
1330
|
+
const envelope = first?.result;
|
|
1331
|
+
if (envelope && typeof envelope === "object" && envelope.ok === false) {
|
|
1332
|
+
throw new Error(envelope.error || "Chrome snapshot script failed");
|
|
1333
|
+
}
|
|
1334
|
+
return envelope?.value;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function inspectInTab(params) {
|
|
1338
|
+
if (!params.uid && !params.selector) throw new Error("chrome_inspect requires uid or selector");
|
|
1339
|
+
const tab = await getTabByParams(params);
|
|
1340
|
+
if (params.foreground) await bringToFront(tab);
|
|
1341
|
+
const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true];
|
|
1342
|
+
await executeScriptTimed({
|
|
1343
|
+
target: { tabId: tab.id, frameIds: [0] },
|
|
1344
|
+
world: "MAIN",
|
|
1345
|
+
files: ["snapshot_injected.js"],
|
|
1346
|
+
}, `inject inspect script in tab ${tab.id}`);
|
|
1347
|
+
const results = await executeScriptTimed({
|
|
1348
|
+
target: { tabId: tab.id, frameIds: [0] },
|
|
1349
|
+
world: "MAIN",
|
|
1350
|
+
func: async (invocationArgs) => {
|
|
1351
|
+
try {
|
|
1352
|
+
const inspectTarget = globalThis.__piChromeInspectTarget;
|
|
1353
|
+
if (typeof inspectTarget !== "function") throw new Error("snapshot_injected.js did not install __piChromeInspectTarget");
|
|
1354
|
+
return { ok: true, value: await inspectTarget(...invocationArgs) };
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
1357
|
+
}
|
|
1358
|
+
},
|
|
1359
|
+
args: [args],
|
|
1360
|
+
}, `run inspect script in tab ${tab.id}`);
|
|
1361
|
+
const first = results?.[0];
|
|
1362
|
+
if (first?.error) {
|
|
1363
|
+
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|
|
1364
|
+
throw new Error(message);
|
|
1365
|
+
}
|
|
1366
|
+
const envelope = first?.result;
|
|
1367
|
+
if (envelope && typeof envelope === "object" && envelope.ok === false) {
|
|
1368
|
+
throw new Error(envelope.error || "Chrome inspect script failed");
|
|
1369
|
+
}
|
|
1370
|
+
return envelope?.value;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// One-shot init script registry, scoped per tab. The source is registered with CDP
|
|
1374
|
+
// Page.addScriptToEvaluateOnNewDocument, which runs it at document_start in the page's MAIN
|
|
1375
|
+
// world and is NOT subject to page CSP (the old func:(code)=>new Function(code) path was
|
|
1376
|
+
// blocked by `script-src 'self'`). page.navigate registers before the nav and unregisters
|
|
1377
|
+
// after load, so only the intended navigation receives the script.
|
|
1378
|
+
const initScriptIds = new Map(); // tabId -> CDP script identifier
|
|
1379
|
+
async function registerInitScript(tabId, source) {
|
|
1380
|
+
await attachDebugger(tabId);
|
|
1381
|
+
await cdp(tabId, "Page.enable", {}).catch(() => undefined);
|
|
1382
|
+
const result = await cdp(tabId, "Page.addScriptToEvaluateOnNewDocument", { source });
|
|
1383
|
+
if (result && result.identifier !== undefined) initScriptIds.set(tabId, result.identifier);
|
|
1384
|
+
}
|
|
1385
|
+
async function unregisterInitScript(tabId) {
|
|
1386
|
+
const identifier = initScriptIds.get(tabId);
|
|
1387
|
+
if (identifier === undefined) return;
|
|
1388
|
+
initScriptIds.delete(tabId);
|
|
1389
|
+
await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Always inject early console/network capture at document_start on every navigation.
|
|
1393
|
+
// Catches console messages, errors, and network requests that fire during page load,
|
|
1394
|
+
// before chrome_snapshot or chrome_evaluate install the instrumentation normally.
|
|
1395
|
+
// The function installEarlyCapture sets __piChromeWrapped flags so the post-hoc
|
|
1396
|
+
// installPiChromeInstrumentation() call is idempotent.
|
|
1397
|
+
if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
|
|
1398
|
+
chrome.webNavigation.onCommitted.addListener((details) => {
|
|
1399
|
+
if (details.frameId !== 0) return;
|
|
1400
|
+
chrome.scripting.executeScript({
|
|
1401
|
+
target: { tabId: details.tabId, frameIds: [0] },
|
|
1402
|
+
world: "MAIN",
|
|
1403
|
+
injectImmediately: true,
|
|
1404
|
+
func: installEarlyCapture,
|
|
1405
|
+
args: [],
|
|
1406
|
+
}).catch(() => undefined);
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
async function bringToFront(tab) {
|
|
1411
|
+
await chrome.windows.update(tab.windowId, { focused: true });
|
|
1412
|
+
await chrome.tabs.update(tab.id, { active: true });
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function waitForTabComplete(tabId, timeoutMs) {
|
|
1416
|
+
return new Promise((resolve, reject) => {
|
|
1417
|
+
const timer = setTimeout(() => {
|
|
1418
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
1419
|
+
reject(new Error(`Timed out after ${timeoutMs}ms waiting for tab ${tabId} to load`));
|
|
1420
|
+
}, timeoutMs);
|
|
1421
|
+
const listener = (updatedTabId, changeInfo) => {
|
|
1422
|
+
if (updatedTabId === tabId && changeInfo.status === "complete") {
|
|
1423
|
+
clearTimeout(timer);
|
|
1424
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
1425
|
+
resolve(true);
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async function takeScreenshot(params) {
|
|
1433
|
+
const tab = await getTabByParams(params);
|
|
1434
|
+
if (params.foreground) await bringToFront(tab);
|
|
1435
|
+
let previousActiveId;
|
|
1436
|
+
if (!tab.active) {
|
|
1437
|
+
const activeBefore = await chrome.tabs.query({ active: true, windowId: tab.windowId });
|
|
1438
|
+
previousActiveId = activeBefore[0]?.id;
|
|
1439
|
+
await chrome.tabs.update(tab.id, { active: true });
|
|
1440
|
+
}
|
|
1441
|
+
try {
|
|
1442
|
+
if (params.fullPage) {
|
|
1443
|
+
// Tile-stitched full page capture: scroll, capture, paste, repeat.
|
|
1444
|
+
const tiles = await executeInTab({ ...params, foreground: false }, captureFullPageTiles, []);
|
|
1445
|
+
// captureFullPageTiles only computes scroll positions / metrics; we capture per scroll here
|
|
1446
|
+
// (chrome.tabs.captureVisibleTab can't be called from MAIN world).
|
|
1447
|
+
const captured = [];
|
|
1448
|
+
for (const tile of tiles.tiles) {
|
|
1449
|
+
await executeInTab({ ...params, foreground: false }, scrollToY, [tile.scrollY]);
|
|
1450
|
+
// Small settle delay; many sites have on-scroll animations / lazy-load.
|
|
1451
|
+
await sleep(120);
|
|
1452
|
+
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
|
1453
|
+
format: params.format || "png",
|
|
1454
|
+
quality: params.format === "jpeg" ? params.quality : undefined,
|
|
1455
|
+
});
|
|
1456
|
+
captured.push({ y: tile.y, dataUrl });
|
|
1457
|
+
}
|
|
1458
|
+
await executeInTab({ ...params, foreground: false }, scrollToY, [tiles.originalScrollY]);
|
|
1459
|
+
return {
|
|
1460
|
+
fullPage: true,
|
|
1461
|
+
tab: await formatTab(tab),
|
|
1462
|
+
dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr },
|
|
1463
|
+
tiles: captured,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
|
1467
|
+
format: params.format || "png",
|
|
1468
|
+
quality: params.format === "jpeg" ? params.quality : undefined,
|
|
1469
|
+
});
|
|
1470
|
+
return { dataUrl, tab: await formatTab(tab) };
|
|
1471
|
+
} finally {
|
|
1472
|
+
if (previousActiveId !== undefined && previousActiveId !== tab.id) {
|
|
1473
|
+
await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// ---------------------------------------------------------------------------
|
|
1479
|
+
// MAIN-world helpers (function declarations injected into the page).
|
|
1480
|
+
// ---------------------------------------------------------------------------
|
|
1481
|
+
|
|
1482
|
+
function getPiChromeState() {
|
|
1483
|
+
const state = window.__PI_CHROME_STATE__ || {
|
|
1484
|
+
nextElementUid: 1,
|
|
1485
|
+
elements: {},
|
|
1486
|
+
console: [],
|
|
1487
|
+
network: [],
|
|
1488
|
+
nextRequestId: 1,
|
|
1489
|
+
instrumentationInstalled: false,
|
|
1490
|
+
};
|
|
1491
|
+
window.__PI_CHROME_STATE__ = state;
|
|
1492
|
+
return state;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function rememberElement(element) {
|
|
1496
|
+
const state = getPiChromeState();
|
|
1497
|
+
if (!element.__piChromeUid) element.__piChromeUid = "el-" + state.nextElementUid++;
|
|
1498
|
+
state.elements[element.__piChromeUid] = element;
|
|
1499
|
+
return element.__piChromeUid;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function elementBySelectorOrUid(selector, uid) {
|
|
1503
|
+
if (uid) {
|
|
1504
|
+
const element = getPiChromeState().elements[uid];
|
|
1505
|
+
if (!element || !element.isConnected) throw new Error(`No live element for uid: ${uid}. Take a fresh chrome_snapshot.`);
|
|
1506
|
+
return element;
|
|
1507
|
+
}
|
|
1508
|
+
if (selector) {
|
|
1509
|
+
const element = document.querySelector(selector);
|
|
1510
|
+
if (!element) throw new Error(`No element matches selector: ${selector}`);
|
|
1511
|
+
return element;
|
|
1512
|
+
}
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function isElementVisible(element) {
|
|
1517
|
+
if (!element || !element.getBoundingClientRect) return false;
|
|
1518
|
+
const style = getComputedStyle(element);
|
|
1519
|
+
if (style.visibility === "hidden" || style.display === "none") return false;
|
|
1520
|
+
const rect = element.getBoundingClientRect();
|
|
1521
|
+
if (rect.width === 0 || rect.height === 0) return false;
|
|
1522
|
+
if (rect.bottom < 0 || rect.right < 0) return false;
|
|
1523
|
+
if (rect.top > innerHeight || rect.left > innerWidth) return false;
|
|
1524
|
+
return true;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function occluderAt(x, y, expected) {
|
|
1528
|
+
const top = document.elementFromPoint(x, y);
|
|
1529
|
+
if (!top || top === expected) return null;
|
|
1530
|
+
if (expected && expected.contains(top)) return null;
|
|
1531
|
+
if (top.contains(expected)) return null;
|
|
1532
|
+
return {
|
|
1533
|
+
tag: top.tagName.toLowerCase(),
|
|
1534
|
+
id: top.id || undefined,
|
|
1535
|
+
className: typeof top.className === "string" ? top.className : undefined,
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function pageHash() {
|
|
1540
|
+
// Cheap rolling hash used for `pageMutated`. Combines first 4kb of body innerText with the
|
|
1541
|
+
// current values of inputs/textareas (which are not part of innerText) and the count of
|
|
1542
|
+
// descendants of <body>. This catches: text changes, input value edits, and DOM structure
|
|
1543
|
+
// changes — the three things a click/type/fill might cause.
|
|
1544
|
+
const body = document.body;
|
|
1545
|
+
const text = (body ? body.innerText : "").slice(0, 4000);
|
|
1546
|
+
let h = 0;
|
|
1547
|
+
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
|
1548
|
+
if (body) {
|
|
1549
|
+
const inputs = body.querySelectorAll("input,textarea,select");
|
|
1550
|
+
let valueBlob = "";
|
|
1551
|
+
for (let i = 0; i < inputs.length && valueBlob.length < 4000; i++) {
|
|
1552
|
+
const v = inputs[i].value;
|
|
1553
|
+
if (typeof v === "string") valueBlob += v + "\x00";
|
|
1554
|
+
}
|
|
1555
|
+
for (let i = 0; i < valueBlob.length; i++) h = (h * 31 + valueBlob.charCodeAt(i)) | 0;
|
|
1556
|
+
h = (h * 31 + body.getElementsByTagName("*").length) | 0;
|
|
1557
|
+
}
|
|
1558
|
+
return h;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function sleepPage(ms) {
|
|
1562
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function rand(min, max) {
|
|
1566
|
+
return min + Math.random() * (max - min);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function dispatchPointerLikeEvent(element, type, x, y, prevX, prevY, opts = {}) {
|
|
1570
|
+
const isPointer = type.startsWith("pointer");
|
|
1571
|
+
const Ctor = isPointer ? PointerEvent : MouseEvent;
|
|
1572
|
+
const isMove = type === "pointermove" || type === "mousemove";
|
|
1573
|
+
const isUpOrClick = type === "pointerup" || type === "mouseup" || type === "click";
|
|
1574
|
+
const init = {
|
|
1575
|
+
bubbles: true,
|
|
1576
|
+
cancelable: true,
|
|
1577
|
+
view: window,
|
|
1578
|
+
clientX: x,
|
|
1579
|
+
clientY: y,
|
|
1580
|
+
screenX: x + (window.screenX || 0),
|
|
1581
|
+
screenY: y + (window.screenY || 0),
|
|
1582
|
+
movementX: Number.isFinite(prevX) ? x - prevX : 0,
|
|
1583
|
+
movementY: Number.isFinite(prevY) ? y - prevY : 0,
|
|
1584
|
+
button: 0,
|
|
1585
|
+
buttons: isMove || isUpOrClick ? 0 : 1,
|
|
1586
|
+
};
|
|
1587
|
+
if (isPointer) {
|
|
1588
|
+
init.pointerType = "mouse";
|
|
1589
|
+
init.pointerId = 1;
|
|
1590
|
+
init.isPrimary = true;
|
|
1591
|
+
init.width = 1;
|
|
1592
|
+
init.height = 1;
|
|
1593
|
+
init.pressure = opts.pressure ?? (type === "pointerdown" ? 0.5 : 0);
|
|
1594
|
+
init.tangentialPressure = 0;
|
|
1595
|
+
init.tiltX = 0;
|
|
1596
|
+
init.tiltY = 0;
|
|
1597
|
+
}
|
|
1598
|
+
const ev = new Ctor(type, init);
|
|
1599
|
+
element.dispatchEvent(ev);
|
|
1600
|
+
return ev.defaultPrevented;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function pointerEventSequence(element, x, y, sequence) {
|
|
1604
|
+
let defaultPrevented = false;
|
|
1605
|
+
const state = getPiChromeState();
|
|
1606
|
+
const prevX = state.pointer?.x;
|
|
1607
|
+
const prevY = state.pointer?.y;
|
|
1608
|
+
for (const type of sequence) {
|
|
1609
|
+
defaultPrevented = dispatchPointerLikeEvent(element, type, x, y, prevX, prevY) || defaultPrevented;
|
|
1610
|
+
}
|
|
1611
|
+
state.pointer = { x, y, t: performance.now() };
|
|
1612
|
+
return defaultPrevented;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
async function humanMoveTo(x, y, steps) {
|
|
1616
|
+
const state = getPiChromeState();
|
|
1617
|
+
const startX = Number.isFinite(state.pointer?.x) ? state.pointer.x : rand(12, Math.max(24, innerWidth - 12));
|
|
1618
|
+
const startY = Number.isFinite(state.pointer?.y) ? state.pointer.y : rand(12, Math.max(24, innerHeight - 12));
|
|
1619
|
+
const n = steps || Math.max(12, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18)));
|
|
1620
|
+
let prevX = startX, prevY = startY;
|
|
1621
|
+
let defaultPrevented = false;
|
|
1622
|
+
for (let i = 1; i <= n; i++) {
|
|
1623
|
+
const t = i / n;
|
|
1624
|
+
const ease = t * t * (3 - 2 * t);
|
|
1625
|
+
const wobble = Math.sin(t * Math.PI) * 8;
|
|
1626
|
+
const px = startX + (x - startX) * ease + rand(-wobble, wobble);
|
|
1627
|
+
const py = startY + (y - startY) * ease + rand(-wobble, wobble);
|
|
1628
|
+
const el = document.elementFromPoint(px, py) || document.body || document.documentElement;
|
|
1629
|
+
defaultPrevented = dispatchPointerLikeEvent(el, "pointermove", px, py, prevX, prevY) || defaultPrevented;
|
|
1630
|
+
defaultPrevented = dispatchPointerLikeEvent(el, "mousemove", px, py, prevX, prevY) || defaultPrevented;
|
|
1631
|
+
prevX = px; prevY = py;
|
|
1632
|
+
await sleepPage(rand(4, 18));
|
|
1633
|
+
}
|
|
1634
|
+
state.pointer = { x, y, t: performance.now() };
|
|
1635
|
+
return defaultPrevented;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function humanClickPoint(point) {
|
|
1639
|
+
if (!point.rect) return { x: point.x, y: point.y };
|
|
1640
|
+
const rect = point.rect;
|
|
1641
|
+
const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
|
|
1642
|
+
const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
|
|
1643
|
+
return {
|
|
1644
|
+
x: rect.left + rect.width / 2 + rand(-insetX, insetX),
|
|
1645
|
+
y: rect.top + rect.height / 2 + rand(-insetY, insetY),
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function installPiChromeInstrumentation() {
|
|
1650
|
+
const state = getPiChromeState();
|
|
1651
|
+
if (state.instrumentationInstalled) return;
|
|
1652
|
+
state.instrumentationInstalled = true;
|
|
1653
|
+
const pushConsole = (level, args) => {
|
|
1654
|
+
state.console.push({
|
|
1655
|
+
id: state.console.length + 1,
|
|
1656
|
+
level,
|
|
1657
|
+
timestamp: Date.now(),
|
|
1658
|
+
url: location.href,
|
|
1659
|
+
args: Array.from(args).map((arg) => {
|
|
1660
|
+
try {
|
|
1661
|
+
if (typeof arg === "string") return arg;
|
|
1662
|
+
if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
|
|
1663
|
+
return JSON.parse(JSON.stringify(arg));
|
|
1664
|
+
} catch {
|
|
1665
|
+
return String(arg);
|
|
1666
|
+
}
|
|
1667
|
+
}),
|
|
1668
|
+
});
|
|
1669
|
+
if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
|
|
1670
|
+
};
|
|
1671
|
+
for (const level of ["debug", "log", "info", "warn", "error"]){
|
|
1672
|
+
const original = console[level];
|
|
1673
|
+
if (typeof original !== "function" || original.__piChromeWrapped) continue;
|
|
1674
|
+
const wrapped = function(...args) {
|
|
1675
|
+
pushConsole(level, args);
|
|
1676
|
+
return original.apply(this, args);
|
|
1677
|
+
};
|
|
1678
|
+
wrapped.__piChromeWrapped = true;
|
|
1679
|
+
console[level] = wrapped;
|
|
1680
|
+
}
|
|
1681
|
+
window.addEventListener("error", (event) => pushConsole("pageerror", [event.message, event.filename + ":" + event.lineno + ":" + event.colno]));
|
|
1682
|
+
window.addEventListener("unhandledrejection", (event) => pushConsole("unhandledrejection", [event.reason]));
|
|
1683
|
+
|
|
1684
|
+
const trimBody = (text) => typeof text === "string" && text.length > 200000 ? text.slice(0, 200000) + `\n[truncated ${text.length - 200000} chars]` : text;
|
|
1685
|
+
const record = (entry) => {
|
|
1686
|
+
state.network.push(entry);
|
|
1687
|
+
if (state.network.length > 1000) state.network.splice(0, state.network.length - 1000);
|
|
1688
|
+
return entry;
|
|
1689
|
+
};
|
|
1690
|
+
if (window.fetch && !window.fetch.__piChromeWrapped) {
|
|
1691
|
+
const originalFetch = window.fetch.bind(window);
|
|
1692
|
+
const wrappedFetch = async (...args) => {
|
|
1693
|
+
const id = "req-" + state.nextRequestId++;
|
|
1694
|
+
const startedAt = Date.now();
|
|
1695
|
+
const input = args[0];
|
|
1696
|
+
const init = args[1] || {};
|
|
1697
|
+
const url = typeof input === "string" ? input : input?.url;
|
|
1698
|
+
const method = (init.method || input?.method || "GET").toUpperCase();
|
|
1699
|
+
const entry = record({ id, type: "fetch", method, url: String(url || ""), startedAt, pageUrl: location.href, status: "pending" });
|
|
1700
|
+
try {
|
|
1701
|
+
const response = await originalFetch(...args);
|
|
1702
|
+
entry.status = response.status;
|
|
1703
|
+
entry.statusText = response.statusText;
|
|
1704
|
+
entry.ok = response.ok;
|
|
1705
|
+
entry.responseUrl = response.url;
|
|
1706
|
+
entry.durationMs = Date.now() - startedAt;
|
|
1707
|
+
entry.responseHeaders = Array.from(response.headers.entries());
|
|
1708
|
+
response.clone().text().then((text) => {
|
|
1709
|
+
entry.responseBody = trimBody(text);
|
|
1710
|
+
entry.responseBodyTruncated = typeof text === "string" && text.length > 200000;
|
|
1711
|
+
}).catch((error) => { entry.responseBodyError = error?.message || String(error); });
|
|
1712
|
+
return response;
|
|
1713
|
+
} catch (error) {
|
|
1714
|
+
entry.error = error?.message || String(error);
|
|
1715
|
+
entry.durationMs = Date.now() - startedAt;
|
|
1716
|
+
throw error;
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
wrappedFetch.__piChromeWrapped = true;
|
|
1720
|
+
window.fetch = wrappedFetch;
|
|
1721
|
+
}
|
|
1722
|
+
if (window.XMLHttpRequest && !XMLHttpRequest.prototype.open.__piChromeWrapped) {
|
|
1723
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
1724
|
+
const originalSend = XMLHttpRequest.prototype.send;
|
|
1725
|
+
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
|
1726
|
+
this.__piChromeRequest = { method: String(method || "GET").toUpperCase(), url: String(url || "") };
|
|
1727
|
+
return originalOpen.call(this, method, url, ...rest);
|
|
1728
|
+
};
|
|
1729
|
+
XMLHttpRequest.prototype.open.__piChromeWrapped = true;
|
|
1730
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
1731
|
+
const id = "req-" + state.nextRequestId++;
|
|
1732
|
+
const startedAt = Date.now();
|
|
1733
|
+
const info = this.__piChromeRequest || {};
|
|
1734
|
+
const entry = record({ id, type: "xhr", method: info.method || "GET", url: info.url || "", startedAt, pageUrl: location.href, status: "pending" });
|
|
1735
|
+
this.addEventListener("loadend", () => {
|
|
1736
|
+
entry.status = this.status;
|
|
1737
|
+
entry.statusText = this.statusText;
|
|
1738
|
+
entry.responseUrl = this.responseURL;
|
|
1739
|
+
entry.durationMs = Date.now() - startedAt;
|
|
1740
|
+
try { entry.responseHeadersText = this.getAllResponseHeaders(); } catch {}
|
|
1741
|
+
try {
|
|
1742
|
+
if (typeof this.responseText === "string") {
|
|
1743
|
+
entry.responseBody = trimBody(this.responseText);
|
|
1744
|
+
entry.responseBodyTruncated = this.responseText.length > 200000;
|
|
1745
|
+
}
|
|
1746
|
+
} catch (error) { entry.responseBodyError = error?.message || String(error); }
|
|
1747
|
+
});
|
|
1748
|
+
this.addEventListener("error", () => { entry.error = "XMLHttpRequest error"; entry.durationMs = Date.now() - startedAt; });
|
|
1749
|
+
return originalSend.call(this, body);
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// Early-capture version of installPiChromeInstrumentation, designed to be injected
|
|
1755
|
+
// at document_start via webNavigation.onCommitted. Wraps console, fetch, and XHR
|
|
1756
|
+
// before the page's own JavaScript runs, so page-load errors are captured.
|
|
1757
|
+
// Sets __piChromeWrapped flags so the post-hoc installPiChromeInstrumentation()
|
|
1758
|
+
// sees them and skips (idempotent).
|
|
1759
|
+
// NOTE: This function is self-contained — it does NOT close over any outer scope
|
|
1760
|
+
// because it gets serialized by chrome.scripting.executeScript({func: ...}).
|
|
1761
|
+
function installEarlyCapture() {
|
|
1762
|
+
if (window.__piChromeEarlyCaptureInstalled) return;
|
|
1763
|
+
window.__piChromeEarlyCaptureInstalled = true;
|
|
1764
|
+
var state = window.__PI_CHROME_STATE__;
|
|
1765
|
+
if (!state) {
|
|
1766
|
+
state = {
|
|
1767
|
+
nextElementUid: 1,
|
|
1768
|
+
elements: {},
|
|
1769
|
+
console: [],
|
|
1770
|
+
network: [],
|
|
1771
|
+
nextRequestId: 1,
|
|
1772
|
+
instrumentationInstalled: false,
|
|
1773
|
+
};
|
|
1774
|
+
window.__PI_CHROME_STATE__ = state;
|
|
1775
|
+
}
|
|
1776
|
+
function pushConsole(level, args) {
|
|
1777
|
+
state.console.push({
|
|
1778
|
+
id: state.console.length + 1,
|
|
1779
|
+
level: level,
|
|
1780
|
+
timestamp: Date.now(),
|
|
1781
|
+
url: location.href,
|
|
1782
|
+
args: Array.from(args).map(function(arg) {
|
|
1783
|
+
try {
|
|
1784
|
+
if (typeof arg === "string") return arg;
|
|
1785
|
+
if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
|
|
1786
|
+
return JSON.parse(JSON.stringify(arg));
|
|
1787
|
+
} catch (e) {
|
|
1788
|
+
return String(arg);
|
|
1789
|
+
}
|
|
1790
|
+
}),
|
|
1791
|
+
});
|
|
1792
|
+
if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
|
|
1793
|
+
}
|
|
1794
|
+
for (var i = 0; i < 5; i++) {
|
|
1795
|
+
var levels = ["debug", "log", "info", "warn", "error"];
|
|
1796
|
+
var level = levels[i];
|
|
1797
|
+
var original = console[level];
|
|
1798
|
+
if (typeof original !== "function" || original.__piChromeWrapped) continue;
|
|
1799
|
+
var wrapped = function(lvl, orig) {
|
|
1800
|
+
return function() {
|
|
1801
|
+
pushConsole(lvl, arguments);
|
|
1802
|
+
return orig.apply(this, arguments);
|
|
1803
|
+
};
|
|
1804
|
+
}(level, original);
|
|
1805
|
+
wrapped.__piChromeWrapped = true;
|
|
1806
|
+
console[level] = wrapped;
|
|
1807
|
+
}
|
|
1808
|
+
window.addEventListener("error", function(event) {
|
|
1809
|
+
pushConsole("pageerror", [event.message, event.filename + ":" + event.lineno + ":" + event.colno]);
|
|
1810
|
+
});
|
|
1811
|
+
window.addEventListener("unhandledrejection", function(event) {
|
|
1812
|
+
pushConsole("unhandledrejection", [event.reason]);
|
|
1813
|
+
});
|
|
1814
|
+
var trimBody = function(text) {
|
|
1815
|
+
return typeof text === "string" && text.length > 200000 ? text.slice(0, 200000) + "\n[truncated " + (text.length - 200000) + " chars]" : text;
|
|
1816
|
+
};
|
|
1817
|
+
var record = function(entry) {
|
|
1818
|
+
state.network.push(entry);
|
|
1819
|
+
if (state.network.length > 1000) state.network.splice(0, state.network.length - 1000);
|
|
1820
|
+
return entry;
|
|
1821
|
+
};
|
|
1822
|
+
if (window.fetch && !window.fetch.__piChromeWrapped) {
|
|
1823
|
+
var originalFetch = window.fetch.bind(window);
|
|
1824
|
+
var wrappedFetch = async function() {
|
|
1825
|
+
var args = [];
|
|
1826
|
+
for (var k = 0; k < arguments.length; k++) args.push(arguments[k]);
|
|
1827
|
+
var id = "req-" + state.nextRequestId++;
|
|
1828
|
+
var startedAt = Date.now();
|
|
1829
|
+
var input = args[0];
|
|
1830
|
+
var init = args[1] || {};
|
|
1831
|
+
var url = typeof input === "string" ? input : (input ? input.url : "");
|
|
1832
|
+
var method = (init.method || (input ? input.method : null) || "GET").toUpperCase();
|
|
1833
|
+
var entry = record({ id: id, type: "fetch", method: method, url: String(url || ""), startedAt: startedAt, pageUrl: location.href, status: "pending" });
|
|
1834
|
+
try {
|
|
1835
|
+
var response = await originalFetch.apply(window, args);
|
|
1836
|
+
entry.status = response.status;
|
|
1837
|
+
entry.statusText = response.statusText;
|
|
1838
|
+
entry.ok = response.ok;
|
|
1839
|
+
entry.responseUrl = response.url;
|
|
1840
|
+
entry.durationMs = Date.now() - startedAt;
|
|
1841
|
+
entry.responseHeaders = Array.from(response.headers.entries());
|
|
1842
|
+
response.clone().text().then(function(text) {
|
|
1843
|
+
entry.responseBody = trimBody(text);
|
|
1844
|
+
entry.responseBodyTruncated = typeof text === "string" && text.length > 200000;
|
|
1845
|
+
}).catch(function(error) { entry.responseBodyError = error ? error.message : String(error); });
|
|
1846
|
+
return response;
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
entry.error = error ? error.message : String(error);
|
|
1849
|
+
entry.durationMs = Date.now() - startedAt;
|
|
1850
|
+
throw error;
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
wrappedFetch.__piChromeWrapped = true;
|
|
1854
|
+
window.fetch = wrappedFetch;
|
|
1855
|
+
}
|
|
1856
|
+
if (window.XMLHttpRequest && !XMLHttpRequest.prototype.open.__piChromeWrapped) {
|
|
1857
|
+
var originalOpen = XMLHttpRequest.prototype.open;
|
|
1858
|
+
var originalSend = XMLHttpRequest.prototype.send;
|
|
1859
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
1860
|
+
this.__piChromeRequest = { method: String(method || "GET").toUpperCase(), url: String(url || "") };
|
|
1861
|
+
return originalOpen.apply(this, arguments);
|
|
1862
|
+
};
|
|
1863
|
+
XMLHttpRequest.prototype.open.__piChromeWrapped = true;
|
|
1864
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
1865
|
+
var id = "req-" + state.nextRequestId++;
|
|
1866
|
+
var startedAt = Date.now();
|
|
1867
|
+
var info = this.__piChromeRequest || {};
|
|
1868
|
+
var entry = record({ id: id, type: "xhr", method: info.method || "GET", url: info.url || "", startedAt: startedAt, pageUrl: location.href, status: "pending" });
|
|
1869
|
+
this.addEventListener("loadend", function() {
|
|
1870
|
+
entry.status = this.status;
|
|
1871
|
+
entry.statusText = this.statusText;
|
|
1872
|
+
entry.responseUrl = this.responseURL;
|
|
1873
|
+
entry.durationMs = Date.now() - startedAt;
|
|
1874
|
+
try { entry.responseHeadersText = this.getAllResponseHeaders(); } catch (e) {}
|
|
1875
|
+
try {
|
|
1876
|
+
if (typeof this.responseText === "string") {
|
|
1877
|
+
entry.responseBody = trimBody(this.responseText);
|
|
1878
|
+
entry.responseBodyTruncated = this.responseText.length > 200000;
|
|
1879
|
+
}
|
|
1880
|
+
} catch (error) { entry.responseBodyError = error ? error.message : String(error); }
|
|
1881
|
+
});
|
|
1882
|
+
this.addEventListener("error", function() { entry.error = "XMLHttpRequest error"; entry.durationMs = Date.now() - startedAt; });
|
|
1883
|
+
return originalSend.apply(this, arguments);
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
state.instrumentationInstalled = true;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function probePage() {
|
|
1890
|
+
// Sanity probe used by /chrome-doctor. Returns evidence that MAIN-world execution works.
|
|
1891
|
+
return {
|
|
1892
|
+
arithmetic: 1 + 1,
|
|
1893
|
+
location: location.href,
|
|
1894
|
+
title: document.title,
|
|
1895
|
+
documentReady: document.readyState,
|
|
1896
|
+
userAgent: navigator.userAgent.slice(0, 200),
|
|
1897
|
+
webdriver: !!navigator.webdriver,
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function captureFullPageTiles() {
|
|
1902
|
+
// Returns the *plan* for tile capture; the actual chrome.tabs.captureVisibleTab calls happen
|
|
1903
|
+
// in the SW. We just report the scroll positions and metrics.
|
|
1904
|
+
const html = document.documentElement;
|
|
1905
|
+
const body = document.body;
|
|
1906
|
+
const width = Math.max(html.scrollWidth, body ? body.scrollWidth : 0, innerWidth);
|
|
1907
|
+
const height = Math.max(html.scrollHeight, body ? body.scrollHeight : 0, innerHeight);
|
|
1908
|
+
const viewportHeight = innerHeight;
|
|
1909
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1910
|
+
const originalScrollY = scrollY;
|
|
1911
|
+
const tiles = [];
|
|
1912
|
+
let y = 0;
|
|
1913
|
+
while (y < height) {
|
|
1914
|
+
tiles.push({ y, scrollY: y });
|
|
1915
|
+
y += viewportHeight;
|
|
1916
|
+
}
|
|
1917
|
+
return { width, height, viewportHeight, dpr, originalScrollY, tiles };
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function scrollToY(y) {
|
|
1921
|
+
window.scrollTo({ top: y, left: 0, behavior: "instant" });
|
|
1922
|
+
return { scrollY };
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function resolvePoint(selector, uid, x, y) {
|
|
1926
|
+
const element = elementBySelectorOrUid(selector, uid);
|
|
1927
|
+
if (element) {
|
|
1928
|
+
element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
1929
|
+
const rect = element.getBoundingClientRect();
|
|
1930
|
+
return { element, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, rect };
|
|
1931
|
+
}
|
|
1932
|
+
if (typeof x !== "number" || typeof y !== "number") throw new Error("Provide selector, uid, or x/y");
|
|
1933
|
+
return { element: document.elementFromPoint(x, y), x, y, rect: undefined };
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
async function clickPage(selector, uid, x, y) {
|
|
1937
|
+
installPiChromeInstrumentation();
|
|
1938
|
+
const before = pageHash();
|
|
1939
|
+
const point = resolvePoint(selector, uid, x, y);
|
|
1940
|
+
if (!point.element) throw new Error("No element at click point");
|
|
1941
|
+
const clickPoint = humanClickPoint(point);
|
|
1942
|
+
point.x = clickPoint.x;
|
|
1943
|
+
point.y = clickPoint.y;
|
|
1944
|
+
point.element = document.elementFromPoint(point.x, point.y) || point.element;
|
|
1945
|
+
const visible = isElementVisible(point.element);
|
|
1946
|
+
const occluded = occluderAt(point.x, point.y, point.element);
|
|
1947
|
+
let defaultPrevented = await humanMoveTo(point.x, point.y);
|
|
1948
|
+
const state = getPiChromeState();
|
|
1949
|
+
const prevX = state.pointer?.x;
|
|
1950
|
+
const prevY = state.pointer?.y;
|
|
1951
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerdown", point.x, point.y, prevX, prevY, { pressure: 0.5 }) || defaultPrevented;
|
|
1952
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "mousedown", point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1953
|
+
if (typeof point.element.focus === "function" && /^(A|BUTTON|INPUT|TEXTAREA|SELECT|SUMMARY)$/.test(point.element.tagName)) {
|
|
1954
|
+
try { point.element.focus({ preventScroll: true }); } catch { try { point.element.focus(); } catch {} }
|
|
1955
|
+
}
|
|
1956
|
+
await sleepPage(rand(45, 140));
|
|
1957
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerup", point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1958
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "mouseup", point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1959
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "click", point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1960
|
+
state.pointer = { x: point.x, y: point.y, t: performance.now() };
|
|
1961
|
+
// Heuristic: if the clicked thing looks like a media play affordance and the page has paused
|
|
1962
|
+
// audio/video, the DOM-event click may not unlock autoplay. Surface a warning.
|
|
1963
|
+
let autoplayHint;
|
|
1964
|
+
const labelRaw = (point.element.getAttribute("aria-label") || point.element.textContent || "").trim();
|
|
1965
|
+
const label = labelRaw.toLowerCase();
|
|
1966
|
+
if (/^(play|start|begin|next|continue|unmute)/.test(label)) {
|
|
1967
|
+
const idleMedia = Array.from(document.querySelectorAll("audio,video")).some((m) => m.paused);
|
|
1968
|
+
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.";
|
|
1969
|
+
}
|
|
1970
|
+
const pageMutated = pageHash() !== before;
|
|
1971
|
+
// Smart-auto retry hint: only set when DOM-event path produced no observable change AND the
|
|
1972
|
+
// element looks gated, OR the page just emitted a user-activation rejection. The dispatcher
|
|
1973
|
+
// uses this to decide whether to retry with Chrome input.
|
|
1974
|
+
let suggestChromeInput = false;
|
|
1975
|
+
let suggestReason;
|
|
1976
|
+
if (!pageMutated) {
|
|
1977
|
+
if (autoplayHint) { suggestChromeInput = true; suggestReason = "play/media affordance + idle media"; }
|
|
1978
|
+
else if (/copy(\s|$)|paste|share|download|fullscreen|sign in with|continue with|allow|enable/i.test(label)) {
|
|
1979
|
+
suggestChromeInput = true; suggestReason = `label '${labelRaw.slice(0, 40)}' looks gated`;
|
|
1980
|
+
} else {
|
|
1981
|
+
// Inspect recent console errors for activation-gate rejections.
|
|
1982
|
+
const recent = (state.console || []).slice(-8);
|
|
1983
|
+
const hit = recent.find((e) => /NotAllowedError|Document is not focused|requires transient activation|gesture is required/.test(
|
|
1984
|
+
(e.args || []).map((a) => typeof a === "string" ? a : (a && a.message) || JSON.stringify(a)).join(" ")
|
|
1985
|
+
));
|
|
1986
|
+
if (hit) { suggestChromeInput = true; suggestReason = "recent console error indicates user-activation gate"; }
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
return {
|
|
1990
|
+
x: point.x,
|
|
1991
|
+
y: point.y,
|
|
1992
|
+
selector,
|
|
1993
|
+
uid,
|
|
1994
|
+
tag: point.element.tagName,
|
|
1995
|
+
label: labelRaw.slice(0, 80) || undefined,
|
|
1996
|
+
input: "dom",
|
|
1997
|
+
defaultPrevented,
|
|
1998
|
+
elementVisible: visible,
|
|
1999
|
+
occludedBy: occluded || undefined,
|
|
2000
|
+
pageMutated,
|
|
2001
|
+
autoplayHint,
|
|
2002
|
+
suggestChromeInput: suggestChromeInput || undefined,
|
|
2003
|
+
suggestReason,
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
async function hoverPage(selector, uid, x, y) {
|
|
2008
|
+
installPiChromeInstrumentation();
|
|
2009
|
+
const point = resolvePoint(selector, uid, x, y);
|
|
2010
|
+
if (!point.element) throw new Error("No element to hover");
|
|
2011
|
+
await humanMoveTo(point.x, point.y);
|
|
2012
|
+
const state = getPiChromeState();
|
|
2013
|
+
const prevX = state.pointer?.x, prevY = state.pointer?.y;
|
|
2014
|
+
let defaultPrevented = false;
|
|
2015
|
+
for (const type of ["pointerover", "mouseover", "pointerenter", "mouseenter"]) {
|
|
2016
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, type, point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
2017
|
+
}
|
|
2018
|
+
// Small dwell so hover-intent handlers fire.
|
|
2019
|
+
await sleepPage(rand(80, 220));
|
|
2020
|
+
return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, input: "dom" };
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
|
|
2024
|
+
installPiChromeInstrumentation();
|
|
2025
|
+
const before = pageHash();
|
|
2026
|
+
const from = resolvePoint(fromSelector, fromUid, fromX, fromY);
|
|
2027
|
+
const to = resolvePoint(toSelector, toUid, toX, toY);
|
|
2028
|
+
if (!from.element) throw new Error("Drag source element not found");
|
|
2029
|
+
if (!to.element) throw new Error("Drag target element not found");
|
|
2030
|
+
// Move to source.
|
|
2031
|
+
await humanMoveTo(from.x, from.y);
|
|
2032
|
+
const state = getPiChromeState();
|
|
2033
|
+
let prevX = state.pointer?.x, prevY = state.pointer?.y;
|
|
2034
|
+
// Build a shared DataTransfer so HTML5 drag-and-drop handlers can populate / read it.
|
|
2035
|
+
const dt = new DataTransfer();
|
|
2036
|
+
const dragInit = (type, target, x, y) => {
|
|
2037
|
+
const ev = new DragEvent(type, {
|
|
2038
|
+
bubbles: true, cancelable: true, composed: true,
|
|
2039
|
+
clientX: x, clientY: y,
|
|
2040
|
+
screenX: x + (window.screenX || 0), screenY: y + (window.screenY || 0),
|
|
2041
|
+
button: 0, buttons: 1, view: window,
|
|
2042
|
+
dataTransfer: dt,
|
|
2043
|
+
});
|
|
2044
|
+
target.dispatchEvent(ev);
|
|
2045
|
+
return ev;
|
|
2046
|
+
};
|
|
2047
|
+
dispatchPointerLikeEvent(from.element, "pointerover", from.x, from.y, prevX, prevY);
|
|
2048
|
+
dispatchPointerLikeEvent(from.element, "pointerdown", from.x, from.y, prevX, prevY, { pressure: 0.5 });
|
|
2049
|
+
dispatchPointerLikeEvent(from.element, "mousedown", from.x, from.y, prevX, prevY);
|
|
2050
|
+
await sleepPage(rand(40, 110));
|
|
2051
|
+
dragInit("dragstart", from.element, from.x, from.y);
|
|
2052
|
+
dragInit("drag", from.element, from.x, from.y);
|
|
2053
|
+
let lastOver = from.element;
|
|
2054
|
+
const n = steps || 18;
|
|
2055
|
+
for (let i = 1; i <= n; i++) {
|
|
2056
|
+
const t = i / n;
|
|
2057
|
+
const ease = t * t * (3 - 2 * t);
|
|
2058
|
+
const wobble = Math.sin(t * Math.PI) * 6;
|
|
2059
|
+
const x = from.x + (to.x - from.x) * ease + rand(-wobble, wobble);
|
|
2060
|
+
const y = from.y + (to.y - from.y) * ease + rand(-wobble, wobble);
|
|
2061
|
+
const overEl = document.elementFromPoint(x, y) || to.element;
|
|
2062
|
+
dispatchPointerLikeEvent(overEl, "pointermove", x, y, prevX, prevY);
|
|
2063
|
+
dispatchPointerLikeEvent(overEl, "mousemove", x, y, prevX, prevY);
|
|
2064
|
+
if (overEl !== lastOver) {
|
|
2065
|
+
dragInit("dragleave", lastOver, x, y);
|
|
2066
|
+
dragInit("dragenter", overEl, x, y);
|
|
2067
|
+
lastOver = overEl;
|
|
2068
|
+
}
|
|
2069
|
+
dragInit("dragover", overEl, x, y);
|
|
2070
|
+
dragInit("drag", from.element, x, y);
|
|
2071
|
+
prevX = x; prevY = y;
|
|
2072
|
+
await sleepPage(rand(8, 26));
|
|
2073
|
+
}
|
|
2074
|
+
dispatchPointerLikeEvent(to.element, "pointerover", to.x, to.y, prevX, prevY);
|
|
2075
|
+
dispatchPointerLikeEvent(to.element, "mouseover", to.x, to.y, prevX, prevY);
|
|
2076
|
+
dragInit("drop", to.element, to.x, to.y);
|
|
2077
|
+
dragInit("dragend", from.element, to.x, to.y);
|
|
2078
|
+
dispatchPointerLikeEvent(to.element, "pointerup", to.x, to.y, prevX, prevY);
|
|
2079
|
+
dispatchPointerLikeEvent(to.element, "mouseup", to.x, to.y, prevX, prevY);
|
|
2080
|
+
state.pointer = { x: to.x, y: to.y, t: performance.now() };
|
|
2081
|
+
return {
|
|
2082
|
+
from: { x: from.x, y: from.y },
|
|
2083
|
+
to: { x: to.x, y: to.y },
|
|
2084
|
+
steps: n,
|
|
2085
|
+
pageMutated: pageHash() !== before,
|
|
2086
|
+
note: "DOM-event drag with HTML5 DragEvent + shared DataTransfer.",
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
async function scrollPage(selector, uid, deltaY, deltaX, steps) {
|
|
2091
|
+
installPiChromeInstrumentation();
|
|
2092
|
+
const before = pageHash();
|
|
2093
|
+
let target;
|
|
2094
|
+
if (selector || uid) {
|
|
2095
|
+
target = elementBySelectorOrUid(selector, uid);
|
|
2096
|
+
} else {
|
|
2097
|
+
target = document.scrollingElement || document.documentElement || document.body;
|
|
2098
|
+
}
|
|
2099
|
+
if (!target) throw new Error("No scroll target");
|
|
2100
|
+
const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight };
|
|
2101
|
+
const cx = Math.max(0, Math.min(innerWidth - 1, rect.left + Math.min(rect.width, innerWidth) / 2));
|
|
2102
|
+
const cy = Math.max(0, Math.min(innerHeight - 1, rect.top + Math.min(rect.height, innerHeight) / 2));
|
|
2103
|
+
const n = Math.max(3, Math.min(40, steps || Math.max(3, Math.ceil(Math.abs(deltaY || 0) / 100))));
|
|
2104
|
+
// Front-loaded wheel deltas, momentum-style.
|
|
2105
|
+
const totalY = deltaY || 0;
|
|
2106
|
+
const totalX = deltaX || 0;
|
|
2107
|
+
const weights = [];
|
|
2108
|
+
for (let i = 1; i <= n; i++) weights.push(1 / i);
|
|
2109
|
+
const sumW = weights.reduce((a, b) => a + b, 0);
|
|
2110
|
+
let movedY = 0, movedX = 0;
|
|
2111
|
+
for (let i = 0; i < n; i++) {
|
|
2112
|
+
const dy = totalY * (weights[i] / sumW);
|
|
2113
|
+
const dx = totalX * (weights[i] / sumW);
|
|
2114
|
+
const ev = new WheelEvent("wheel", {
|
|
2115
|
+
bubbles: true, cancelable: true, composed: true, view: window,
|
|
2116
|
+
clientX: cx, clientY: cy,
|
|
2117
|
+
deltaX: dx, deltaY: dy, deltaMode: 0,
|
|
2118
|
+
});
|
|
2119
|
+
target.dispatchEvent(ev);
|
|
2120
|
+
if (!ev.defaultPrevented) {
|
|
2121
|
+
// Apply scroll ourselves; mirrors what the browser would do.
|
|
2122
|
+
if (target === document.scrollingElement || target === document.documentElement || target === document.body) {
|
|
2123
|
+
window.scrollBy({ left: dx, top: dy, behavior: "instant" });
|
|
2124
|
+
} else {
|
|
2125
|
+
target.scrollTop += dy;
|
|
2126
|
+
target.scrollLeft += dx;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
movedY += dy; movedX += dx;
|
|
2130
|
+
await sleepPage(rand(12, 28));
|
|
2131
|
+
}
|
|
2132
|
+
return {
|
|
2133
|
+
deltaX: movedX, deltaY: movedY, steps: n,
|
|
2134
|
+
scrollTop: target.scrollTop, scrollLeft: target.scrollLeft,
|
|
2135
|
+
pageMutated: pageHash() !== before,
|
|
2136
|
+
input: "dom",
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
function uploadFiles(selector, uid, files) {
|
|
2141
|
+
installPiChromeInstrumentation();
|
|
2142
|
+
const element = elementBySelectorOrUid(selector, uid);
|
|
2143
|
+
if (!element || element.tagName !== "INPUT" || element.type !== "file") {
|
|
2144
|
+
throw new Error("Target must be <input type=file>");
|
|
2145
|
+
}
|
|
2146
|
+
const dt = new DataTransfer();
|
|
2147
|
+
for (const f of files) {
|
|
2148
|
+
const bytes = Uint8Array.from(atob(f.base64 || ""), (c) => c.charCodeAt(0));
|
|
2149
|
+
dt.items.add(new File([bytes], f.name, { type: f.type || "application/octet-stream" }));
|
|
2150
|
+
}
|
|
2151
|
+
element.files = dt.files;
|
|
2152
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2153
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2154
|
+
return { uploaded: files.map((f) => ({ name: f.name, type: f.type, size: (f.base64 || "").length })) };
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function dispatchInputEvents(element, data, inputType = "insertText") {
|
|
2158
|
+
element.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType, data }));
|
|
2159
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data }));
|
|
2160
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function setNativeValue(element, value) {
|
|
2164
|
+
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
2165
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
|
2166
|
+
if (descriptor?.set) descriptor.set.call(element, value);
|
|
2167
|
+
else element.value = value;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function printableKeyCode(ch) {
|
|
2171
|
+
return ch.length === 1 ? usKeyLayoutForChar(ch).keyCode : 0;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function dispatchKeyEvent(element, type, key, mods = {}) {
|
|
2175
|
+
const SPECIAL = { Enter: 13, Tab: 9, Backspace: 8, Delete: 46, Escape: 27,
|
|
2176
|
+
ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, " ": 32, Shift: 16, Control: 17, Alt: 18, Meta: 91 };
|
|
2177
|
+
const code = key.length === 1 ? usKeyLayoutForChar(key).code : (key === " " ? "Space" : key);
|
|
2178
|
+
const keyCode = key.length === 1 ? printableKeyCode(key) : (SPECIAL[key] ?? 0);
|
|
2179
|
+
const ev = new KeyboardEvent(type, {
|
|
2180
|
+
key,
|
|
2181
|
+
code,
|
|
2182
|
+
keyCode,
|
|
2183
|
+
which: keyCode,
|
|
2184
|
+
charCode: type === "keypress" && key.length === 1 ? key.charCodeAt(0) : 0,
|
|
2185
|
+
shiftKey: !!mods.shiftKey,
|
|
2186
|
+
ctrlKey: !!mods.ctrlKey,
|
|
2187
|
+
altKey: !!mods.altKey,
|
|
2188
|
+
metaKey: !!mods.metaKey,
|
|
2189
|
+
bubbles: true,
|
|
2190
|
+
cancelable: true,
|
|
2191
|
+
composed: true,
|
|
2192
|
+
view: window,
|
|
2193
|
+
});
|
|
2194
|
+
element.dispatchEvent(ev);
|
|
2195
|
+
return ev;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
async function typeCharacter(element, ch) {
|
|
2199
|
+
const needShift = ch.length === 1 && (/^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch));
|
|
2200
|
+
if (needShift) {
|
|
2201
|
+
dispatchKeyEvent(element, "keydown", "Shift", { shiftKey: true });
|
|
2202
|
+
await sleepPage(rand(8, 24));
|
|
2203
|
+
}
|
|
2204
|
+
const mods = { shiftKey: needShift };
|
|
2205
|
+
const down = dispatchKeyEvent(element, "keydown", ch, mods);
|
|
2206
|
+
if (down.defaultPrevented) {
|
|
2207
|
+
if (needShift) dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
|
|
2208
|
+
return { defaultPrevented: true };
|
|
2209
|
+
}
|
|
2210
|
+
if (ch.length === 1) dispatchKeyEvent(element, "keypress", ch, mods);
|
|
2211
|
+
|
|
2212
|
+
if (element.isContentEditable) {
|
|
2213
|
+
// execCommand("insertText") fires its own beforeinput + input. Don't double-dispatch.
|
|
2214
|
+
document.execCommand("insertText", false, ch);
|
|
2215
|
+
} else if ("value" in element) {
|
|
2216
|
+
const start = element.selectionStart ?? element.value.length;
|
|
2217
|
+
const end = element.selectionEnd ?? element.value.length;
|
|
2218
|
+
const next = element.value.slice(0, start) + ch + element.value.slice(end);
|
|
2219
|
+
const before = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: ch });
|
|
2220
|
+
element.dispatchEvent(before);
|
|
2221
|
+
if (!before.defaultPrevented) {
|
|
2222
|
+
setNativeValue(element, next);
|
|
2223
|
+
try { element.selectionStart = element.selectionEnd = start + ch.length; } catch {}
|
|
2224
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ch }));
|
|
2225
|
+
}
|
|
2226
|
+
} else {
|
|
2227
|
+
throw new Error("Focused element is not text-editable");
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
await sleepPage(rand(25, 95));
|
|
2231
|
+
dispatchKeyEvent(element, "keyup", ch, mods);
|
|
2232
|
+
if (needShift) {
|
|
2233
|
+
await sleepPage(rand(5, 18));
|
|
2234
|
+
dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
|
|
2235
|
+
}
|
|
2236
|
+
await sleepPage(rand(35, 140));
|
|
2237
|
+
return { defaultPrevented: false };
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
async function typeIntoPage(selector, uid, text, pressEnter) {
|
|
2241
|
+
installPiChromeInstrumentation();
|
|
2242
|
+
const before = pageHash();
|
|
2243
|
+
let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
|
|
2244
|
+
if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
|
|
2245
|
+
const initialValue = "value" in element ? element.value : (element.isContentEditable ? element.textContent : null);
|
|
2246
|
+
element.focus();
|
|
2247
|
+
if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable");
|
|
2248
|
+
for (const ch of Array.from(text)) await typeCharacter(element, ch);
|
|
2249
|
+
if (pressEnter) await pressKeyInPage("Enter");
|
|
2250
|
+
const finalValue = "value" in element ? element.value : element.textContent;
|
|
2251
|
+
const valueMatches = "value" in element ? element.value.includes(text) : (element.textContent || "").includes(text);
|
|
2252
|
+
const pageMutated = pageHash() !== before;
|
|
2253
|
+
// Smart-auto retry hint when typing didn't land at all (e.g., editor blocks DOM-event input).
|
|
2254
|
+
let suggestChromeInput = false, suggestReason;
|
|
2255
|
+
if (text.length > 0 && initialValue === finalValue) {
|
|
2256
|
+
suggestChromeInput = true;
|
|
2257
|
+
suggestReason = "value did not change — editor likely rejects DOM-event input";
|
|
2258
|
+
}
|
|
2259
|
+
return {
|
|
2260
|
+
selector, uid, length: text.length, pressEnter,
|
|
2261
|
+
input: "dom",
|
|
2262
|
+
valueMatches,
|
|
2263
|
+
pageMutated,
|
|
2264
|
+
suggestChromeInput: suggestChromeInput || undefined,
|
|
2265
|
+
suggestReason,
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
async function fillPage(selector, uid, text, submit) {
|
|
2270
|
+
installPiChromeInstrumentation();
|
|
2271
|
+
const before = pageHash();
|
|
2272
|
+
let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
|
|
2273
|
+
if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
|
|
2274
|
+
element.focus();
|
|
2275
|
+
if (element.isContentEditable) {
|
|
2276
|
+
element.textContent = "";
|
|
2277
|
+
document.execCommand("insertText", false, text);
|
|
2278
|
+
} else if ("value" in element) {
|
|
2279
|
+
setNativeValue(element, text);
|
|
2280
|
+
const length = String(text).length;
|
|
2281
|
+
try { element.selectionStart = element.selectionEnd = length; } catch {}
|
|
2282
|
+
dispatchInputEvents(element, text, "insertReplacementText");
|
|
2283
|
+
} else {
|
|
2284
|
+
throw new Error("Focused element is not text-editable");
|
|
2285
|
+
}
|
|
2286
|
+
if (submit) await pressKeyInPage("Enter");
|
|
2287
|
+
return {
|
|
2288
|
+
selector, uid, length: String(text).length, submit,
|
|
2289
|
+
input: "dom",
|
|
2290
|
+
valueMatches: "value" in element ? element.value === String(text) : undefined,
|
|
2291
|
+
pageMutated: pageHash() !== before,
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
async function pressKeyInPage(key) {
|
|
2296
|
+
const normalized = normalizeKey(key);
|
|
2297
|
+
const target = document.activeElement || document.body;
|
|
2298
|
+
const before = pageHash();
|
|
2299
|
+
const down = dispatchKeyEvent(target, "keydown", normalized);
|
|
2300
|
+
if (normalized.length === 1) dispatchKeyEvent(target, "keypress", normalized);
|
|
2301
|
+
// Character insertion for printable keys when focus is in an editable.
|
|
2302
|
+
if (normalized.length === 1 && !down.defaultPrevented && (target.isContentEditable || ("value" in target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")))) {
|
|
2303
|
+
if (target.isContentEditable) {
|
|
2304
|
+
const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
|
|
2305
|
+
target.dispatchEvent(bi);
|
|
2306
|
+
if (!bi.defaultPrevented) {
|
|
2307
|
+
document.execCommand("insertText", false, normalized);
|
|
2308
|
+
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
|
|
2309
|
+
}
|
|
2310
|
+
} else {
|
|
2311
|
+
const start = target.selectionStart ?? target.value.length;
|
|
2312
|
+
const end = target.selectionEnd ?? target.value.length;
|
|
2313
|
+
const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
|
|
2314
|
+
target.dispatchEvent(bi);
|
|
2315
|
+
if (!bi.defaultPrevented) {
|
|
2316
|
+
setNativeValue(target, target.value.slice(0, start) + normalized + target.value.slice(end));
|
|
2317
|
+
try { target.selectionStart = target.selectionEnd = start + 1; } catch {}
|
|
2318
|
+
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
} else if (normalized === "Backspace" && "value" in target) {
|
|
2322
|
+
const start = target.selectionStart ?? target.value.length;
|
|
2323
|
+
const end = target.selectionEnd ?? target.value.length;
|
|
2324
|
+
if (start > 0 || end > start) {
|
|
2325
|
+
const from = start === end ? start - 1 : start;
|
|
2326
|
+
const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "deleteContentBackward" });
|
|
2327
|
+
target.dispatchEvent(bi);
|
|
2328
|
+
if (!bi.defaultPrevented) {
|
|
2329
|
+
setNativeValue(target, target.value.slice(0, from) + target.value.slice(end));
|
|
2330
|
+
try { target.selectionStart = target.selectionEnd = from; } catch {}
|
|
2331
|
+
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
await sleepPage(rand(25, 95));
|
|
2336
|
+
const up = dispatchKeyEvent(target, "keyup", normalized);
|
|
2337
|
+
if (normalized === "Enter") {
|
|
2338
|
+
const form = target.closest?.("form");
|
|
2339
|
+
if (form) form.requestSubmit?.();
|
|
2340
|
+
}
|
|
2341
|
+
return {
|
|
2342
|
+
key: normalized,
|
|
2343
|
+
input: "dom",
|
|
2344
|
+
defaultPrevented: down.defaultPrevented || up.defaultPrevented,
|
|
2345
|
+
pageMutated: pageHash() !== before,
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
function listConsoleMessages(clear) {
|
|
2350
|
+
installPiChromeInstrumentation();
|
|
2351
|
+
const state = getPiChromeState();
|
|
2352
|
+
const messages = state.console.slice();
|
|
2353
|
+
if (clear) state.console = [];
|
|
2354
|
+
return { messages, count: messages.length };
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function listNetworkRequests(includePreservedRequests, clear) {
|
|
2358
|
+
installPiChromeInstrumentation();
|
|
2359
|
+
const state = getPiChromeState();
|
|
2360
|
+
const currentUrl = location.href;
|
|
2361
|
+
const requests = state.network
|
|
2362
|
+
.filter((request) => includePreservedRequests || request.pageUrl === currentUrl)
|
|
2363
|
+
.map(({ responseBody, ...summary }) => ({ ...summary, hasResponseBody: responseBody !== undefined }));
|
|
2364
|
+
if (clear) state.network = [];
|
|
2365
|
+
return { requests, count: requests.length, note: "Captures fetch/XHR after instrumentation is installed. Browser-initiated document/static asset requests are not captured." };
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
function getNetworkRequest(requestId) {
|
|
2369
|
+
installPiChromeInstrumentation();
|
|
2370
|
+
const request = getPiChromeState().network.find((entry) => entry.id === requestId);
|
|
2371
|
+
if (!request) throw new Error(`No network request with id ${requestId}`);
|
|
2372
|
+
return request;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
function normalizeKey(key) {
|
|
2376
|
+
const table = {
|
|
2377
|
+
enter: "Enter",
|
|
2378
|
+
escape: "Escape",
|
|
2379
|
+
tab: "Tab",
|
|
2380
|
+
backspace: "Backspace",
|
|
2381
|
+
delete: "Delete",
|
|
2382
|
+
arrowup: "ArrowUp",
|
|
2383
|
+
arrowdown: "ArrowDown",
|
|
2384
|
+
arrowleft: "ArrowLeft",
|
|
2385
|
+
arrowright: "ArrowRight",
|
|
2386
|
+
};
|
|
2387
|
+
return table[String(key).toLowerCase()] || key;
|
|
2388
|
+
}
|