pi-chrome 0.15.37 → 0.15.39
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 +20 -0
- package/README.md +11 -0
- package/docs/FAQ.md +9 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +460 -92
- package/extensions/chrome-profile-bridge/index.ts +100 -16
- package/package.json +2 -2
- package/test-suite/unit/automation-target.test.mjs +387 -0
|
@@ -4,8 +4,170 @@ const POLL_ERROR_BACKOFF_MS = 2000;
|
|
|
4
4
|
const DEFAULT_GROUP_COLOR = "blue";
|
|
5
5
|
const PI_GROUP_RE = /^Pi(\b|\s*-)/i;
|
|
6
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;
|
|
7
11
|
let polling = false;
|
|
8
12
|
|
|
13
|
+
// =================== pi-chrome automation target ownership ===================
|
|
14
|
+
// pi-chrome must never hijack the user's active tab. When a page/navigation action runs without
|
|
15
|
+
// an explicit target (targetId/urlIncludes/titleIncludes), we route it to a dedicated automation
|
|
16
|
+
// target that pi-chrome created and owns. We prefer a separate Chrome window so the user's
|
|
17
|
+
// windows are left untouched; if the windows API is unavailable we fall back to a dedicated tab.
|
|
18
|
+
//
|
|
19
|
+
// Ownership is SESSION-SCOPED, keyed by the calling Pi session's `sessionKey` (forwarded on the
|
|
20
|
+
// wire). One Chrome extension / service worker brokers commands for *all* Pi sessions (see the
|
|
21
|
+
// client/server bridge in index.ts), so a single global target would make concurrent sessions
|
|
22
|
+
// fight over one window. A per-session map gives each session its own isolated window and lets
|
|
23
|
+
// cleanup close exactly that session's target — never another session's, never a user's.
|
|
24
|
+
//
|
|
25
|
+
// State is mirrored to chrome.storage.session so a service-worker restart (MV3 can suspend the
|
|
26
|
+
// worker at any time) re-hydrates ownership instead of orphaning the window it already created.
|
|
27
|
+
// storage.session is cleared on browser restart; any window restored by Chrome's session-restore
|
|
28
|
+
// is then untracked and simply left alone (we only ever close ids we still recognize as ours).
|
|
29
|
+
const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
|
|
30
|
+
const DEFAULT_SESSION_KEY = "__default__";
|
|
31
|
+
const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets";
|
|
32
|
+
let automationHydrated = false;
|
|
33
|
+
|
|
34
|
+
function sessionKeyOf(params) {
|
|
35
|
+
return params && typeof params.sessionKey === "string" && params.sessionKey
|
|
36
|
+
? params.sessionKey
|
|
37
|
+
: DEFAULT_SESSION_KEY;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Re-hydrate the in-memory ownership map from storage.session once per worker lifetime. Best
|
|
41
|
+
// effort: storage may be unavailable on old Chrome, and a failure just means we may create a
|
|
42
|
+
// fresh window (a harmless orphan) rather than reusing one.
|
|
43
|
+
async function hydrateAutomationTargets() {
|
|
44
|
+
if (automationHydrated) return;
|
|
45
|
+
automationHydrated = true;
|
|
46
|
+
try {
|
|
47
|
+
const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
|
|
48
|
+
const saved = stored && stored[AUTOMATION_STORAGE_KEY];
|
|
49
|
+
if (saved && typeof saved === "object") {
|
|
50
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
51
|
+
if (value && typeof value.tabId === "number") {
|
|
52
|
+
automationTargets.set(key, {
|
|
53
|
+
windowId: typeof value.windowId === "number" ? value.windowId : undefined,
|
|
54
|
+
tabId: value.tabId,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Ignore: treat as "no persisted state".
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function persistAutomationTargets() {
|
|
65
|
+
try {
|
|
66
|
+
const obj = {};
|
|
67
|
+
for (const [key, value] of automationTargets) {
|
|
68
|
+
obj[key] = { windowId: typeof value.windowId === "number" ? value.windowId : null, tabId: value.tabId };
|
|
69
|
+
}
|
|
70
|
+
await chrome.storage?.session?.set?.({ [AUTOMATION_STORAGE_KEY]: obj });
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore: persistence is an optimization, not a correctness requirement.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// True if `tabId` is a pi-chrome-owned automation tab. Pass `sessionKey` to check a specific
|
|
77
|
+
// session; omit it to check ownership across *any* session (used as a safety predicate so we
|
|
78
|
+
// never operate on a user-created tab). Never infers ownership from "active".
|
|
79
|
+
function isPiChromeOwnedTarget(tabId, sessionKey) {
|
|
80
|
+
if (typeof tabId !== "number") return false;
|
|
81
|
+
if (sessionKey !== undefined) {
|
|
82
|
+
const t = automationTargets.get(sessionKey);
|
|
83
|
+
return !!t && t.tabId === tabId;
|
|
84
|
+
}
|
|
85
|
+
for (const t of automationTargets.values()) if (t.tabId === tabId) return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create a fresh dedicated automation target for `sessionKey`. Prefer an isolated window; fall
|
|
90
|
+
// back to a tab. Structured so a window can be the default while tab-fallback stays a one-liner.
|
|
91
|
+
async function createAutomationTarget(sessionKey) {
|
|
92
|
+
if (chrome.windows && typeof chrome.windows.create === "function") {
|
|
93
|
+
try {
|
|
94
|
+
const win = await chrome.windows.create({ url: "about:blank", focused: false });
|
|
95
|
+
const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
|
|
96
|
+
if (created && typeof created.id === "number") {
|
|
97
|
+
automationTargets.set(sessionKey, { windowId: typeof win.id === "number" ? win.id : undefined, tabId: created.id });
|
|
98
|
+
await persistAutomationTargets();
|
|
99
|
+
return created;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Window creation can fail (policy, headless, etc.); fall back to a dedicated tab below.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Tab fallback: the tab lives in a pre-existing (user/shared) window we did NOT create, so we
|
|
106
|
+
// must leave windowId unset — cleanup then closes only our tab, never the user's window.
|
|
107
|
+
const tab = await chrome.tabs.create({ url: "about:blank", active: false });
|
|
108
|
+
automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined });
|
|
109
|
+
await persistAutomationTargets();
|
|
110
|
+
return tab;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Return the session's owned automation target if it still exists, else null. Robust to the user
|
|
114
|
+
// (or Chrome) having closed it: a stale entry is forgotten so callers can recreate cleanly.
|
|
115
|
+
async function resolveOwnedAutomationTarget(sessionKey) {
|
|
116
|
+
await hydrateAutomationTargets();
|
|
117
|
+
const t = automationTargets.get(sessionKey);
|
|
118
|
+
if (!t || typeof t.tabId !== "number") return null;
|
|
119
|
+
const existing = await chrome.tabs.get(t.tabId).catch(() => null);
|
|
120
|
+
if (existing && typeof existing.id === "number") return existing;
|
|
121
|
+
automationTargets.delete(sessionKey);
|
|
122
|
+
await persistAutomationTargets();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Return the session's dedicated automation target, creating it on first use (or after the user
|
|
127
|
+
// closed it). Used by page/navigation actions that need a live surface to drive.
|
|
128
|
+
async function getOrCreateAutomationTarget(sessionKey) {
|
|
129
|
+
return (await resolveOwnedAutomationTarget(sessionKey)) || createAutomationTarget(sessionKey);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Close only the session's pi-chrome-owned window/tab, and only if it still exists. Never touches
|
|
133
|
+
// user tabs/windows or other sessions' targets. Safe to call repeatedly and when nothing exists.
|
|
134
|
+
async function cleanupAutomationTarget(sessionKey) {
|
|
135
|
+
await hydrateAutomationTargets();
|
|
136
|
+
const t = automationTargets.get(sessionKey);
|
|
137
|
+
automationTargets.delete(sessionKey);
|
|
138
|
+
await persistAutomationTargets();
|
|
139
|
+
if (!t) return { closedWindowId: null, closedTabId: null };
|
|
140
|
+
const { windowId, tabId } = t;
|
|
141
|
+
if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") {
|
|
142
|
+
const win = await chrome.windows.get(windowId).catch(() => null);
|
|
143
|
+
if (win) {
|
|
144
|
+
await chrome.windows.remove(windowId).catch(() => {});
|
|
145
|
+
return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (typeof tabId === "number") {
|
|
149
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
150
|
+
if (tab) {
|
|
151
|
+
await chrome.tabs.remove(tabId).catch(() => {});
|
|
152
|
+
return { closedWindowId: null, closedTabId: tabId };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { closedWindowId: null, closedTabId: null };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function withTimeout(promise, ms, label, onTimeout) {
|
|
159
|
+
let timer;
|
|
160
|
+
return Promise.race([
|
|
161
|
+
Promise.resolve(promise).finally(() => clearTimeout(timer)),
|
|
162
|
+
new Promise((_, reject) => {
|
|
163
|
+
timer = setTimeout(async () => {
|
|
164
|
+
try { await onTimeout?.(); } catch {}
|
|
165
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
166
|
+
}, ms);
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
|
|
9
171
|
// =================== Chrome input (CDP) layer ===================
|
|
10
172
|
// Tracks which tabs we have attached chrome.debugger to.
|
|
11
173
|
const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
|
|
@@ -29,6 +191,31 @@ function recordAttachEvent(entry) {
|
|
|
29
191
|
if (attachDebugLog.length > 20) attachDebugLog.shift();
|
|
30
192
|
}
|
|
31
193
|
|
|
194
|
+
function normalPageTarget(target, tabId) {
|
|
195
|
+
const url = String(target?.url || "");
|
|
196
|
+
return target?.tabId === tabId && target?.type === "page" && !url.startsWith("chrome://") && !url.startsWith("chrome-extension://") && !url.startsWith("devtools://");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function pageDebuggeeForTab(tabId) {
|
|
200
|
+
const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))).catch(() => []);
|
|
201
|
+
const target = targets.find((t) => normalPageTarget(t, tabId));
|
|
202
|
+
return target?.id ? { targetId: target.id } : { tabId };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function debuggerAttachRaw(tabId, preferredDebuggee) {
|
|
206
|
+
const debuggee = preferredDebuggee || { tabId };
|
|
207
|
+
await withTimeout(
|
|
208
|
+
chrome.debugger.attach(debuggee, CDP_VERSION),
|
|
209
|
+
ATTACH_TIMEOUT_MS,
|
|
210
|
+
`Chrome debugger attach to tab ${tabId}`,
|
|
211
|
+
async () => {
|
|
212
|
+
attachedTabs.delete(tabId);
|
|
213
|
+
try { await chrome.debugger.detach(debuggee); } catch {}
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
return debuggee;
|
|
217
|
+
}
|
|
218
|
+
|
|
32
219
|
async function attachDebugger(tabId) {
|
|
33
220
|
if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
|
|
34
221
|
if (attachedTabs.has(tabId)) {
|
|
@@ -50,15 +237,23 @@ async function attachDebugger(tabId) {
|
|
|
50
237
|
}
|
|
51
238
|
}
|
|
52
239
|
} catch {}
|
|
53
|
-
|
|
240
|
+
let attachedDebuggee = null;
|
|
241
|
+
const attemptAttach = async (debuggee) => {
|
|
54
242
|
try {
|
|
55
|
-
await
|
|
243
|
+
attachedDebuggee = await debuggerAttachRaw(tabId, debuggee);
|
|
56
244
|
return null;
|
|
57
245
|
} catch (error) {
|
|
58
246
|
return error;
|
|
59
247
|
}
|
|
60
248
|
};
|
|
249
|
+
const retryPageTargetIfExtensionBlocked = async (err, kind) => {
|
|
250
|
+
if (!/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(String(err?.message || err))) return err;
|
|
251
|
+
const pageDebuggee = await pageDebuggeeForTab(tabId);
|
|
252
|
+
recordAttachEvent({ kind, tabId, debuggee: pageDebuggee });
|
|
253
|
+
return attemptAttach(pageDebuggee);
|
|
254
|
+
};
|
|
61
255
|
let err = await attemptAttach();
|
|
256
|
+
if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry");
|
|
62
257
|
if (err) {
|
|
63
258
|
const msg = String(err?.message || err);
|
|
64
259
|
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);
|
|
@@ -70,6 +265,7 @@ async function attachDebugger(tabId) {
|
|
|
70
265
|
}
|
|
71
266
|
await sleep(180);
|
|
72
267
|
err = await attemptAttach();
|
|
268
|
+
if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry2");
|
|
73
269
|
if (err) {
|
|
74
270
|
recordAttachEvent({ kind: "attach-retry-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
|
|
75
271
|
// One more try after a longer settle. Some Chrome builds need ~500ms after a navigation
|
|
@@ -77,37 +273,53 @@ async function attachDebugger(tabId) {
|
|
|
77
273
|
// will accept the target.
|
|
78
274
|
await sleep(500);
|
|
79
275
|
err = await attemptAttach();
|
|
276
|
+
if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry3");
|
|
80
277
|
if (err) {
|
|
81
278
|
recordAttachEvent({ kind: "attach-retry2-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
|
|
82
|
-
|
|
279
|
+
const meta = await describeInputTarget(tabId);
|
|
280
|
+
throw new Error(`Chrome debugger attach failed for tab ${tabId}: ${String(err.message || err)}${targetMetaSuffix(meta)}`);
|
|
83
281
|
}
|
|
84
282
|
}
|
|
85
283
|
}
|
|
86
|
-
recordAttachEvent({ kind: "attached", tabId });
|
|
284
|
+
recordAttachEvent({ kind: "attached", tabId, debuggee: attachedDebuggee });
|
|
87
285
|
// Seed pointer in a plausible "just left the address bar" location.
|
|
88
|
-
const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
|
|
286
|
+
const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 }, debuggee: attachedDebuggee || { tabId } };
|
|
89
287
|
attachedTabs.set(tabId, entry);
|
|
90
288
|
return entry;
|
|
91
289
|
}
|
|
92
290
|
|
|
93
|
-
async function
|
|
94
|
-
const tab =
|
|
291
|
+
async function describeInputTarget(tabId) {
|
|
292
|
+
const tab = await chrome.tabs.get(Number(tabId)).catch(() => null);
|
|
293
|
+
const active = (await chrome.tabs.query({ active: true, lastFocusedWindow: true }).catch(() => []))[0] || null;
|
|
95
294
|
let targets = [];
|
|
96
295
|
try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
|
|
296
|
+
return {
|
|
297
|
+
resolvedTab: tab ? { id: tab.id, windowId: tab.windowId, url: tab.url, status: tab.status, title: tab.title, active: tab.active } : null,
|
|
298
|
+
activeTab: active ? { id: active.id, windowId: active.windowId, url: active.url, status: active.status, title: active.title, active: active.active } : null,
|
|
299
|
+
attachedTabs: Array.from(attachedTabs.keys()),
|
|
300
|
+
cdpTargets: targets.map((t) => ({ id: t.id, tabId: t.tabId, type: t.type, url: t.url, attached: t.attached, extensionId: t.extensionId })),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function targetMetaSuffix(meta) {
|
|
305
|
+
return `\nTarget metadata: ${JSON.stringify(meta).slice(0, 4000)}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function inputDebug(params) {
|
|
309
|
+
const requested = params?.targetId ? await describeInputTarget(Number(params.targetId)) : await describeInputTarget(-1);
|
|
97
310
|
return {
|
|
98
311
|
extensionVersion: chrome.runtime.getManifest().version,
|
|
99
312
|
extensionId: chrome.runtime.id,
|
|
100
|
-
|
|
101
|
-
requestedTab: tab ? { id: tab.id, url: tab.url, status: tab.status, title: tab.title } : null,
|
|
102
|
-
cdpTargets: targets,
|
|
313
|
+
...requested,
|
|
103
314
|
recentAttachEvents: attachDebugLog.slice(),
|
|
104
315
|
};
|
|
105
316
|
}
|
|
106
317
|
|
|
107
318
|
async function detachDebugger(tabId) {
|
|
108
|
-
|
|
319
|
+
const entry = attachedTabs.get(tabId);
|
|
320
|
+
if (!entry) return;
|
|
109
321
|
attachedTabs.delete(tabId);
|
|
110
|
-
try { await chrome.debugger.detach({ tabId }); } catch {}
|
|
322
|
+
try { await chrome.debugger.detach(entry.debuggee || { tabId }); } catch {}
|
|
111
323
|
}
|
|
112
324
|
|
|
113
325
|
async function detachAll() {
|
|
@@ -134,14 +346,22 @@ setInterval(() => {
|
|
|
134
346
|
}, 5000);
|
|
135
347
|
|
|
136
348
|
function cdpRaw(tabId, method, params) {
|
|
137
|
-
|
|
138
|
-
|
|
349
|
+
const debuggee = attachedTabs.get(tabId)?.debuggee || { tabId };
|
|
350
|
+
return withTimeout(new Promise((resolve, reject) => {
|
|
351
|
+
chrome.debugger.sendCommand(debuggee, method, params || {}, (result) => {
|
|
139
352
|
if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
|
|
140
353
|
else resolve(result);
|
|
141
354
|
});
|
|
355
|
+
}), CDP_COMMAND_TIMEOUT_MS, `CDP ${method}`, async () => {
|
|
356
|
+
attachedTabs.delete(tabId);
|
|
357
|
+
try { await chrome.debugger.detach(debuggee); } catch {}
|
|
142
358
|
});
|
|
143
359
|
}
|
|
144
360
|
|
|
361
|
+
function executeScriptTimed(options, label) {
|
|
362
|
+
return withTimeout(chrome.scripting.executeScript(options), SCRIPTING_TIMEOUT_MS, label || "chrome.scripting.executeScript");
|
|
363
|
+
}
|
|
364
|
+
|
|
145
365
|
// Wraps cdpRaw with one auto-recover on detached/closed sessions:
|
|
146
366
|
// chrome.debugger.attach can stay cached in attachedTabs even after Chrome killed
|
|
147
367
|
// the session (tab nav, devtools opened/closed, etc). Recover by detaching the
|
|
@@ -214,8 +434,7 @@ async function cdp(tabId, method, params) {
|
|
|
214
434
|
}
|
|
215
435
|
if (!isStale) throw error;
|
|
216
436
|
attachedTabs.delete(tabId);
|
|
217
|
-
await
|
|
218
|
-
attachedTabs.set(tabId, { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } });
|
|
437
|
+
await attachDebugger(tabId).catch(() => undefined);
|
|
219
438
|
return cdpRaw(tabId, method, params);
|
|
220
439
|
}
|
|
221
440
|
}
|
|
@@ -254,14 +473,18 @@ function cdpIsSyntaxError(details) {
|
|
|
254
473
|
|
|
255
474
|
// Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
|
|
256
475
|
async function resolveTargetInTab(tabId, params) {
|
|
257
|
-
const results = await
|
|
476
|
+
const results = await executeScriptTimed({
|
|
258
477
|
target: { tabId, frameIds: [0] },
|
|
259
478
|
world: "MAIN",
|
|
260
479
|
func: (selector, uid, x, y) => {
|
|
261
480
|
const state = window.__PI_CHROME_STATE__;
|
|
262
481
|
let el = null;
|
|
263
|
-
if (uid
|
|
264
|
-
|
|
482
|
+
if (uid) {
|
|
483
|
+
el = state && state.elements ? state.elements[uid] : null;
|
|
484
|
+
if (!el || !el.isConnected) return { found: false, staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
|
|
485
|
+
} else if (selector) {
|
|
486
|
+
el = document.querySelector(selector);
|
|
487
|
+
}
|
|
265
488
|
if (el) {
|
|
266
489
|
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
267
490
|
const r = el.getBoundingClientRect();
|
|
@@ -271,8 +494,9 @@ async function resolveTargetInTab(tabId, params) {
|
|
|
271
494
|
return { found: false };
|
|
272
495
|
},
|
|
273
496
|
args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
|
|
274
|
-
});
|
|
497
|
+
}, `resolve input target in tab ${tabId}`);
|
|
275
498
|
const v = results?.[0]?.result;
|
|
499
|
+
if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
|
|
276
500
|
if (!v || !v.found) throw new Error("Could not resolve target element for Chrome input");
|
|
277
501
|
return v;
|
|
278
502
|
}
|
|
@@ -400,36 +624,70 @@ async function cdpTypeChar(tabId, ch) {
|
|
|
400
624
|
await sleep(rng(35, 130));
|
|
401
625
|
}
|
|
402
626
|
|
|
627
|
+
async function domClickFallback(tabId, params, cause) {
|
|
628
|
+
const results = await executeScriptTimed({
|
|
629
|
+
target: { tabId, frameIds: [0] },
|
|
630
|
+
world: "MAIN",
|
|
631
|
+
func: (selector, uid, x, y) => {
|
|
632
|
+
const state = window.__PI_CHROME_STATE__;
|
|
633
|
+
let el = uid && state && state.elements ? state.elements[uid] : null;
|
|
634
|
+
if (uid && (!el || !el.isConnected)) return { staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
|
|
635
|
+
if (!el && selector) el = document.querySelector(selector);
|
|
636
|
+
if (!el && typeof x === "number" && typeof y === "number") el = document.elementFromPoint(x, y);
|
|
637
|
+
if (!el) throw new Error(`DOM fallback target not found: ${uid || selector || `${x},${y}`}`);
|
|
638
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
639
|
+
const rect = el.getBoundingClientRect();
|
|
640
|
+
const eventInit = { bubbles: true, cancelable: true, view: window, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, button: 0, buttons: 1 };
|
|
641
|
+
el.dispatchEvent(new PointerEvent("pointerdown", { ...eventInit, pointerId: 1, pointerType: "mouse", isPrimary: true }));
|
|
642
|
+
el.dispatchEvent(new MouseEvent("mousedown", eventInit));
|
|
643
|
+
if (typeof el.focus === "function") el.focus({ preventScroll: true });
|
|
644
|
+
el.dispatchEvent(new PointerEvent("pointerup", { ...eventInit, pointerId: 1, pointerType: "mouse", isPrimary: true, buttons: 0 }));
|
|
645
|
+
el.dispatchEvent(new MouseEvent("mouseup", { ...eventInit, buttons: 0 }));
|
|
646
|
+
el.click();
|
|
647
|
+
return { tag: el.tagName, url: location.href };
|
|
648
|
+
},
|
|
649
|
+
args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
|
|
650
|
+
}, `DOM click fallback in tab ${tabId}`);
|
|
651
|
+
const v = results?.[0]?.result;
|
|
652
|
+
if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
|
|
653
|
+
return { input: "dom-fallback", reason: String(cause?.message || cause).slice(0, 500), tag: v?.tag };
|
|
654
|
+
}
|
|
655
|
+
|
|
403
656
|
async function chromeInputClick(params) {
|
|
404
657
|
const tab = await getTabByParams(params);
|
|
405
658
|
if (params.foreground) await bringToFront(tab);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
659
|
+
try {
|
|
660
|
+
await attachDebugger(tab.id);
|
|
661
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
662
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
663
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
664
|
+
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 });
|
|
665
|
+
await sleep(rng(45, 140));
|
|
666
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
667
|
+
// Reset :focus-visible if the click landed on a focusable element. CDP-driven pointer
|
|
668
|
+
// focus can leave :focus-visible=true in Chromium, which trips heuristics that expect
|
|
669
|
+
// Reset focus styling after pointer click when possible.
|
|
670
|
+
if (params.selector || params.uid) {
|
|
671
|
+
await executeScriptTimed({
|
|
672
|
+
target: { tabId: tab.id, frameIds: [0] },
|
|
673
|
+
world: "MAIN",
|
|
674
|
+
func: (sel, uid) => {
|
|
675
|
+
const state = window.__PI_CHROME_STATE__;
|
|
676
|
+
let el = null;
|
|
677
|
+
if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid];
|
|
678
|
+
else if (sel) el = document.querySelector(sel);
|
|
679
|
+
if (el && typeof el.focus === "function" && el === document.activeElement) {
|
|
680
|
+
try { el.blur(); el.focus({ preventScroll: true, focusVisible: false }); } catch {}
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
args: [params.selector ?? null, params.uid ?? null],
|
|
684
|
+
}, `reset focus style in tab ${tab.id}`).catch(() => undefined);
|
|
685
|
+
}
|
|
686
|
+
return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if (params.domFallback === false) throw error;
|
|
689
|
+
return domClickFallback(tab.id, params, error);
|
|
431
690
|
}
|
|
432
|
-
return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
|
|
433
691
|
}
|
|
434
692
|
|
|
435
693
|
async function chromeInputHover(params) {
|
|
@@ -504,29 +762,74 @@ async function chromeInputType(params) {
|
|
|
504
762
|
return { input: "chrome", length: text.length };
|
|
505
763
|
}
|
|
506
764
|
|
|
765
|
+
async function domFillFallback(tabId, params, cause) {
|
|
766
|
+
if (!(params.selector || params.uid)) throw cause;
|
|
767
|
+
const results = await executeScriptTimed({
|
|
768
|
+
target: { tabId, frameIds: [0] },
|
|
769
|
+
world: "MAIN",
|
|
770
|
+
func: async (selector, uid, text, submit) => {
|
|
771
|
+
const state = window.__PI_CHROME_STATE__;
|
|
772
|
+
let el = uid && state && state.elements ? state.elements[uid] : null;
|
|
773
|
+
if (uid && (!el || !el.isConnected)) return { staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
|
|
774
|
+
if (!el && selector) el = document.querySelector(selector);
|
|
775
|
+
if (!el) throw new Error(`DOM fallback target not found: ${uid || selector}`);
|
|
776
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
777
|
+
if (typeof el.focus === "function") el.focus({ preventScroll: true });
|
|
778
|
+
const value = String(text ?? "");
|
|
779
|
+
if ("value" in el) {
|
|
780
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
781
|
+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
|
782
|
+
if (setter) setter.call(el, value);
|
|
783
|
+
else el.value = value;
|
|
784
|
+
} else if (el.isContentEditable) {
|
|
785
|
+
el.textContent = value;
|
|
786
|
+
} else {
|
|
787
|
+
throw new Error(`DOM fallback target is not fillable: <${el.tagName.toLowerCase()}>`);
|
|
788
|
+
}
|
|
789
|
+
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
790
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
791
|
+
if (submit) {
|
|
792
|
+
const form = el.closest("form");
|
|
793
|
+
if (form) form.requestSubmit ? form.requestSubmit() : form.submit();
|
|
794
|
+
else document.querySelector("button,[type=submit]")?.click();
|
|
795
|
+
}
|
|
796
|
+
return { valueMatches: "value" in el ? el.value === value : el.textContent === value, tag: el.tagName, url: location.href };
|
|
797
|
+
},
|
|
798
|
+
args: [params.selector ?? null, params.uid ?? null, params.text ?? "", params.submit === true],
|
|
799
|
+
}, `DOM fill fallback in tab ${tabId}`);
|
|
800
|
+
const v = results?.[0]?.result;
|
|
801
|
+
if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
|
|
802
|
+
return { input: "dom-fallback", length: String(params.text || "").length, valueMatches: v?.valueMatches, reason: String(cause?.message || cause).slice(0, 500), tag: v?.tag };
|
|
803
|
+
}
|
|
804
|
+
|
|
507
805
|
async function chromeInputFill(params) {
|
|
508
806
|
const tab = await getTabByParams(params);
|
|
509
807
|
if (params.foreground) await bringToFront(tab);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
808
|
+
try {
|
|
809
|
+
await attachDebugger(tab.id);
|
|
810
|
+
if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
|
|
811
|
+
const resolved = await resolveTargetInTab(tab.id, params);
|
|
812
|
+
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
813
|
+
await cdpMoveTo(tab.id, point.x, point.y);
|
|
814
|
+
// Triple-click selects all in input fields.
|
|
815
|
+
for (let i = 1; i <= 3; i++) {
|
|
816
|
+
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 });
|
|
817
|
+
await sleep(rng(20, 60));
|
|
818
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
|
|
819
|
+
await sleep(rng(20, 60));
|
|
820
|
+
}
|
|
821
|
+
// Delete selection.
|
|
822
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
823
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
520
824
|
await sleep(rng(20, 60));
|
|
825
|
+
const text = String(params.text || "");
|
|
826
|
+
for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
|
|
827
|
+
if (params.submit) await chromeInputKey({ ...params, key: "Enter" });
|
|
828
|
+
return { input: "chrome", length: text.length };
|
|
829
|
+
} catch (error) {
|
|
830
|
+
if (params.domFallback === false) throw error;
|
|
831
|
+
return domFillFallback(tab.id, params, error);
|
|
521
832
|
}
|
|
522
|
-
// Delete selection.
|
|
523
|
-
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
524
|
-
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
525
|
-
await sleep(rng(20, 60));
|
|
526
|
-
const text = String(params.text || "");
|
|
527
|
-
for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
|
|
528
|
-
if (params.submit) await chromeInputKey({ ...params, key: "Enter" });
|
|
529
|
-
return { input: "chrome", length: text.length };
|
|
530
833
|
}
|
|
531
834
|
|
|
532
835
|
async function chromeInputScroll(params) {
|
|
@@ -714,7 +1017,12 @@ async function pollLoop() {
|
|
|
714
1017
|
|
|
715
1018
|
async function handleCommand(command) {
|
|
716
1019
|
try {
|
|
717
|
-
const result = await
|
|
1020
|
+
const result = await withTimeout(
|
|
1021
|
+
dispatch(command.action, command.params ?? {}),
|
|
1022
|
+
COMMAND_TIMEOUT_MS,
|
|
1023
|
+
command.action || "Chrome command",
|
|
1024
|
+
() => detachAll(),
|
|
1025
|
+
);
|
|
718
1026
|
await postResult({ id: command.id, ok: true, result });
|
|
719
1027
|
} catch (error) {
|
|
720
1028
|
await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) });
|
|
@@ -765,8 +1073,10 @@ async function groupRecord(groupId) {
|
|
|
765
1073
|
};
|
|
766
1074
|
}
|
|
767
1075
|
|
|
768
|
-
// Find
|
|
769
|
-
//
|
|
1076
|
+
// Find existing tab groups whose title matches `title` (case-insensitive).
|
|
1077
|
+
// Same-window lookup is used when grouping an already-created tab. Any-window lookup is used before
|
|
1078
|
+
// creating a new Pi tab so one Pi session keeps one tab group and new tabs are created in that
|
|
1079
|
+
// group's window (Chrome tab groups cannot span windows).
|
|
770
1080
|
async function findGroupByTitle(windowId, title) {
|
|
771
1081
|
if (!chrome.tabGroups) return null;
|
|
772
1082
|
const wanted = cleanGroupTitle(title).toLowerCase();
|
|
@@ -775,6 +1085,13 @@ async function findGroupByTitle(windowId, title) {
|
|
|
775
1085
|
return match ? match.id : null;
|
|
776
1086
|
}
|
|
777
1087
|
|
|
1088
|
+
async function findGroupRecordByTitle(title) {
|
|
1089
|
+
if (!chrome.tabGroups) return null;
|
|
1090
|
+
const wanted = cleanGroupTitle(title).toLowerCase();
|
|
1091
|
+
const groups = await chrome.tabGroups.query({}).catch(() => []);
|
|
1092
|
+
return groups.find((g) => (g.title || "").trim().toLowerCase() === wanted) || null;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
778
1095
|
// Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an
|
|
779
1096
|
// existing same-title group in its window when present, otherwise create a new group.
|
|
780
1097
|
async function groupTab(tab, title, color) {
|
|
@@ -807,28 +1124,40 @@ async function dispatch(action, params) {
|
|
|
807
1124
|
return Promise.all(tabs.map(formatTab));
|
|
808
1125
|
}
|
|
809
1126
|
case "tab.new": {
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1127
|
+
// Every Pi-opened tab must join a tab group. There is intentionally no opt-out: an ungrouped
|
|
1128
|
+
// Pi-created tab is easy to lose among user tabs. If grouping fails after creation, close the
|
|
1129
|
+
// tab best-effort before surfacing the error so tab.new never leaves an ungrouped Pi tab.
|
|
1130
|
+
const groupTitle = params.groupTitle || "Pi";
|
|
1131
|
+
const existingGroup = await findGroupRecordByTitle(groupTitle);
|
|
1132
|
+
const createParams = { url: params.url || "about:blank", active: true };
|
|
1133
|
+
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
|
|
1134
|
+
const tab = await chrome.tabs.create(createParams);
|
|
1135
|
+
try {
|
|
1136
|
+
return await groupTab(tab, groupTitle, params.groupColor);
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
815
1141
|
}
|
|
816
1142
|
case "tab.activate": {
|
|
817
|
-
|
|
1143
|
+
// Management actions never auto-create an automation target (createOwnedTarget:false): with
|
|
1144
|
+
// no explicit target they act on an owned target if one exists, else error — they must never
|
|
1145
|
+
// fall back to (or spawn a tab just to touch) the user's active tab.
|
|
1146
|
+
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
818
1147
|
await chrome.windows.update(tab.windowId, { focused: true });
|
|
819
1148
|
return formatTab(await chrome.tabs.update(tab.id, { active: true }));
|
|
820
1149
|
}
|
|
821
1150
|
case "tab.group": {
|
|
822
|
-
const tab = await getTabByParams(params);
|
|
1151
|
+
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
823
1152
|
return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
|
|
824
1153
|
}
|
|
825
1154
|
case "tab.ungroup": {
|
|
826
|
-
const tab = await getTabByParams(params);
|
|
1155
|
+
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
827
1156
|
if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id);
|
|
828
1157
|
return formatTab(await chrome.tabs.get(tab.id));
|
|
829
1158
|
}
|
|
830
1159
|
case "tab.close": {
|
|
831
|
-
const tab = await getTabByParams(params);
|
|
1160
|
+
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
832
1161
|
await chrome.tabs.remove(tab.id);
|
|
833
1162
|
return { closed: tab.id };
|
|
834
1163
|
}
|
|
@@ -910,6 +1239,16 @@ async function dispatch(action, params) {
|
|
|
910
1239
|
}
|
|
911
1240
|
case "page.screenshot":
|
|
912
1241
|
return takeScreenshot(params);
|
|
1242
|
+
case "automation.status": {
|
|
1243
|
+
// Report this session's owned automation target (ids only). Used for diagnostics/tests.
|
|
1244
|
+
await hydrateAutomationTargets();
|
|
1245
|
+
const t = automationTargets.get(sessionKeyOf(params));
|
|
1246
|
+
return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
|
|
1247
|
+
}
|
|
1248
|
+
case "automation.cleanup":
|
|
1249
|
+
// Close only THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or
|
|
1250
|
+
// another Pi session's target.
|
|
1251
|
+
return cleanupAutomationTarget(sessionKeyOf(params));
|
|
913
1252
|
default:
|
|
914
1253
|
throw new Error(`Unknown action: ${action}`);
|
|
915
1254
|
}
|
|
@@ -931,12 +1270,28 @@ async function formatTab(tab) {
|
|
|
931
1270
|
};
|
|
932
1271
|
}
|
|
933
1272
|
|
|
934
|
-
|
|
1273
|
+
// Resolve which Chrome tab an action targets.
|
|
1274
|
+
//
|
|
1275
|
+
// Explicit targeting (targetId / urlIncludes / titleIncludes) is unchanged: callers can still act
|
|
1276
|
+
// on any existing tab, including a user tab, when they ask for it by name. Only the implicit
|
|
1277
|
+
// "no target given" case changed — it used to grab the user's *active* tab (and page.navigate
|
|
1278
|
+
// would then overwrite it); it now resolves to this Pi session's dedicated automation target.
|
|
1279
|
+
//
|
|
1280
|
+
// `createOwnedTarget` controls the implicit case:
|
|
1281
|
+
// - true (default): create the automation target on first use. Used by every page/content
|
|
1282
|
+
// action — page.navigate, click/type/fill/key/hover/drag/scroll/tap/upload, snapshot,
|
|
1283
|
+
// inspect, evaluate, screenshot, waitFor, console/network list, probe. These need a live
|
|
1284
|
+
// surface to drive, so auto-creating is correct and they no longer touch the user's tab.
|
|
1285
|
+
// - false: do NOT create. Used by tab.activate/close/group/ungroup (tab *management*): with no
|
|
1286
|
+
// explicit target they operate on an already-owned automation target if one exists, else
|
|
1287
|
+
// throw asking for an explicit target — so e.g. `chrome_tab close` can never silently close
|
|
1288
|
+
// the user's active tab the way it used to, and never spawns a throwaway tab just to close it.
|
|
1289
|
+
async function getTabByParams(params, { createOwnedTarget = true } = {}) {
|
|
935
1290
|
const tabs = await chrome.tabs.query({});
|
|
936
1291
|
let tab;
|
|
937
1292
|
if (params.targetId !== undefined) {
|
|
938
1293
|
const id = Number(params.targetId);
|
|
939
|
-
tab = tabs.
|
|
1294
|
+
tab = await chrome.tabs.get(id).catch(() => null);
|
|
940
1295
|
if (!tab?.id) {
|
|
941
1296
|
// Chrome tab ids are not stable across reloads/navigations; a long session can hold a
|
|
942
1297
|
// stale id. Surface the current tabs so the caller can re-target instead of guessing.
|
|
@@ -956,12 +1311,25 @@ async function getTabByParams(params) {
|
|
|
956
1311
|
} else if (params.titleIncludes) {
|
|
957
1312
|
tab = tabs.find((candidate) => (candidate.title || "").includes(params.titleIncludes));
|
|
958
1313
|
} else {
|
|
959
|
-
|
|
960
|
-
|
|
1314
|
+
// No explicit target: use this session's dedicated automation target instead of hijacking the
|
|
1315
|
+
// user's active tab. This keeps human browsing and Pi automation separated — navigating here
|
|
1316
|
+
// never replaces whatever the user currently has open. Callers that *want* a specific
|
|
1317
|
+
// existing tab pass targetId/urlIncludes/titleIncludes above.
|
|
1318
|
+
const sessionKey = sessionKeyOf(params);
|
|
1319
|
+
tab = createOwnedTarget
|
|
1320
|
+
? await getOrCreateAutomationTarget(sessionKey)
|
|
1321
|
+
: await resolveOwnedAutomationTarget(sessionKey);
|
|
1322
|
+
if (!tab) {
|
|
1323
|
+
throw new Error(
|
|
1324
|
+
"No target tab specified and this Pi session has no automation tab yet. " +
|
|
1325
|
+
"Pass targetId/urlIncludes/titleIncludes, or run chrome_navigate first.",
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
961
1328
|
}
|
|
962
1329
|
if (!tab?.id) throw new Error("No matching Chrome tab found");
|
|
963
|
-
|
|
964
|
-
|
|
1330
|
+
const url = tab.url || "";
|
|
1331
|
+
if (url.startsWith("chrome://") || url.startsWith("chrome-extension://") || url.startsWith("devtools://")) {
|
|
1332
|
+
throw new Error(`Chrome blocks extension automation on protected URL: tab=${tab.id} url=${url}`);
|
|
965
1333
|
}
|
|
966
1334
|
// Tabs Pi interacts with (page.* actions) join this session's group so the user can see exactly
|
|
967
1335
|
// which tabs Pi is driving. We only adopt *ungrouped* tabs — never hijack a tab the user (or
|
|
@@ -1032,7 +1400,7 @@ async function executeInTab(params, func, args) {
|
|
|
1032
1400
|
// Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
|
|
1033
1401
|
// injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
|
|
1034
1402
|
// serialize the invocation args. The wrapper references window.__piAction defined above.
|
|
1035
|
-
const results = await
|
|
1403
|
+
const results = await executeScriptTimed({
|
|
1036
1404
|
target: { tabId: tab.id },
|
|
1037
1405
|
world: "MAIN",
|
|
1038
1406
|
func: async (invocationArgs) => {
|
|
@@ -1043,7 +1411,7 @@ async function executeInTab(params, func, args) {
|
|
|
1043
1411
|
}
|
|
1044
1412
|
},
|
|
1045
1413
|
args: [args || []],
|
|
1046
|
-
});
|
|
1414
|
+
}, `execute page action in tab ${tab.id}`);
|
|
1047
1415
|
const first = results?.[0];
|
|
1048
1416
|
if (first?.error) {
|
|
1049
1417
|
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|
|
@@ -1139,12 +1507,12 @@ async function snapshotInTab(params) {
|
|
|
1139
1507
|
params.query ?? null,
|
|
1140
1508
|
params.maxTextChars ?? null,
|
|
1141
1509
|
];
|
|
1142
|
-
await
|
|
1510
|
+
await executeScriptTimed({
|
|
1143
1511
|
target: { tabId: tab.id, frameIds: [0] },
|
|
1144
1512
|
world: "MAIN",
|
|
1145
1513
|
files: ["snapshot_injected.js"],
|
|
1146
|
-
});
|
|
1147
|
-
const results = await
|
|
1514
|
+
}, `inject snapshot script in tab ${tab.id}`);
|
|
1515
|
+
const results = await executeScriptTimed({
|
|
1148
1516
|
target: { tabId: tab.id, frameIds: [0] },
|
|
1149
1517
|
world: "MAIN",
|
|
1150
1518
|
func: async (invocationArgs) => {
|
|
@@ -1157,7 +1525,7 @@ async function snapshotInTab(params) {
|
|
|
1157
1525
|
}
|
|
1158
1526
|
},
|
|
1159
1527
|
args: [args],
|
|
1160
|
-
});
|
|
1528
|
+
}, `run snapshot script in tab ${tab.id}`);
|
|
1161
1529
|
const first = results?.[0];
|
|
1162
1530
|
if (first?.error) {
|
|
1163
1531
|
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|
|
@@ -1175,12 +1543,12 @@ async function inspectInTab(params) {
|
|
|
1175
1543
|
const tab = await getTabByParams(params);
|
|
1176
1544
|
if (params.foreground) await bringToFront(tab);
|
|
1177
1545
|
const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true];
|
|
1178
|
-
await
|
|
1546
|
+
await executeScriptTimed({
|
|
1179
1547
|
target: { tabId: tab.id, frameIds: [0] },
|
|
1180
1548
|
world: "MAIN",
|
|
1181
1549
|
files: ["snapshot_injected.js"],
|
|
1182
|
-
});
|
|
1183
|
-
const results = await
|
|
1550
|
+
}, `inject inspect script in tab ${tab.id}`);
|
|
1551
|
+
const results = await executeScriptTimed({
|
|
1184
1552
|
target: { tabId: tab.id, frameIds: [0] },
|
|
1185
1553
|
world: "MAIN",
|
|
1186
1554
|
func: async (invocationArgs) => {
|
|
@@ -1193,7 +1561,7 @@ async function inspectInTab(params) {
|
|
|
1193
1561
|
}
|
|
1194
1562
|
},
|
|
1195
1563
|
args: [args],
|
|
1196
|
-
});
|
|
1564
|
+
}, `run inspect script in tab ${tab.id}`);
|
|
1197
1565
|
const first = results?.[0];
|
|
1198
1566
|
if (first?.error) {
|
|
1199
1567
|
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|