pi-chrome 0.8.0 → 0.10.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/README.md
CHANGED
|
@@ -65,6 +65,25 @@ pi-chrome v<version>
|
|
|
65
65
|
✓ Companion Chrome extension responding (ID: <chrome-extension-id>, ext v<version>)
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
## Trusted-input mode (CDP)
|
|
69
|
+
|
|
70
|
+
By default, `chrome_*` clicks and keystrokes are **synthetic** DOM events (`event.isTrusted === false`). They drive React/Vue/Angular state correctly but **do not** satisfy Chrome's user-activation gates: clipboard write, fullscreen, file picker, and autoplay all need a real user gesture.
|
|
71
|
+
|
|
72
|
+
pi-chrome can optionally route input through `chrome.debugger` (CDP `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent`) so each event arrives as `isTrusted=true`, satisfies user-activation, and bypasses site bot-detection that filters synthetic events. The tradeoff: Chrome pins a yellow *"Pi Existing Chrome Profile Bridge started debugging this browser"* banner to the top of any debugged tab.
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
/chrome-trusted on # all chrome_* tools dispatch via CDP
|
|
78
|
+
/chrome-trusted off # synthetic events only (default)
|
|
79
|
+
/chrome-trusted auto # CDP only when a tool passes trusted=true
|
|
80
|
+
/chrome-trusted status # show current mode and attached tabs
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For a single call, pass `trusted: true` on `chrome_click`, `chrome_type`, `chrome_fill`, `chrome_key`, `chrome_hover`, `chrome_drag`, or `chrome_scroll`. The per-call value always wins over the session toggle.
|
|
84
|
+
|
|
85
|
+
First time you upgrade to a pi-chrome that uses trusted input, Chrome will prompt to re-approve the extension's new `debugger` permission — do that once in `chrome://extensions`.
|
|
86
|
+
|
|
68
87
|
## Background mode
|
|
69
88
|
|
|
70
89
|
By default, `chrome_*` tools focus Chrome and activate the target tab so you can watch the agent work — great for demos, pair-driving, debugging, and first-time confidence that things are happening.
|
|
@@ -124,7 +143,7 @@ If the Chrome extension you have loaded is older than `pi-chrome` on disk, `/chr
|
|
|
124
143
|
## Compose with
|
|
125
144
|
|
|
126
145
|
- **pi-qq** — ask side questions about what the agent saw in Chrome without polluting the main transcript: `/qq summarize what the active GitHub tab shows`.
|
|
127
|
-
- **
|
|
146
|
+
- **pi-bar** — watch context pressure as the agent scrapes large pages; the footer's red threshold is a clean signal to `/qq` for a recap before context overflows.
|
|
128
147
|
- **PR demo skills** (such as `ios-pr-agent` / `ios-demo-record` workflows) — `chrome_screenshot` writes to `.pi/chrome-screenshots/` so you can attach images to PR descriptions or demo bundles.
|
|
129
148
|
|
|
130
149
|
## Tools
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Pi Existing Chrome Profile Bridge",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"description": "Lets Pi control tabs in this existing Chrome profile via a local bridge at 127.0.0.1.",
|
|
6
|
-
"permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation"],
|
|
6
|
+
"permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation", "debugger"],
|
|
7
7
|
"host_permissions": ["<all_urls>", "http://127.0.0.1:17318/*"],
|
|
8
8
|
"background": {
|
|
9
9
|
"service_worker": "service_worker.js"
|
|
@@ -3,6 +3,350 @@ const CLIENT_NAME = `Pi Chrome Bridge ${chrome.runtime.id}`;
|
|
|
3
3
|
const POLL_ERROR_BACKOFF_MS = 2000;
|
|
4
4
|
let polling = false;
|
|
5
5
|
|
|
6
|
+
// =================== Trusted-input (CDP) layer ===================
|
|
7
|
+
// Tracks which tabs we have attached chrome.debugger to, plus session-level mode.
|
|
8
|
+
const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
|
|
9
|
+
let TRUSTED_MODE = "off"; // "off" | "on" | "auto"
|
|
10
|
+
const TRUSTED_IDLE_DETACH_MS = 15_000;
|
|
11
|
+
const CDP_VERSION = "1.3";
|
|
12
|
+
|
|
13
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
14
|
+
function rng(min, max) { return min + Math.random() * (max - min); }
|
|
15
|
+
|
|
16
|
+
async function wantsTrusted(params) {
|
|
17
|
+
if (params && params.trusted === false) return false;
|
|
18
|
+
if (params && params.trusted === true) return true;
|
|
19
|
+
return TRUSTED_MODE === "on";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setTrustedMode(mode) {
|
|
23
|
+
const next = String(mode || "").toLowerCase();
|
|
24
|
+
if (!["off", "on", "auto"].includes(next)) throw new Error(`bad trusted mode: ${next}`);
|
|
25
|
+
TRUSTED_MODE = next;
|
|
26
|
+
if (next === "off") void detachAll();
|
|
27
|
+
return { mode: TRUSTED_MODE };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function trustedStatus() {
|
|
31
|
+
return {
|
|
32
|
+
mode: TRUSTED_MODE,
|
|
33
|
+
attachedTabs: Array.from(attachedTabs.keys()),
|
|
34
|
+
permissionGranted: typeof chrome !== "undefined" && !!chrome.debugger,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function attachDebugger(tabId) {
|
|
39
|
+
if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
|
|
40
|
+
if (attachedTabs.has(tabId)) {
|
|
41
|
+
const entry = attachedTabs.get(tabId);
|
|
42
|
+
entry.detachAt = Date.now() + TRUSTED_IDLE_DETACH_MS;
|
|
43
|
+
return entry;
|
|
44
|
+
}
|
|
45
|
+
await chrome.debugger.attach({ tabId }, CDP_VERSION);
|
|
46
|
+
// Seed pointer in a plausible "just left the address bar" location.
|
|
47
|
+
const entry = { detachAt: Date.now() + TRUSTED_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
|
|
48
|
+
attachedTabs.set(tabId, entry);
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function detachDebugger(tabId) {
|
|
53
|
+
if (!attachedTabs.has(tabId)) return;
|
|
54
|
+
attachedTabs.delete(tabId);
|
|
55
|
+
try { await chrome.debugger.detach({ tabId }); } catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function detachAll() {
|
|
59
|
+
const ids = Array.from(attachedTabs.keys());
|
|
60
|
+
await Promise.all(ids.map(detachDebugger));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (chrome.debugger && chrome.debugger.onDetach) {
|
|
64
|
+
chrome.debugger.onDetach.addListener(({ tabId }, reason) => {
|
|
65
|
+
if (tabId !== undefined) attachedTabs.delete(tabId);
|
|
66
|
+
if (reason === "canceled_by_user") {
|
|
67
|
+
console.warn(`[pi-chrome] debugger canceled by user on tab ${tabId}; trusted mode will reattach on next call`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setInterval(() => {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
for (const [tabId, entry] of attachedTabs) {
|
|
75
|
+
if (entry.detachAt && entry.detachAt < now && TRUSTED_MODE !== "on") {
|
|
76
|
+
void detachDebugger(tabId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}, 5000);
|
|
80
|
+
|
|
81
|
+
function cdp(tabId, method, params) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
chrome.debugger.sendCommand({ tabId }, method, params || {}, (result) => {
|
|
84
|
+
if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
|
|
85
|
+
else resolve(result);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
|
|
91
|
+
async function resolveTargetInTab(tabId, params) {
|
|
92
|
+
const results = await chrome.scripting.executeScript({
|
|
93
|
+
target: { tabId, frameIds: [0] },
|
|
94
|
+
world: "MAIN",
|
|
95
|
+
func: (selector, uid, x, y) => {
|
|
96
|
+
const state = window.__PI_CHROME_STATE__;
|
|
97
|
+
let el = null;
|
|
98
|
+
if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid];
|
|
99
|
+
else if (selector) el = document.querySelector(selector);
|
|
100
|
+
if (el) {
|
|
101
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
102
|
+
const r = el.getBoundingClientRect();
|
|
103
|
+
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 };
|
|
104
|
+
}
|
|
105
|
+
if (typeof x === "number" && typeof y === "number") return { x, y, rect: null, tag: null, found: true };
|
|
106
|
+
return { found: false };
|
|
107
|
+
},
|
|
108
|
+
args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
|
|
109
|
+
});
|
|
110
|
+
const v = results?.[0]?.result;
|
|
111
|
+
if (!v || !v.found) throw new Error("Could not resolve target element for trusted action");
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pickInsideRect(rect) {
|
|
116
|
+
if (!rect) return null;
|
|
117
|
+
const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
|
|
118
|
+
const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
|
|
119
|
+
return {
|
|
120
|
+
x: rect.left + rect.width / 2 + rng(-insetX, insetX),
|
|
121
|
+
y: rect.top + rect.height / 2 + rng(-insetY, insetY),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function cdpMoveTo(tabId, x, y) {
|
|
126
|
+
const entry = attachedTabs.get(tabId);
|
|
127
|
+
const startX = entry?.pointer?.x ?? Math.max(20, Math.min(400, x - 200));
|
|
128
|
+
const startY = entry?.pointer?.y ?? Math.max(20, Math.min(400, y - 200));
|
|
129
|
+
const n = Math.max(10, Math.min(36, Math.round(Math.hypot(x - startX, y - startY) / 20)));
|
|
130
|
+
for (let i = 1; i <= n; i++) {
|
|
131
|
+
const t = i / n;
|
|
132
|
+
const ease = t * t * (3 - 2 * t);
|
|
133
|
+
const wobble = Math.sin(t * Math.PI) * 8;
|
|
134
|
+
const px = startX + (x - startX) * ease + rng(-wobble, wobble);
|
|
135
|
+
const py = startY + (y - startY) * ease + rng(-wobble, wobble);
|
|
136
|
+
await cdp(tabId, "Input.dispatchMouseEvent", {
|
|
137
|
+
type: "mouseMoved", x: px, y: py, button: "none", buttons: 0, pointerType: "mouse",
|
|
138
|
+
});
|
|
139
|
+
await sleep(rng(5, 16));
|
|
140
|
+
}
|
|
141
|
+
if (entry) entry.pointer = { x, y };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cdpModifiersFor(mods) {
|
|
145
|
+
let m = 0;
|
|
146
|
+
if (mods?.altKey) m |= 1;
|
|
147
|
+
if (mods?.ctrlKey) m |= 2;
|
|
148
|
+
if (mods?.metaKey) m |= 4;
|
|
149
|
+
if (mods?.shiftKey) m |= 8;
|
|
150
|
+
return m;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cdpKeyInfo(key, shifted) {
|
|
154
|
+
// Map common keys to CDP key event init fields. Returns { code, key, windowsVirtualKeyCode, text }.
|
|
155
|
+
const SPECIAL = {
|
|
156
|
+
Enter: { code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
|
|
157
|
+
Tab: { code: "Tab", windowsVirtualKeyCode: 9, text: "\t" },
|
|
158
|
+
Backspace: { code: "Backspace", windowsVirtualKeyCode: 8, text: "" },
|
|
159
|
+
Delete: { code: "Delete", windowsVirtualKeyCode: 46, text: "" },
|
|
160
|
+
Escape: { code: "Escape", windowsVirtualKeyCode: 27, text: "" },
|
|
161
|
+
ArrowLeft: { code: "ArrowLeft", windowsVirtualKeyCode: 37, text: "" },
|
|
162
|
+
ArrowUp: { code: "ArrowUp", windowsVirtualKeyCode: 38, text: "" },
|
|
163
|
+
ArrowRight: { code: "ArrowRight", windowsVirtualKeyCode: 39, text: "" },
|
|
164
|
+
ArrowDown: { code: "ArrowDown", windowsVirtualKeyCode: 40, text: "" },
|
|
165
|
+
Shift: { code: "ShiftLeft", windowsVirtualKeyCode: 16, text: "" },
|
|
166
|
+
Control: { code: "ControlLeft", windowsVirtualKeyCode: 17, text: "" },
|
|
167
|
+
Alt: { code: "AltLeft", windowsVirtualKeyCode: 18, text: "" },
|
|
168
|
+
Meta: { code: "MetaLeft", windowsVirtualKeyCode: 91, text: "" },
|
|
169
|
+
" ": { code: "Space", windowsVirtualKeyCode: 32, text: " " },
|
|
170
|
+
};
|
|
171
|
+
if (SPECIAL[key]) return { key, ...SPECIAL[key] };
|
|
172
|
+
if (key.length === 1) {
|
|
173
|
+
const ch = key;
|
|
174
|
+
let code, vk;
|
|
175
|
+
if (/^[a-zA-Z]$/.test(ch)) { code = `Key${ch.toUpperCase()}`; vk = ch.toUpperCase().charCodeAt(0); }
|
|
176
|
+
else if (/^[0-9]$/.test(ch)) { code = `Digit${ch}`; vk = ch.charCodeAt(0); }
|
|
177
|
+
else { code = ch; vk = ch.charCodeAt(0); }
|
|
178
|
+
return { key: ch, code, windowsVirtualKeyCode: vk, text: ch };
|
|
179
|
+
}
|
|
180
|
+
return { key, code: key, windowsVirtualKeyCode: 0, text: "" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function cdpTypeChar(tabId, ch) {
|
|
184
|
+
const needShift = /^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch);
|
|
185
|
+
let modifiers = 0;
|
|
186
|
+
if (needShift) {
|
|
187
|
+
await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 8 });
|
|
188
|
+
modifiers = 8;
|
|
189
|
+
await sleep(rng(8, 22));
|
|
190
|
+
}
|
|
191
|
+
const info = cdpKeyInfo(ch);
|
|
192
|
+
await cdp(tabId, "Input.dispatchKeyEvent", {
|
|
193
|
+
type: "keyDown", key: info.key, code: info.code,
|
|
194
|
+
windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
|
|
195
|
+
text: info.text, unmodifiedText: info.text, modifiers,
|
|
196
|
+
});
|
|
197
|
+
await sleep(rng(25, 90));
|
|
198
|
+
await cdp(tabId, "Input.dispatchKeyEvent", {
|
|
199
|
+
type: "keyUp", key: info.key, code: info.code,
|
|
200
|
+
windowsVirtualKeyCode: info.windowsVirtualKeyCode, modifiers,
|
|
201
|
+
});
|
|
202
|
+
if (needShift) {
|
|
203
|
+
await sleep(rng(5, 18));
|
|
204
|
+
await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 0 });
|
|
205
|
+
}
|
|
206
|
+
await sleep(rng(35, 130));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function trustedClick(params) {
|
|
210
|
+
const tab = await getTabByParams(params);
|
|
211
|
+
if (params.foreground) await bringToFront(tab);
|
|
212
|
+
await attachDebugger(tab.id);
|
|
213
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
214
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
215
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
216
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
|
|
217
|
+
await sleep(rng(45, 140));
|
|
218
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
219
|
+
return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function trustedHover(params) {
|
|
223
|
+
const tab = await getTabByParams(params);
|
|
224
|
+
if (params.foreground) await bringToFront(tab);
|
|
225
|
+
await attachDebugger(tab.id);
|
|
226
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
227
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
228
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
229
|
+
await sleep(rng(80, 220));
|
|
230
|
+
return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function trustedKey(params) {
|
|
234
|
+
const tab = await getTabByParams(params);
|
|
235
|
+
if (params.foreground) await bringToFront(tab);
|
|
236
|
+
await attachDebugger(tab.id);
|
|
237
|
+
const key = String(params.key || "");
|
|
238
|
+
if (!key) throw new Error("trusted.key: missing key");
|
|
239
|
+
const info = cdpKeyInfo(key);
|
|
240
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", {
|
|
241
|
+
type: "keyDown", key: info.key, code: info.code,
|
|
242
|
+
windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
|
|
243
|
+
text: info.text, unmodifiedText: info.text,
|
|
244
|
+
});
|
|
245
|
+
await sleep(rng(25, 90));
|
|
246
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", {
|
|
247
|
+
type: "keyUp", key: info.key, code: info.code,
|
|
248
|
+
windowsVirtualKeyCode: info.windowsVirtualKeyCode,
|
|
249
|
+
});
|
|
250
|
+
return { trusted: true, key: info.key };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function trustedType(params) {
|
|
254
|
+
const tab = await getTabByParams(params);
|
|
255
|
+
if (params.foreground) await bringToFront(tab);
|
|
256
|
+
await attachDebugger(tab.id);
|
|
257
|
+
if (params.selector || params.uid) {
|
|
258
|
+
// Focus target by clicking it first.
|
|
259
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
260
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
261
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
262
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
|
|
263
|
+
await sleep(rng(45, 110));
|
|
264
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
265
|
+
await sleep(rng(50, 120));
|
|
266
|
+
}
|
|
267
|
+
const text = String(params.text || "");
|
|
268
|
+
for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
|
|
269
|
+
if (params.pressEnter) {
|
|
270
|
+
await cdpTypeChar(tab.id, "\r").catch(() => undefined);
|
|
271
|
+
await trustedKey({ ...params, key: "Enter" });
|
|
272
|
+
}
|
|
273
|
+
return { trusted: true, length: text.length };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function trustedFill(params) {
|
|
277
|
+
const tab = await getTabByParams(params);
|
|
278
|
+
if (params.foreground) await bringToFront(tab);
|
|
279
|
+
await attachDebugger(tab.id);
|
|
280
|
+
if (!(params.selector || params.uid)) throw new Error("trusted.fill: selector or uid required");
|
|
281
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
282
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
283
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
284
|
+
// Triple-click selects all in input fields.
|
|
285
|
+
for (let i = 1; i <= 3; i++) {
|
|
286
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: i, pointerType: "mouse" });
|
|
287
|
+
await sleep(rng(20, 60));
|
|
288
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
|
|
289
|
+
await sleep(rng(20, 60));
|
|
290
|
+
}
|
|
291
|
+
// Delete selection.
|
|
292
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
293
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
294
|
+
await sleep(rng(20, 60));
|
|
295
|
+
const text = String(params.text || "");
|
|
296
|
+
for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
|
|
297
|
+
if (params.submit) await trustedKey({ ...params, key: "Enter" });
|
|
298
|
+
return { trusted: true, length: text.length };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function trustedScroll(params) {
|
|
302
|
+
const tab = await getTabByParams(params);
|
|
303
|
+
if (params.foreground) await bringToFront(tab);
|
|
304
|
+
await attachDebugger(tab.id);
|
|
305
|
+
const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
|
|
306
|
+
const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
|
|
307
|
+
const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y;
|
|
308
|
+
const totalY = params.deltaY || 0, totalX = params.deltaX || 0;
|
|
309
|
+
const n = Math.max(3, Math.min(20, params.steps || Math.ceil(Math.abs(totalY) / 120)));
|
|
310
|
+
// momentum-shaped front-loaded weights
|
|
311
|
+
const w = []; for (let i = 1; i <= n; i++) w.push(1 / i);
|
|
312
|
+
const sumW = w.reduce((a, b) => a + b, 0);
|
|
313
|
+
for (let i = 0; i < n; i++) {
|
|
314
|
+
const dy = totalY * (w[i] / sumW), dx = totalX * (w[i] / sumW);
|
|
315
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", {
|
|
316
|
+
type: "mouseWheel", x, y, deltaX: dx, deltaY: dy, pointerType: "mouse",
|
|
317
|
+
});
|
|
318
|
+
await sleep(rng(14, 32));
|
|
319
|
+
}
|
|
320
|
+
return { trusted: true, deltaX: totalX, deltaY: totalY, steps: n };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function trustedDrag(params) {
|
|
324
|
+
const tab = await getTabByParams(params);
|
|
325
|
+
if (params.foreground) await bringToFront(tab);
|
|
326
|
+
await attachDebugger(tab.id);
|
|
327
|
+
const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
|
|
328
|
+
const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
|
|
329
|
+
const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y };
|
|
330
|
+
const tp = to.rect ? pickInsideRect(to.rect) : { x: to.x, y: to.y };
|
|
331
|
+
await cdpMoveTo(tab.id, fp.x, fp.y);
|
|
332
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: fp.x, y: fp.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
|
|
333
|
+
await sleep(rng(60, 140));
|
|
334
|
+
const steps = params.steps || 20;
|
|
335
|
+
for (let i = 1; i <= steps; i++) {
|
|
336
|
+
const t = i / steps;
|
|
337
|
+
const ease = t * t * (3 - 2 * t);
|
|
338
|
+
const wobble = Math.sin(t * Math.PI) * 6;
|
|
339
|
+
const x = fp.x + (tp.x - fp.x) * ease + rng(-wobble, wobble);
|
|
340
|
+
const y = fp.y + (tp.y - fp.y) * ease + rng(-wobble, wobble);
|
|
341
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "left", buttons: 1, pointerType: "mouse" });
|
|
342
|
+
await sleep(rng(10, 26));
|
|
343
|
+
}
|
|
344
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: tp.x, y: tp.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
345
|
+
return { trusted: true, from: fp, to: tp, steps };
|
|
346
|
+
}
|
|
347
|
+
// ===============================================================
|
|
348
|
+
|
|
349
|
+
|
|
6
350
|
function armKeepaliveAlarm() {
|
|
7
351
|
chrome.alarms.create("pi-bridge-keepalive", { periodInMinutes: 0.5 });
|
|
8
352
|
}
|
|
@@ -43,6 +387,13 @@ async function pollLoop() {
|
|
|
43
387
|
cache: "no-store",
|
|
44
388
|
});
|
|
45
389
|
if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
|
|
390
|
+
const expected = response.headers.get("x-pi-chrome-version");
|
|
391
|
+
const ours = chrome.runtime.getManifest().version;
|
|
392
|
+
if (expected && expected !== ours && isVersionOlder(ours, expected)) {
|
|
393
|
+
console.warn(`[pi-chrome] extension v${ours} behind pi-chrome v${expected}; reloading extension`);
|
|
394
|
+
try { chrome.runtime.reload(); } catch {}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
46
397
|
const payload = await response.json();
|
|
47
398
|
if (payload.type === "command") await handleCommand(payload.command);
|
|
48
399
|
}
|
|
@@ -70,8 +421,16 @@ async function postResult(result) {
|
|
|
70
421
|
});
|
|
71
422
|
}
|
|
72
423
|
|
|
73
|
-
function
|
|
74
|
-
|
|
424
|
+
function isVersionOlder(a, b) {
|
|
425
|
+
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
426
|
+
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
427
|
+
const n = Math.max(pa.length, pb.length);
|
|
428
|
+
for (let i = 0; i < n; i++) {
|
|
429
|
+
const x = pa[i] ?? 0, y = pb[i] ?? 0;
|
|
430
|
+
if (x < y) return true;
|
|
431
|
+
if (x > y) return false;
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
75
434
|
}
|
|
76
435
|
|
|
77
436
|
async function dispatch(action, params) {
|
|
@@ -109,19 +468,32 @@ async function dispatch(action, params) {
|
|
|
109
468
|
case "page.evaluate":
|
|
110
469
|
return evaluateInTab(params);
|
|
111
470
|
case "page.click":
|
|
471
|
+
if (await wantsTrusted(params)) return trustedClick(params);
|
|
112
472
|
return executeActionInTab(params, clickPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
|
|
113
473
|
case "page.hover":
|
|
474
|
+
if (await wantsTrusted(params)) return trustedHover(params);
|
|
114
475
|
return executeActionInTab(params, hoverPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
|
|
115
476
|
case "page.drag":
|
|
477
|
+
if (await wantsTrusted(params)) return trustedDrag(params);
|
|
116
478
|
return executeActionInTab(params, dragPage, [params.fromUid ?? null, params.fromSelector ?? null, params.fromX ?? null, params.fromY ?? null, params.toUid ?? null, params.toSelector ?? null, params.toX ?? null, params.toY ?? null, params.steps ?? 12]);
|
|
117
479
|
case "page.upload":
|
|
118
480
|
return executeActionInTab(params, uploadFiles, [params.selector ?? null, params.uid ?? null, params.files || []]);
|
|
119
481
|
case "page.type":
|
|
482
|
+
if (await wantsTrusted(params)) return trustedType(params);
|
|
120
483
|
return executeActionInTab(params, typeIntoPage, [params.selector ?? null, params.uid ?? null, params.text || "", Boolean(params.pressEnter)]);
|
|
121
484
|
case "page.fill":
|
|
485
|
+
if (await wantsTrusted(params)) return trustedFill(params);
|
|
122
486
|
return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]);
|
|
123
487
|
case "page.key":
|
|
488
|
+
if (await wantsTrusted(params)) return trustedKey(params);
|
|
124
489
|
return executeActionInTab(params, pressKeyInPage, [params.key]);
|
|
490
|
+
case "page.scroll":
|
|
491
|
+
if (await wantsTrusted(params)) return trustedScroll(params);
|
|
492
|
+
return executeActionInTab(params, scrollPage, [params.selector ?? null, params.uid ?? null, params.deltaY ?? 0, params.deltaX ?? 0, params.steps ?? null]);
|
|
493
|
+
case "trusted.mode":
|
|
494
|
+
return setTrustedMode(params.mode);
|
|
495
|
+
case "trusted.status":
|
|
496
|
+
return trustedStatus();
|
|
125
497
|
case "page.console.list":
|
|
126
498
|
return executeInTab(params, listConsoleMessages, [params.clear === true]);
|
|
127
499
|
case "page.network.list":
|
|
@@ -207,6 +579,15 @@ const HELPER_FUNCS = [
|
|
|
207
579
|
occluderAt,
|
|
208
580
|
pageHash,
|
|
209
581
|
pointerEventSequence,
|
|
582
|
+
sleepPage,
|
|
583
|
+
rand,
|
|
584
|
+
dispatchPointerLikeEvent,
|
|
585
|
+
humanMoveTo,
|
|
586
|
+
humanClickPoint,
|
|
587
|
+
printableKeyCode,
|
|
588
|
+
dispatchKeyEvent,
|
|
589
|
+
typeCharacter,
|
|
590
|
+
scrollPage,
|
|
210
591
|
];
|
|
211
592
|
|
|
212
593
|
async function executeInTab(params, func, args) {
|
|
@@ -497,32 +878,94 @@ function pageHash() {
|
|
|
497
878
|
return h;
|
|
498
879
|
}
|
|
499
880
|
|
|
881
|
+
function sleepPage(ms) {
|
|
882
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function rand(min, max) {
|
|
886
|
+
return min + Math.random() * (max - min);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function dispatchPointerLikeEvent(element, type, x, y, prevX, prevY, opts = {}) {
|
|
890
|
+
const isPointer = type.startsWith("pointer");
|
|
891
|
+
const Ctor = isPointer ? PointerEvent : MouseEvent;
|
|
892
|
+
const isMove = type === "pointermove" || type === "mousemove";
|
|
893
|
+
const isUpOrClick = type === "pointerup" || type === "mouseup" || type === "click";
|
|
894
|
+
const init = {
|
|
895
|
+
bubbles: true,
|
|
896
|
+
cancelable: true,
|
|
897
|
+
view: window,
|
|
898
|
+
clientX: x,
|
|
899
|
+
clientY: y,
|
|
900
|
+
screenX: x + (window.screenX || 0),
|
|
901
|
+
screenY: y + (window.screenY || 0),
|
|
902
|
+
movementX: Number.isFinite(prevX) ? x - prevX : 0,
|
|
903
|
+
movementY: Number.isFinite(prevY) ? y - prevY : 0,
|
|
904
|
+
button: 0,
|
|
905
|
+
buttons: isMove || isUpOrClick ? 0 : 1,
|
|
906
|
+
};
|
|
907
|
+
if (isPointer) {
|
|
908
|
+
init.pointerType = "mouse";
|
|
909
|
+
init.pointerId = 1;
|
|
910
|
+
init.isPrimary = true;
|
|
911
|
+
init.width = 1;
|
|
912
|
+
init.height = 1;
|
|
913
|
+
init.pressure = opts.pressure ?? (type === "pointerdown" ? 0.5 : 0);
|
|
914
|
+
init.tangentialPressure = 0;
|
|
915
|
+
init.tiltX = 0;
|
|
916
|
+
init.tiltY = 0;
|
|
917
|
+
}
|
|
918
|
+
const ev = new Ctor(type, init);
|
|
919
|
+
element.dispatchEvent(ev);
|
|
920
|
+
return ev.defaultPrevented;
|
|
921
|
+
}
|
|
922
|
+
|
|
500
923
|
function pointerEventSequence(element, x, y, sequence) {
|
|
501
924
|
let defaultPrevented = false;
|
|
925
|
+
const state = getPiChromeState();
|
|
926
|
+
const prevX = state.pointer?.x;
|
|
927
|
+
const prevY = state.pointer?.y;
|
|
502
928
|
for (const type of sequence) {
|
|
503
|
-
|
|
504
|
-
const Ctor = isPointer ? PointerEvent : MouseEvent;
|
|
505
|
-
const init = {
|
|
506
|
-
bubbles: true,
|
|
507
|
-
cancelable: true,
|
|
508
|
-
view: window,
|
|
509
|
-
clientX: x,
|
|
510
|
-
clientY: y,
|
|
511
|
-
button: 0,
|
|
512
|
-
buttons: type === "pointermove" || type === "mousemove" ? 0 : 1,
|
|
513
|
-
};
|
|
514
|
-
if (isPointer) {
|
|
515
|
-
init.pointerType = "mouse";
|
|
516
|
-
init.pointerId = 1;
|
|
517
|
-
init.isPrimary = true;
|
|
518
|
-
}
|
|
519
|
-
const ev = new Ctor(type, init);
|
|
520
|
-
element.dispatchEvent(ev);
|
|
521
|
-
if (ev.defaultPrevented) defaultPrevented = true;
|
|
929
|
+
defaultPrevented = dispatchPointerLikeEvent(element, type, x, y, prevX, prevY) || defaultPrevented;
|
|
522
930
|
}
|
|
931
|
+
state.pointer = { x, y, t: performance.now() };
|
|
523
932
|
return defaultPrevented;
|
|
524
933
|
}
|
|
525
934
|
|
|
935
|
+
async function humanMoveTo(x, y, steps) {
|
|
936
|
+
const state = getPiChromeState();
|
|
937
|
+
const startX = Number.isFinite(state.pointer?.x) ? state.pointer.x : rand(12, Math.max(24, innerWidth - 12));
|
|
938
|
+
const startY = Number.isFinite(state.pointer?.y) ? state.pointer.y : rand(12, Math.max(24, innerHeight - 12));
|
|
939
|
+
const n = steps || Math.max(12, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18)));
|
|
940
|
+
let prevX = startX, prevY = startY;
|
|
941
|
+
let defaultPrevented = false;
|
|
942
|
+
for (let i = 1; i <= n; i++) {
|
|
943
|
+
const t = i / n;
|
|
944
|
+
const ease = t * t * (3 - 2 * t);
|
|
945
|
+
const wobble = Math.sin(t * Math.PI) * 8;
|
|
946
|
+
const px = startX + (x - startX) * ease + rand(-wobble, wobble);
|
|
947
|
+
const py = startY + (y - startY) * ease + rand(-wobble, wobble);
|
|
948
|
+
const el = document.elementFromPoint(px, py) || document.body || document.documentElement;
|
|
949
|
+
defaultPrevented = dispatchPointerLikeEvent(el, "pointermove", px, py, prevX, prevY) || defaultPrevented;
|
|
950
|
+
defaultPrevented = dispatchPointerLikeEvent(el, "mousemove", px, py, prevX, prevY) || defaultPrevented;
|
|
951
|
+
prevX = px; prevY = py;
|
|
952
|
+
await sleepPage(rand(4, 18));
|
|
953
|
+
}
|
|
954
|
+
state.pointer = { x, y, t: performance.now() };
|
|
955
|
+
return defaultPrevented;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function humanClickPoint(point) {
|
|
959
|
+
if (!point.rect) return { x: point.x, y: point.y };
|
|
960
|
+
const rect = point.rect;
|
|
961
|
+
const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
|
|
962
|
+
const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
|
|
963
|
+
return {
|
|
964
|
+
x: rect.left + rect.width / 2 + rand(-insetX, insetX),
|
|
965
|
+
y: rect.top + rect.height / 2 + rand(-insetY, insetY),
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
526
969
|
function installPiChromeInstrumentation() {
|
|
527
970
|
const state = getPiChromeState();
|
|
528
971
|
if (state.instrumentationInstalled) return;
|
|
@@ -773,16 +1216,31 @@ function resolvePoint(selector, uid, x, y) {
|
|
|
773
1216
|
return { element: document.elementFromPoint(x, y), x, y, rect: undefined };
|
|
774
1217
|
}
|
|
775
1218
|
|
|
776
|
-
function clickPage(selector, uid, x, y) {
|
|
1219
|
+
async function clickPage(selector, uid, x, y) {
|
|
777
1220
|
installPiChromeInstrumentation();
|
|
778
1221
|
const before = pageHash();
|
|
779
1222
|
const point = resolvePoint(selector, uid, x, y);
|
|
780
1223
|
if (!point.element) throw new Error("No element at click point");
|
|
1224
|
+
const clickPoint = humanClickPoint(point);
|
|
1225
|
+
point.x = clickPoint.x;
|
|
1226
|
+
point.y = clickPoint.y;
|
|
1227
|
+
point.element = document.elementFromPoint(point.x, point.y) || point.element;
|
|
781
1228
|
const visible = isElementVisible(point.element);
|
|
782
1229
|
const occluded = occluderAt(point.x, point.y, point.element);
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1230
|
+
let defaultPrevented = await humanMoveTo(point.x, point.y);
|
|
1231
|
+
const state = getPiChromeState();
|
|
1232
|
+
const prevX = state.pointer?.x;
|
|
1233
|
+
const prevY = state.pointer?.y;
|
|
1234
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerdown", point.x, point.y, prevX, prevY, { pressure: 0.5 }) || defaultPrevented;
|
|
1235
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "mousedown", point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1236
|
+
if (typeof point.element.focus === "function" && /^(A|BUTTON|INPUT|TEXTAREA|SELECT|SUMMARY)$/.test(point.element.tagName)) {
|
|
1237
|
+
try { point.element.focus({ preventScroll: true }); } catch { try { point.element.focus(); } catch {} }
|
|
1238
|
+
}
|
|
1239
|
+
await sleepPage(rand(45, 140));
|
|
1240
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerup", point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1241
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "mouseup", point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1242
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, "click", point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1243
|
+
state.pointer = { x: point.x, y: point.y, t: performance.now() };
|
|
786
1244
|
// Heuristic: if the clicked thing looks like a media play affordance and the page has paused
|
|
787
1245
|
// audio/video, the synthetic click may not unlock autoplay. Surface a warning.
|
|
788
1246
|
let autoplayHint;
|
|
@@ -806,38 +1264,136 @@ function clickPage(selector, uid, x, y) {
|
|
|
806
1264
|
};
|
|
807
1265
|
}
|
|
808
1266
|
|
|
809
|
-
function hoverPage(selector, uid, x, y) {
|
|
1267
|
+
async function hoverPage(selector, uid, x, y) {
|
|
810
1268
|
installPiChromeInstrumentation();
|
|
811
1269
|
const point = resolvePoint(selector, uid, x, y);
|
|
812
1270
|
if (!point.element) throw new Error("No element to hover");
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1271
|
+
await humanMoveTo(point.x, point.y);
|
|
1272
|
+
const state = getPiChromeState();
|
|
1273
|
+
const prevX = state.pointer?.x, prevY = state.pointer?.y;
|
|
1274
|
+
let defaultPrevented = false;
|
|
1275
|
+
for (const type of ["pointerover", "mouseover", "pointerenter", "mouseenter"]) {
|
|
1276
|
+
defaultPrevented = dispatchPointerLikeEvent(point.element, type, point.x, point.y, prevX, prevY) || defaultPrevented;
|
|
1277
|
+
}
|
|
1278
|
+
// Small dwell so hover-intent handlers fire.
|
|
1279
|
+
await sleepPage(rand(80, 220));
|
|
816
1280
|
return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, isTrusted: false };
|
|
817
1281
|
}
|
|
818
1282
|
|
|
819
|
-
function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
|
|
1283
|
+
async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
|
|
820
1284
|
installPiChromeInstrumentation();
|
|
821
1285
|
const before = pageHash();
|
|
822
1286
|
const from = resolvePoint(fromSelector, fromUid, fromX, fromY);
|
|
823
1287
|
const to = resolvePoint(toSelector, toUid, toX, toY);
|
|
824
1288
|
if (!from.element) throw new Error("Drag source element not found");
|
|
825
1289
|
if (!to.element) throw new Error("Drag target element not found");
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1290
|
+
// Move to source.
|
|
1291
|
+
await humanMoveTo(from.x, from.y);
|
|
1292
|
+
const state = getPiChromeState();
|
|
1293
|
+
let prevX = state.pointer?.x, prevY = state.pointer?.y;
|
|
1294
|
+
// Build a shared DataTransfer so HTML5 drag-and-drop handlers can populate / read it.
|
|
1295
|
+
const dt = new DataTransfer();
|
|
1296
|
+
const dragInit = (type, target, x, y) => {
|
|
1297
|
+
const ev = new DragEvent(type, {
|
|
1298
|
+
bubbles: true, cancelable: true, composed: true,
|
|
1299
|
+
clientX: x, clientY: y,
|
|
1300
|
+
screenX: x + (window.screenX || 0), screenY: y + (window.screenY || 0),
|
|
1301
|
+
button: 0, buttons: 1, view: window,
|
|
1302
|
+
dataTransfer: dt,
|
|
1303
|
+
});
|
|
1304
|
+
target.dispatchEvent(ev);
|
|
1305
|
+
return ev;
|
|
1306
|
+
};
|
|
1307
|
+
dispatchPointerLikeEvent(from.element, "pointerover", from.x, from.y, prevX, prevY);
|
|
1308
|
+
dispatchPointerLikeEvent(from.element, "pointerdown", from.x, from.y, prevX, prevY, { pressure: 0.5 });
|
|
1309
|
+
dispatchPointerLikeEvent(from.element, "mousedown", from.x, from.y, prevX, prevY);
|
|
1310
|
+
await sleepPage(rand(40, 110));
|
|
1311
|
+
dragInit("dragstart", from.element, from.x, from.y);
|
|
1312
|
+
dragInit("drag", from.element, from.x, from.y);
|
|
1313
|
+
let lastOver = from.element;
|
|
1314
|
+
const n = steps || 18;
|
|
1315
|
+
for (let i = 1; i <= n; i++) {
|
|
1316
|
+
const t = i / n;
|
|
1317
|
+
const ease = t * t * (3 - 2 * t);
|
|
1318
|
+
const wobble = Math.sin(t * Math.PI) * 6;
|
|
1319
|
+
const x = from.x + (to.x - from.x) * ease + rand(-wobble, wobble);
|
|
1320
|
+
const y = from.y + (to.y - from.y) * ease + rand(-wobble, wobble);
|
|
831
1321
|
const overEl = document.elementFromPoint(x, y) || to.element;
|
|
832
|
-
|
|
1322
|
+
dispatchPointerLikeEvent(overEl, "pointermove", x, y, prevX, prevY);
|
|
1323
|
+
dispatchPointerLikeEvent(overEl, "mousemove", x, y, prevX, prevY);
|
|
1324
|
+
if (overEl !== lastOver) {
|
|
1325
|
+
dragInit("dragleave", lastOver, x, y);
|
|
1326
|
+
dragInit("dragenter", overEl, x, y);
|
|
1327
|
+
lastOver = overEl;
|
|
1328
|
+
}
|
|
1329
|
+
dragInit("dragover", overEl, x, y);
|
|
1330
|
+
dragInit("drag", from.element, x, y);
|
|
1331
|
+
prevX = x; prevY = y;
|
|
1332
|
+
await sleepPage(rand(8, 26));
|
|
833
1333
|
}
|
|
834
|
-
|
|
1334
|
+
dispatchPointerLikeEvent(to.element, "pointerover", to.x, to.y, prevX, prevY);
|
|
1335
|
+
dispatchPointerLikeEvent(to.element, "mouseover", to.x, to.y, prevX, prevY);
|
|
1336
|
+
dragInit("drop", to.element, to.x, to.y);
|
|
1337
|
+
dragInit("dragend", from.element, to.x, to.y);
|
|
1338
|
+
dispatchPointerLikeEvent(to.element, "pointerup", to.x, to.y, prevX, prevY);
|
|
1339
|
+
dispatchPointerLikeEvent(to.element, "mouseup", to.x, to.y, prevX, prevY);
|
|
1340
|
+
state.pointer = { x: to.x, y: to.y, t: performance.now() };
|
|
835
1341
|
return {
|
|
836
1342
|
from: { x: from.x, y: from.y },
|
|
837
1343
|
to: { x: to.x, y: to.y },
|
|
838
|
-
steps,
|
|
1344
|
+
steps: n,
|
|
1345
|
+
pageMutated: pageHash() !== before,
|
|
1346
|
+
note: "Synthetic drag with HTML5 DragEvent + shared DataTransfer. isTrusted is still false.",
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
async function scrollPage(selector, uid, deltaY, deltaX, steps) {
|
|
1351
|
+
installPiChromeInstrumentation();
|
|
1352
|
+
const before = pageHash();
|
|
1353
|
+
let target;
|
|
1354
|
+
if (selector || uid) {
|
|
1355
|
+
target = elementBySelectorOrUid(selector, uid);
|
|
1356
|
+
} else {
|
|
1357
|
+
target = document.scrollingElement || document.documentElement || document.body;
|
|
1358
|
+
}
|
|
1359
|
+
if (!target) throw new Error("No scroll target");
|
|
1360
|
+
const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight };
|
|
1361
|
+
const cx = Math.max(0, Math.min(innerWidth - 1, rect.left + Math.min(rect.width, innerWidth) / 2));
|
|
1362
|
+
const cy = Math.max(0, Math.min(innerHeight - 1, rect.top + Math.min(rect.height, innerHeight) / 2));
|
|
1363
|
+
const n = Math.max(3, Math.min(40, steps || Math.max(3, Math.ceil(Math.abs(deltaY || 0) / 100))));
|
|
1364
|
+
// Front-loaded wheel deltas, momentum-style.
|
|
1365
|
+
const totalY = deltaY || 0;
|
|
1366
|
+
const totalX = deltaX || 0;
|
|
1367
|
+
const weights = [];
|
|
1368
|
+
for (let i = 1; i <= n; i++) weights.push(1 / i);
|
|
1369
|
+
const sumW = weights.reduce((a, b) => a + b, 0);
|
|
1370
|
+
let movedY = 0, movedX = 0;
|
|
1371
|
+
for (let i = 0; i < n; i++) {
|
|
1372
|
+
const dy = totalY * (weights[i] / sumW);
|
|
1373
|
+
const dx = totalX * (weights[i] / sumW);
|
|
1374
|
+
const ev = new WheelEvent("wheel", {
|
|
1375
|
+
bubbles: true, cancelable: true, composed: true, view: window,
|
|
1376
|
+
clientX: cx, clientY: cy,
|
|
1377
|
+
deltaX: dx, deltaY: dy, deltaMode: 0,
|
|
1378
|
+
});
|
|
1379
|
+
target.dispatchEvent(ev);
|
|
1380
|
+
if (!ev.defaultPrevented) {
|
|
1381
|
+
// Apply scroll ourselves; mirrors what the browser would do.
|
|
1382
|
+
if (target === document.scrollingElement || target === document.documentElement || target === document.body) {
|
|
1383
|
+
window.scrollBy({ left: dx, top: dy, behavior: "instant" });
|
|
1384
|
+
} else {
|
|
1385
|
+
target.scrollTop += dy;
|
|
1386
|
+
target.scrollLeft += dx;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
movedY += dy; movedX += dx;
|
|
1390
|
+
await sleepPage(rand(12, 28));
|
|
1391
|
+
}
|
|
1392
|
+
return {
|
|
1393
|
+
deltaX: movedX, deltaY: movedY, steps: n,
|
|
1394
|
+
scrollTop: target.scrollTop, scrollLeft: target.scrollLeft,
|
|
839
1395
|
pageMutated: pageHash() !== before,
|
|
840
|
-
|
|
1396
|
+
isTrusted: false,
|
|
841
1397
|
};
|
|
842
1398
|
}
|
|
843
1399
|
|
|
@@ -871,23 +1427,90 @@ function setNativeValue(element, value) {
|
|
|
871
1427
|
else element.value = value;
|
|
872
1428
|
}
|
|
873
1429
|
|
|
874
|
-
function
|
|
875
|
-
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
if (
|
|
879
|
-
|
|
1430
|
+
function printableKeyCode(ch) {
|
|
1431
|
+
if (ch === " ") return 32;
|
|
1432
|
+
const upper = ch.toUpperCase();
|
|
1433
|
+
if (/^[A-Z]$/.test(upper)) return upper.charCodeAt(0);
|
|
1434
|
+
if (/^[0-9]$/.test(ch)) return ch.charCodeAt(0);
|
|
1435
|
+
return ch.charCodeAt(0) || 0;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function dispatchKeyEvent(element, type, key, mods = {}) {
|
|
1439
|
+
const code = key.length === 1 && /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` :
|
|
1440
|
+
key.length === 1 && /^[0-9]$/.test(key) ? `Digit${key}` :
|
|
1441
|
+
key === " " ? "Space" : key;
|
|
1442
|
+
const SPECIAL = { Enter: 13, Tab: 9, Backspace: 8, Delete: 46, Escape: 27,
|
|
1443
|
+
ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, " ": 32, Shift: 16, Control: 17, Alt: 18, Meta: 91 };
|
|
1444
|
+
const keyCode = key.length === 1 ? printableKeyCode(key) : (SPECIAL[key] ?? 0);
|
|
1445
|
+
const ev = new KeyboardEvent(type, {
|
|
1446
|
+
key,
|
|
1447
|
+
code,
|
|
1448
|
+
keyCode,
|
|
1449
|
+
which: keyCode,
|
|
1450
|
+
charCode: type === "keypress" && key.length === 1 ? key.charCodeAt(0) : 0,
|
|
1451
|
+
shiftKey: !!mods.shiftKey,
|
|
1452
|
+
ctrlKey: !!mods.ctrlKey,
|
|
1453
|
+
altKey: !!mods.altKey,
|
|
1454
|
+
metaKey: !!mods.metaKey,
|
|
1455
|
+
bubbles: true,
|
|
1456
|
+
cancelable: true,
|
|
1457
|
+
composed: true,
|
|
1458
|
+
view: window,
|
|
1459
|
+
});
|
|
1460
|
+
element.dispatchEvent(ev);
|
|
1461
|
+
return ev;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function typeCharacter(element, ch) {
|
|
1465
|
+
const needShift = ch.length === 1 && (/^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch));
|
|
1466
|
+
if (needShift) {
|
|
1467
|
+
dispatchKeyEvent(element, "keydown", "Shift", { shiftKey: true });
|
|
1468
|
+
await sleepPage(rand(8, 24));
|
|
1469
|
+
}
|
|
1470
|
+
const mods = { shiftKey: needShift };
|
|
1471
|
+
const down = dispatchKeyEvent(element, "keydown", ch, mods);
|
|
1472
|
+
if (down.defaultPrevented) {
|
|
1473
|
+
if (needShift) dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
|
|
1474
|
+
return { defaultPrevented: true };
|
|
1475
|
+
}
|
|
1476
|
+
if (ch.length === 1) dispatchKeyEvent(element, "keypress", ch, mods);
|
|
1477
|
+
|
|
880
1478
|
if (element.isContentEditable) {
|
|
881
|
-
|
|
1479
|
+
// execCommand("insertText") fires its own beforeinput + input. Don't double-dispatch.
|
|
1480
|
+
document.execCommand("insertText", false, ch);
|
|
882
1481
|
} else if ("value" in element) {
|
|
883
1482
|
const start = element.selectionStart ?? element.value.length;
|
|
884
1483
|
const end = element.selectionEnd ?? element.value.length;
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1484
|
+
const next = element.value.slice(0, start) + ch + element.value.slice(end);
|
|
1485
|
+
const before = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: ch });
|
|
1486
|
+
element.dispatchEvent(before);
|
|
1487
|
+
if (!before.defaultPrevented) {
|
|
1488
|
+
setNativeValue(element, next);
|
|
1489
|
+
try { element.selectionStart = element.selectionEnd = start + ch.length; } catch {}
|
|
1490
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ch }));
|
|
1491
|
+
}
|
|
888
1492
|
} else {
|
|
889
1493
|
throw new Error("Focused element is not text-editable");
|
|
890
1494
|
}
|
|
1495
|
+
|
|
1496
|
+
await sleepPage(rand(25, 95));
|
|
1497
|
+
dispatchKeyEvent(element, "keyup", ch, mods);
|
|
1498
|
+
if (needShift) {
|
|
1499
|
+
await sleepPage(rand(5, 18));
|
|
1500
|
+
dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
|
|
1501
|
+
}
|
|
1502
|
+
await sleepPage(rand(35, 140));
|
|
1503
|
+
return { defaultPrevented: false };
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
async function typeIntoPage(selector, uid, text, pressEnter) {
|
|
1507
|
+
installPiChromeInstrumentation();
|
|
1508
|
+
const before = pageHash();
|
|
1509
|
+
let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
|
|
1510
|
+
if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
|
|
1511
|
+
element.focus();
|
|
1512
|
+
if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable");
|
|
1513
|
+
for (const ch of Array.from(text)) await typeCharacter(element, ch);
|
|
891
1514
|
if (pressEnter) pressKeyInPage("Enter");
|
|
892
1515
|
return {
|
|
893
1516
|
selector, uid, length: text.length, pressEnter,
|
|
@@ -923,14 +1546,48 @@ function fillPage(selector, uid, text, submit) {
|
|
|
923
1546
|
};
|
|
924
1547
|
}
|
|
925
1548
|
|
|
926
|
-
function pressKeyInPage(key) {
|
|
1549
|
+
async function pressKeyInPage(key) {
|
|
927
1550
|
const normalized = normalizeKey(key);
|
|
928
1551
|
const target = document.activeElement || document.body;
|
|
929
|
-
const before =
|
|
930
|
-
const down =
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
target.
|
|
1552
|
+
const before = pageHash();
|
|
1553
|
+
const down = dispatchKeyEvent(target, "keydown", normalized);
|
|
1554
|
+
if (normalized.length === 1) dispatchKeyEvent(target, "keypress", normalized);
|
|
1555
|
+
// Character insertion for printable keys when focus is in an editable.
|
|
1556
|
+
if (normalized.length === 1 && !down.defaultPrevented && (target.isContentEditable || ("value" in target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")))) {
|
|
1557
|
+
if (target.isContentEditable) {
|
|
1558
|
+
const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
|
|
1559
|
+
target.dispatchEvent(bi);
|
|
1560
|
+
if (!bi.defaultPrevented) {
|
|
1561
|
+
document.execCommand("insertText", false, normalized);
|
|
1562
|
+
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
|
|
1563
|
+
}
|
|
1564
|
+
} else {
|
|
1565
|
+
const start = target.selectionStart ?? target.value.length;
|
|
1566
|
+
const end = target.selectionEnd ?? target.value.length;
|
|
1567
|
+
const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
|
|
1568
|
+
target.dispatchEvent(bi);
|
|
1569
|
+
if (!bi.defaultPrevented) {
|
|
1570
|
+
setNativeValue(target, target.value.slice(0, start) + normalized + target.value.slice(end));
|
|
1571
|
+
try { target.selectionStart = target.selectionEnd = start + 1; } catch {}
|
|
1572
|
+
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
} else if (normalized === "Backspace" && "value" in target) {
|
|
1576
|
+
const start = target.selectionStart ?? target.value.length;
|
|
1577
|
+
const end = target.selectionEnd ?? target.value.length;
|
|
1578
|
+
if (start > 0 || end > start) {
|
|
1579
|
+
const from = start === end ? start - 1 : start;
|
|
1580
|
+
const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "deleteContentBackward" });
|
|
1581
|
+
target.dispatchEvent(bi);
|
|
1582
|
+
if (!bi.defaultPrevented) {
|
|
1583
|
+
setNativeValue(target, target.value.slice(0, from) + target.value.slice(end));
|
|
1584
|
+
try { target.selectionStart = target.selectionEnd = from; } catch {}
|
|
1585
|
+
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
await sleepPage(rand(25, 95));
|
|
1590
|
+
const up = dispatchKeyEvent(target, "keyup", normalized);
|
|
934
1591
|
if (normalized === "Enter") {
|
|
935
1592
|
const form = target.closest?.("form");
|
|
936
1593
|
if (form) form.requestSubmit?.();
|
|
@@ -939,7 +1596,7 @@ function pressKeyInPage(key) {
|
|
|
939
1596
|
key: normalized,
|
|
940
1597
|
isTrusted: false,
|
|
941
1598
|
defaultPrevented: down.defaultPrevented || up.defaultPrevented,
|
|
942
|
-
pageMutated:
|
|
1599
|
+
pageMutated: pageHash() !== before,
|
|
943
1600
|
};
|
|
944
1601
|
}
|
|
945
1602
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
6
6
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -46,7 +46,16 @@ type BridgeResult = {
|
|
|
46
46
|
error?: string;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
const
|
|
49
|
+
const PI_CHROME_PKG_PATH = resolve(__dirname, "..", "..", "package.json");
|
|
50
|
+
function readPiChromeVersion(): string {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(readFileSync(PI_CHROME_PKG_PATH, "utf8")) as { version?: string };
|
|
53
|
+
if (pkg.version) return pkg.version;
|
|
54
|
+
} catch {}
|
|
55
|
+
return "0.0.0-dev";
|
|
56
|
+
}
|
|
57
|
+
const PI_CHROME_VERSION = readPiChromeVersion();
|
|
58
|
+
const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__";
|
|
50
59
|
const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1";
|
|
51
60
|
const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318");
|
|
52
61
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -117,13 +126,14 @@ function readRequestBody(request: IncomingMessage): Promise<string> {
|
|
|
117
126
|
});
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
|
129
|
+
function sendJson(response: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {
|
|
121
130
|
response.writeHead(status, {
|
|
122
131
|
"content-type": "application/json; charset=utf-8",
|
|
123
132
|
"access-control-allow-origin": "*",
|
|
124
133
|
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
125
134
|
"access-control-allow-headers": "content-type",
|
|
126
135
|
"cache-control": "no-store",
|
|
136
|
+
...(extraHeaders ?? {}),
|
|
127
137
|
});
|
|
128
138
|
response.end(JSON.stringify(body));
|
|
129
139
|
}
|
|
@@ -315,7 +325,16 @@ class ChromeProfileBridge {
|
|
|
315
325
|
if (command) this.queue.unshift(command);
|
|
316
326
|
return;
|
|
317
327
|
}
|
|
318
|
-
|
|
328
|
+
// Re-read version on every /next so bumping package.json takes effect without pi restart.
|
|
329
|
+
const currentVersion = readPiChromeVersion();
|
|
330
|
+
sendJson(
|
|
331
|
+
response,
|
|
332
|
+
200,
|
|
333
|
+
command
|
|
334
|
+
? { type: "command", command, expectedExtensionVersion: currentVersion }
|
|
335
|
+
: { type: "none", expectedExtensionVersion: currentVersion },
|
|
336
|
+
{ "x-pi-chrome-version": currentVersion },
|
|
337
|
+
);
|
|
319
338
|
return;
|
|
320
339
|
}
|
|
321
340
|
if (request.method === "POST" && url.pathname === "/result") {
|
|
@@ -361,6 +380,18 @@ const imageFormatValues = ["png", "jpeg"] as const;
|
|
|
361
380
|
const waitForValues = ["selector", "expression"] as const;
|
|
362
381
|
|
|
363
382
|
export default function (pi: ExtensionAPI): void {
|
|
383
|
+
const globalState = globalThis as typeof globalThis & {
|
|
384
|
+
[PI_CHROME_GLOBAL_KEY]?: { version: string; root: string };
|
|
385
|
+
};
|
|
386
|
+
const alreadyLoaded = globalState[PI_CHROME_GLOBAL_KEY];
|
|
387
|
+
if (alreadyLoaded) {
|
|
388
|
+
console.warn(
|
|
389
|
+
`pi-chrome already loaded from ${alreadyLoaded.root} (v${alreadyLoaded.version}); skipping duplicate from ${extensionRoot()}.`,
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
globalState[PI_CHROME_GLOBAL_KEY] = { version: PI_CHROME_VERSION, root: extensionRoot() };
|
|
394
|
+
|
|
364
395
|
const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
|
|
365
396
|
let backgroundDefault = false;
|
|
366
397
|
|
|
@@ -425,6 +456,7 @@ Usage rules:
|
|
|
425
456
|
const status = bridge.status();
|
|
426
457
|
lines.push(`• Local bridge: mode=${status.mode}, url=${status.url}`);
|
|
427
458
|
let extensionAlive = false;
|
|
459
|
+
let versionMismatch = false;
|
|
428
460
|
try {
|
|
429
461
|
const started = Date.now();
|
|
430
462
|
const version = (await bridge.send("tab.version", {}, 35_000)) as {
|
|
@@ -434,11 +466,16 @@ Usage rules:
|
|
|
434
466
|
};
|
|
435
467
|
const latencyMs = Date.now() - started;
|
|
436
468
|
extensionAlive = true;
|
|
437
|
-
lines.push(`✓ Companion Chrome extension responding (ID: ${version.extensionId ?? "?"}, ext v${version.extensionVersion ?? "?"}, latency ${latencyMs}ms)`);
|
|
438
469
|
if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) {
|
|
470
|
+
versionMismatch = true;
|
|
439
471
|
lines.push(
|
|
440
|
-
|
|
472
|
+
`✗ EXTENSION VERSION MISMATCH: companion extension is v${version.extensionVersion}, but pi-chrome is v${PI_CHROME_VERSION}.`,
|
|
473
|
+
` All chrome_* tools will run with the OLD extension code until this is fixed.`,
|
|
474
|
+
` Fix: open chrome://extensions and click reload on "Pi Existing Chrome Profile Bridge".`,
|
|
475
|
+
` (Future version drifts will self-heal: the extension now polls pi-chrome's expected version and reloads itself.)`,
|
|
441
476
|
);
|
|
477
|
+
} else {
|
|
478
|
+
lines.push(`✓ Companion Chrome extension responding (ID: ${version.extensionId ?? "?"}, ext v${version.extensionVersion ?? "?"}, latency ${latencyMs}ms)`);
|
|
442
479
|
}
|
|
443
480
|
} catch (error) {
|
|
444
481
|
const message = (error as Error).message;
|
|
@@ -450,7 +487,7 @@ Usage rules:
|
|
|
450
487
|
}
|
|
451
488
|
}
|
|
452
489
|
|
|
453
|
-
if (extensionAlive) {
|
|
490
|
+
if (extensionAlive && !versionMismatch) {
|
|
454
491
|
// MAIN-world evaluate probe.
|
|
455
492
|
try {
|
|
456
493
|
const value = await bridge.send("page.evaluate", { expression: "1+1", awaitPromise: true, foreground: false }, 10_000);
|
|
@@ -468,28 +505,81 @@ Usage rules:
|
|
|
468
505
|
} catch (error) {
|
|
469
506
|
lines.push(`⚠ page.probe failed: ${(error as Error).message}`);
|
|
470
507
|
}
|
|
508
|
+
} else if (versionMismatch) {
|
|
509
|
+
lines.push(`… Skipped MAIN-world capability checks because the loaded extension is stale.`);
|
|
471
510
|
}
|
|
472
511
|
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
512
|
+
// Trusted-input (chrome.debugger) probe.
|
|
513
|
+
if (extensionAlive && !versionMismatch) {
|
|
514
|
+
try {
|
|
515
|
+
const status = (await bridge.send("trusted.status", {}, 5_000)) as {
|
|
516
|
+
mode?: string;
|
|
517
|
+
attachedTabs?: number[];
|
|
518
|
+
permissionGranted?: boolean;
|
|
519
|
+
};
|
|
520
|
+
if (status.permissionGranted) {
|
|
521
|
+
lines.push(`✓ Trusted-input mode available via chrome.debugger (current: ${status.mode ?? "off"}${status.attachedTabs && status.attachedTabs.length ? `; attached to tab ${status.attachedTabs.join(",")}` : ""}). Pass trusted=true on chrome_click/type/etc, or run /chrome-trusted on, to satisfy isTrusted + user-activation gates.`);
|
|
522
|
+
} else {
|
|
523
|
+
lines.push(`⚠ chrome.debugger API unavailable. The extension is missing the "debugger" permission — reload the extension in chrome://extensions and accept the new permission prompt.`);
|
|
524
|
+
}
|
|
525
|
+
} catch (error) {
|
|
526
|
+
lines.push(`⚠ trusted.status probe failed: ${(error as Error).message}`);
|
|
484
527
|
}
|
|
485
|
-
} catch {
|
|
486
|
-
lines.push(`• CDP probe inconclusive.`);
|
|
487
528
|
}
|
|
488
529
|
|
|
489
530
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
490
531
|
},
|
|
491
532
|
});
|
|
492
533
|
|
|
534
|
+
pi.registerCommand("chrome-trusted", {
|
|
535
|
+
description:
|
|
536
|
+
"Toggle trusted-input mode for chrome_* tools. ON: all clicks/types/etc go through chrome.debugger (CDP) so events are browser-trusted (isTrusted=true) and satisfy user-activation gates (clipboard, fullscreen, autoplay, file picker). Tradeoff: Chrome pins a yellow 'started debugging this browser' banner to the top of any tab in use. OFF (default): synthetic DOM events. AUTO: attach on demand only when a per-call trusted=true is passed. STATUS: print current mode and attached tabs.",
|
|
537
|
+
getArgumentCompletions: (prefix) => {
|
|
538
|
+
const items = [
|
|
539
|
+
{ value: "on", label: "on", description: "All chrome_* tools dispatch via CDP. Yellow debugger banner appears." },
|
|
540
|
+
{ value: "off", label: "off", description: "Synthetic events only (default)." },
|
|
541
|
+
{ value: "auto", label: "auto", description: "Use CDP only when a tool passes trusted=true." },
|
|
542
|
+
{ value: "status", label: "status", description: "Show current trusted mode and any attached tabs." },
|
|
543
|
+
];
|
|
544
|
+
const lowered = prefix.toLowerCase();
|
|
545
|
+
const matches = items.filter((item) => item.value.startsWith(lowered));
|
|
546
|
+
return matches.length > 0 ? matches : null;
|
|
547
|
+
},
|
|
548
|
+
handler: async (args, ctx) => {
|
|
549
|
+
const arg = (args || "").trim().toLowerCase();
|
|
550
|
+
if (arg === "status" || arg === "") {
|
|
551
|
+
try {
|
|
552
|
+
const status = (await bridge.send("trusted.status", {}, 5_000)) as { mode: string; attachedTabs: number[]; permissionGranted: boolean };
|
|
553
|
+
const attached = status.attachedTabs?.length ? ` (attached to tab ${status.attachedTabs.join(",")})` : "";
|
|
554
|
+
const perm = status.permissionGranted ? "" : " — chrome.debugger API unavailable; reload the extension and accept the new permission.";
|
|
555
|
+
ctx.ui.notify(`Trusted-input mode: ${status.mode}${attached}${perm}`, "info");
|
|
556
|
+
} catch (error) {
|
|
557
|
+
ctx.ui.notify(`Failed to read trusted mode: ${(error as Error).message}`, "warning");
|
|
558
|
+
}
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!["on", "off", "auto"].includes(arg)) {
|
|
562
|
+
ctx.ui.notify(`Unknown argument '${arg}'. Use: on | off | auto | status`, "warning");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const result = (await bridge.send("trusted.mode", { mode: arg }, 5_000)) as { mode: string };
|
|
567
|
+
if (result.mode === "on") {
|
|
568
|
+
ctx.ui.notify(
|
|
569
|
+
"Trusted-input mode ON. All chrome_* tools now dispatch through chrome.debugger (CDP). Chrome will show a yellow 'started debugging this browser' banner. Events arrive as isTrusted=true and satisfy user-activation gates.",
|
|
570
|
+
"info",
|
|
571
|
+
);
|
|
572
|
+
} else if (result.mode === "off") {
|
|
573
|
+
ctx.ui.notify("Trusted-input mode OFF. Synthetic events only. Any attached debugger sessions detached.", "info");
|
|
574
|
+
} else {
|
|
575
|
+
ctx.ui.notify("Trusted-input mode AUTO. CDP attaches only when a tool passes trusted=true.", "info");
|
|
576
|
+
}
|
|
577
|
+
} catch (error) {
|
|
578
|
+
ctx.ui.notify(`Failed to set trusted mode: ${(error as Error).message}`, "warning");
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
|
|
493
583
|
pi.registerCommand("chrome-background", {
|
|
494
584
|
description:
|
|
495
585
|
"Toggle silent/background mode for chrome_* tools. Background ON: chrome_* tools act silently — your editor/terminal keeps focus, Chrome does not pop up, your workflow is not interrupted. Background OFF (default): Chrome focuses and activates the target tab so you can watch the agent work, useful for demos, pair-driving, and debugging — tradeoff: Chrome pops up and steals focus. Pass `on` / `off` to set explicitly, or no argument to toggle.",
|
|
@@ -689,7 +779,7 @@ Usage rules:
|
|
|
689
779
|
name: "chrome_click",
|
|
690
780
|
label: "Chrome Click",
|
|
691
781
|
description:
|
|
692
|
-
"Click a snapshot uid, CSS selector, or viewport coordinate in an existing Chrome tab through the companion extension.
|
|
782
|
+
"Click a snapshot uid, CSS selector, or viewport coordinate in an existing Chrome tab through the companion extension. Defaults to synthetic DOM events (isTrusted=false). Pass trusted=true (or run /chrome-trusted on) to route through chrome.debugger so events arrive as browser-trusted and satisfy user-activation gates — Chrome shows a yellow 'started debugging' banner while attached. Pass includeSnapshot=true to return a fresh snapshot after the click.",
|
|
693
783
|
promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
|
|
694
784
|
parameters: Type.Object({
|
|
695
785
|
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
|
|
@@ -704,6 +794,7 @@ Usage rules:
|
|
|
704
794
|
background: Type.Optional(
|
|
705
795
|
Type.Boolean({ description: "If true, click silently without focusing Chrome. Default false." }),
|
|
706
796
|
),
|
|
797
|
+
trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the event is browser-trusted (isTrusted=true, user-activation satisfied). Triggers Chrome's 'started debugging this browser' banner." })),
|
|
707
798
|
host: Type.Optional(Type.String()),
|
|
708
799
|
port: Type.Optional(Type.Number()),
|
|
709
800
|
}),
|
|
@@ -721,7 +812,7 @@ Usage rules:
|
|
|
721
812
|
name: "chrome_type",
|
|
722
813
|
label: "Chrome Type",
|
|
723
814
|
description:
|
|
724
|
-
"Focus an optional snapshot uid or CSS selector, then type text into an existing Chrome tab
|
|
815
|
+
"Focus an optional snapshot uid or CSS selector, then type text into an existing Chrome tab. Defaults to synthetic per-character keydown/beforeinput/input/keyup sequence. Pass trusted=true (or run /chrome-trusted on) to route through chrome.debugger so each keystroke is browser-trusted (isTrusted=true). Pass includeSnapshot=true to return a fresh snapshot after typing.",
|
|
725
816
|
promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
|
|
726
817
|
parameters: Type.Object({
|
|
727
818
|
text: Type.String(),
|
|
@@ -736,6 +827,7 @@ Usage rules:
|
|
|
736
827
|
background: Type.Optional(
|
|
737
828
|
Type.Boolean({ description: "If true, type silently without focusing Chrome. Default false." }),
|
|
738
829
|
),
|
|
830
|
+
trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so each keystroke is browser-trusted. Triggers Chrome's debugger banner." })),
|
|
739
831
|
host: Type.Optional(Type.String()),
|
|
740
832
|
port: Type.Optional(Type.Number()),
|
|
741
833
|
}),
|
|
@@ -768,6 +860,7 @@ Usage rules:
|
|
|
768
860
|
background: Type.Optional(
|
|
769
861
|
Type.Boolean({ description: "If true, fill silently without focusing Chrome. Default false." }),
|
|
770
862
|
),
|
|
863
|
+
trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP for browser-trusted input. Triggers Chrome's debugger banner." })),
|
|
771
864
|
host: Type.Optional(Type.String()),
|
|
772
865
|
port: Type.Optional(Type.Number()),
|
|
773
866
|
}),
|
|
@@ -797,6 +890,7 @@ Usage rules:
|
|
|
797
890
|
background: Type.Optional(
|
|
798
891
|
Type.Boolean({ description: "If true, send the key silently without focusing Chrome. Default false." }),
|
|
799
892
|
),
|
|
893
|
+
trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the keystroke is browser-trusted." })),
|
|
800
894
|
host: Type.Optional(Type.String()),
|
|
801
895
|
port: Type.Optional(Type.Number()),
|
|
802
896
|
}),
|
|
@@ -967,6 +1061,7 @@ Usage rules:
|
|
|
967
1061
|
urlIncludes: Type.Optional(Type.String()),
|
|
968
1062
|
titleIncludes: Type.Optional(Type.String()),
|
|
969
1063
|
background: Type.Optional(Type.Boolean()),
|
|
1064
|
+
trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP for browser-trusted hover." })),
|
|
970
1065
|
}),
|
|
971
1066
|
async execute(_id, params): Promise<ToolTextResult> {
|
|
972
1067
|
const result = await bridge.send("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS);
|
|
@@ -977,7 +1072,7 @@ Usage rules:
|
|
|
977
1072
|
pi.registerTool({
|
|
978
1073
|
name: "chrome_drag",
|
|
979
1074
|
label: "Chrome Drag",
|
|
980
|
-
description: "Synthetic
|
|
1075
|
+
description: "Synthetic drag from one uid/selector/point to another. Dispatches pointerdown → humanised pointermove path → dragstart/drag/dragenter/dragover/dragleave/drop/dragend with a shared HTML5 DataTransfer, then pointerup. isTrusted=false.",
|
|
981
1076
|
promptSnippet: "Drag a Chrome element from one point to another.",
|
|
982
1077
|
parameters: Type.Object({
|
|
983
1078
|
fromUid: Type.Optional(Type.String()),
|
|
@@ -993,6 +1088,7 @@ Usage rules:
|
|
|
993
1088
|
urlIncludes: Type.Optional(Type.String()),
|
|
994
1089
|
titleIncludes: Type.Optional(Type.String()),
|
|
995
1090
|
background: Type.Optional(Type.Boolean()),
|
|
1091
|
+
trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the drag is browser-trusted (real HTML5 dragstart/drop with native DataTransfer)." })),
|
|
996
1092
|
}),
|
|
997
1093
|
async execute(_id, params): Promise<ToolTextResult> {
|
|
998
1094
|
const result = await bridge.send("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS);
|
|
@@ -1000,6 +1096,29 @@ Usage rules:
|
|
|
1000
1096
|
},
|
|
1001
1097
|
});
|
|
1002
1098
|
|
|
1099
|
+
pi.registerTool({
|
|
1100
|
+
name: "chrome_scroll",
|
|
1101
|
+
label: "Chrome Scroll",
|
|
1102
|
+
description: "Scroll the page or a specific scrollable element by dispatching real wheel events with momentum-shaped deltas, then applying the scroll. Positive deltaY scrolls down. Pass uid/selector to scroll within a container, otherwise the document scrolls.",
|
|
1103
|
+
promptSnippet: "Scroll a Chrome page or container via wheel events (not raw scrollTop).",
|
|
1104
|
+
parameters: Type.Object({
|
|
1105
|
+
uid: Type.Optional(Type.String()),
|
|
1106
|
+
selector: Type.Optional(Type.String()),
|
|
1107
|
+
deltaY: Type.Optional(Type.Number({ description: "Pixels to scroll vertically. Positive = down." })),
|
|
1108
|
+
deltaX: Type.Optional(Type.Number({ description: "Pixels to scroll horizontally. Positive = right." })),
|
|
1109
|
+
steps: Type.Optional(Type.Number({ description: "Number of wheel events to dispatch. Defaults to ceil(|deltaY|/100)." })),
|
|
1110
|
+
targetId: Type.Optional(Type.String()),
|
|
1111
|
+
urlIncludes: Type.Optional(Type.String()),
|
|
1112
|
+
titleIncludes: Type.Optional(Type.String()),
|
|
1113
|
+
background: Type.Optional(Type.Boolean()),
|
|
1114
|
+
trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch wheel events through chrome.debugger / CDP for browser-trusted scrolling." })),
|
|
1115
|
+
}),
|
|
1116
|
+
async execute(_id, params): Promise<ToolTextResult> {
|
|
1117
|
+
const result = await bridge.send("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
|
|
1118
|
+
return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1003
1122
|
pi.registerTool({
|
|
1004
1123
|
name: "chrome_upload_file",
|
|
1005
1124
|
label: "Chrome Upload File",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-chrome",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Drive your existing logged-in Chrome from Pi — no re-login, no throwaway profile, watch the agent work in real time (or toggle quiet background mode).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|