pi-chrome 0.15.46 → 0.15.48
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 +14 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +9 -1
- package/SECURITY.md +1 -1
- package/docs/ARCHITECTURE.md +20 -5
- package/docs/COMPARISON.md +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +202 -102
- package/extensions/chrome-profile-bridge/index.ts +115 -119
- package/package.json +2 -2
- package/test-suite/README.md +9 -0
- package/test-suite/challenges/43-hard-background.html +42 -0
- package/test-suite/manifest.json +75 -0
- package/test-suite/unit/automation-target.test.mjs +109 -2
- package/test-suite/unit/background-policy.test.mjs +469 -0
- package/test-suite/unit/session-cleanup.test.mjs +93 -0
|
@@ -29,7 +29,11 @@ let polling = false;
|
|
|
29
29
|
const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
|
|
30
30
|
const DEFAULT_SESSION_KEY = "__default__";
|
|
31
31
|
const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets";
|
|
32
|
-
let automationHydrated
|
|
32
|
+
let automationHydrated;
|
|
33
|
+
const sessionTabs = new Map(); // sessionKey -> Map<tabId, { created: boolean, groupId?: number }>
|
|
34
|
+
const SESSION_TABS_STORAGE_KEY = "piChromeSessionTabs";
|
|
35
|
+
let sessionTabsReady;
|
|
36
|
+
let sessionTabsWrite = Promise.resolve();
|
|
33
37
|
|
|
34
38
|
function sessionKeyOf(params) {
|
|
35
39
|
return params && typeof params.sessionKey === "string" && params.sessionKey
|
|
@@ -41,24 +45,99 @@ function sessionKeyOf(params) {
|
|
|
41
45
|
// effort: storage may be unavailable on old Chrome, and a failure just means we may create a
|
|
42
46
|
// fresh window (a harmless orphan) rather than reusing one.
|
|
43
47
|
async function hydrateAutomationTargets() {
|
|
44
|
-
if (automationHydrated) return;
|
|
45
|
-
automationHydrated =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
if (automationHydrated) return automationHydrated;
|
|
49
|
+
automationHydrated = (async () => {
|
|
50
|
+
try {
|
|
51
|
+
const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
|
|
52
|
+
const saved = stored && stored[AUTOMATION_STORAGE_KEY];
|
|
53
|
+
if (saved && typeof saved === "object") {
|
|
54
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
55
|
+
if (value && typeof value.tabId === "number") {
|
|
56
|
+
automationTargets.set(key, {
|
|
57
|
+
windowId: typeof value.windowId === "number" ? value.windowId : undefined,
|
|
58
|
+
tabId: value.tabId,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
56
61
|
}
|
|
57
62
|
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Ignore: treat as "no persisted state".
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
return automationHydrated;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function hydrateSessionTabs() {
|
|
71
|
+
if (!sessionTabsReady) sessionTabsReady = (async () => {
|
|
72
|
+
try {
|
|
73
|
+
const stored = await chrome.storage?.session?.get?.(SESSION_TABS_STORAGE_KEY);
|
|
74
|
+
for (const [key, entries] of Object.entries(stored?.[SESSION_TABS_STORAGE_KEY] || {})) {
|
|
75
|
+
if (!Array.isArray(entries)) continue;
|
|
76
|
+
const tabs = new Map();
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!entry || !Number.isInteger(entry.tabId) || entry.tabId < 0) continue;
|
|
79
|
+
if (entry.created === true) tabs.set(entry.tabId, { created: true });
|
|
80
|
+
else if (entry.created === false && Number.isInteger(entry.groupId) && entry.groupId >= 0) {
|
|
81
|
+
tabs.set(entry.tabId, { created: false, groupId: entry.groupId });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (tabs.size) sessionTabs.set(key, tabs);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Missing ownership must leave tabs alone, not guess ownership from group names.
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
return sessionTabsReady;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function persistSessionTabs() {
|
|
94
|
+
// Serialize writes and construct each snapshot when its turn starts.
|
|
95
|
+
sessionTabsWrite = sessionTabsWrite.then(async () => {
|
|
96
|
+
const saved = Object.fromEntries([...sessionTabs].map(([key, tabs]) => [
|
|
97
|
+
key, [...tabs].map(([tabId, record]) => ({ tabId, ...record })),
|
|
98
|
+
]));
|
|
99
|
+
await chrome.storage?.session?.set?.({ [SESSION_TABS_STORAGE_KEY]: saved });
|
|
100
|
+
}).catch(() => {});
|
|
101
|
+
return sessionTabsWrite;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function trackSessionTab(sessionKey, tabId, created, groupId) {
|
|
105
|
+
await Promise.all([hydrateSessionTabs(), hydrateAutomationTargets()]);
|
|
106
|
+
if (!Number.isInteger(tabId)) return;
|
|
107
|
+
if (!created) {
|
|
108
|
+
if (!Number.isInteger(groupId) || groupId < 0 || isPiChromeOwnedTarget(tabId)) return;
|
|
109
|
+
if ([...sessionTabs.values()].some((tabs) => tabs.get(tabId)?.created)) return;
|
|
110
|
+
}
|
|
111
|
+
let tabs = sessionTabs.get(sessionKey);
|
|
112
|
+
if (!tabs) sessionTabs.set(sessionKey, tabs = new Map());
|
|
113
|
+
tabs.set(tabId, created ? { created: true } : { created: false, groupId });
|
|
114
|
+
await persistSessionTabs();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function cleanupSessionTabs(sessionKey) {
|
|
118
|
+
await hydrateSessionTabs();
|
|
119
|
+
const automation = await cleanupAutomationTarget(sessionKey);
|
|
120
|
+
const tabs = sessionTabs.get(sessionKey);
|
|
121
|
+
let closedCreatedTabs = 0;
|
|
122
|
+
let ungroupedAdoptedTabs = 0;
|
|
123
|
+
for (const [tabId, record] of [...(tabs || [])]) {
|
|
124
|
+
try {
|
|
125
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
126
|
+
if (tab && record.created) {
|
|
127
|
+
await chrome.tabs.remove(tabId);
|
|
128
|
+
closedCreatedTabs++;
|
|
129
|
+
} else if (tab && tab.groupId === record.groupId) {
|
|
130
|
+
await chrome.tabs.ungroup(tabId);
|
|
131
|
+
ungroupedAdoptedTabs++;
|
|
132
|
+
}
|
|
133
|
+
tabs.delete(tabId);
|
|
134
|
+
} catch {
|
|
135
|
+
// Retain failed operations for a later cleanup; never report them as completed.
|
|
58
136
|
}
|
|
59
|
-
} catch {
|
|
60
|
-
// Ignore: treat as "no persisted state".
|
|
61
137
|
}
|
|
138
|
+
if (tabs && !tabs.size) sessionTabs.delete(sessionKey);
|
|
139
|
+
await persistSessionTabs();
|
|
140
|
+
return { ...automation, closedCreatedTabs, ungroupedAdoptedTabs };
|
|
62
141
|
}
|
|
63
142
|
|
|
64
143
|
async function persistAutomationTargets() {
|
|
@@ -144,25 +223,26 @@ async function getOrCreateAutomationTarget(sessionKey, groupTitle) {
|
|
|
144
223
|
async function cleanupAutomationTarget(sessionKey) {
|
|
145
224
|
await hydrateAutomationTargets();
|
|
146
225
|
const t = automationTargets.get(sessionKey);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
await chrome.
|
|
155
|
-
|
|
226
|
+
const result = { closedWindowId: null, closedTabId: null };
|
|
227
|
+
if (!t) return result;
|
|
228
|
+
const tab = await chrome.tabs.get(t.tabId).catch(() => null);
|
|
229
|
+
if (tab) {
|
|
230
|
+
try {
|
|
231
|
+
// Never remove a whole window: users/other sessions can add tabs even between a
|
|
232
|
+
// contents check and removal. Chrome closes empty windows when their last tab closes.
|
|
233
|
+
await chrome.tabs.remove(t.tabId);
|
|
234
|
+
result.closedTabId = t.tabId;
|
|
235
|
+
} catch {
|
|
236
|
+
return result; // Keep ownership so cleanup can retry.
|
|
156
237
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (tab) {
|
|
161
|
-
await chrome.tabs.remove(tabId).catch(() => {});
|
|
162
|
-
return { closedWindowId: null, closedTabId: tabId };
|
|
238
|
+
if (tab.windowId === t.windowId && typeof chrome.windows?.get === "function") {
|
|
239
|
+
const remaining = await chrome.windows.get(t.windowId).catch(() => null);
|
|
240
|
+
if (!remaining) result.closedWindowId = t.windowId;
|
|
163
241
|
}
|
|
164
242
|
}
|
|
165
|
-
|
|
243
|
+
automationTargets.delete(sessionKey);
|
|
244
|
+
await persistAutomationTargets();
|
|
245
|
+
return result;
|
|
166
246
|
}
|
|
167
247
|
|
|
168
248
|
function withTimeout(promise, ms, label, onTimeout) {
|
|
@@ -665,7 +745,7 @@ async function domClickFallback(tabId, params, cause) {
|
|
|
665
745
|
|
|
666
746
|
async function chromeInputClick(params) {
|
|
667
747
|
const tab = await getTabByParams(params);
|
|
668
|
-
|
|
748
|
+
await bringToFront(tab, params);
|
|
669
749
|
try {
|
|
670
750
|
await attachDebugger(tab.id);
|
|
671
751
|
const resolved = await resolveTargetInTab(tab.id, params);
|
|
@@ -702,7 +782,7 @@ async function chromeInputClick(params) {
|
|
|
702
782
|
|
|
703
783
|
async function chromeInputHover(params) {
|
|
704
784
|
const tab = await getTabByParams(params);
|
|
705
|
-
|
|
785
|
+
await bringToFront(tab, params);
|
|
706
786
|
await attachDebugger(tab.id);
|
|
707
787
|
const resolved = await resolveTargetInTab(tab.id, params);
|
|
708
788
|
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
@@ -713,7 +793,7 @@ async function chromeInputHover(params) {
|
|
|
713
793
|
|
|
714
794
|
async function chromeInputKey(params) {
|
|
715
795
|
const tab = await getTabByParams(params);
|
|
716
|
-
|
|
796
|
+
await bringToFront(tab, params);
|
|
717
797
|
await attachDebugger(tab.id);
|
|
718
798
|
const key = String(params.key || "");
|
|
719
799
|
if (!key) throw new Error("chrome.key: missing key");
|
|
@@ -751,7 +831,7 @@ async function chromeInputKey(params) {
|
|
|
751
831
|
|
|
752
832
|
async function chromeInputType(params) {
|
|
753
833
|
const tab = await getTabByParams(params);
|
|
754
|
-
|
|
834
|
+
await bringToFront(tab, params);
|
|
755
835
|
await attachDebugger(tab.id);
|
|
756
836
|
if (params.selector || params.uid) {
|
|
757
837
|
// Focus target by clicking it first.
|
|
@@ -814,7 +894,7 @@ async function domFillFallback(tabId, params, cause) {
|
|
|
814
894
|
|
|
815
895
|
async function chromeInputFill(params) {
|
|
816
896
|
const tab = await getTabByParams(params);
|
|
817
|
-
|
|
897
|
+
await bringToFront(tab, params);
|
|
818
898
|
try {
|
|
819
899
|
await attachDebugger(tab.id);
|
|
820
900
|
if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
|
|
@@ -844,7 +924,7 @@ async function chromeInputFill(params) {
|
|
|
844
924
|
|
|
845
925
|
async function chromeInputScroll(params) {
|
|
846
926
|
const tab = await getTabByParams(params);
|
|
847
|
-
|
|
927
|
+
await bringToFront(tab, params);
|
|
848
928
|
await attachDebugger(tab.id);
|
|
849
929
|
const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
|
|
850
930
|
const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
|
|
@@ -894,7 +974,7 @@ async function chromeInputScroll(params) {
|
|
|
894
974
|
|
|
895
975
|
async function chromeInputTap(params) {
|
|
896
976
|
const tab = await getTabByParams(params);
|
|
897
|
-
|
|
977
|
+
await bringToFront(tab, params);
|
|
898
978
|
await attachDebugger(tab.id);
|
|
899
979
|
const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number"))
|
|
900
980
|
? await resolveTargetInTab(tab.id, params)
|
|
@@ -910,7 +990,7 @@ async function chromeInputTap(params) {
|
|
|
910
990
|
|
|
911
991
|
async function chromeInputDrag(params) {
|
|
912
992
|
const tab = await getTabByParams(params);
|
|
913
|
-
|
|
993
|
+
await bringToFront(tab, params);
|
|
914
994
|
await attachDebugger(tab.id);
|
|
915
995
|
const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
|
|
916
996
|
const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
|
|
@@ -935,7 +1015,7 @@ async function chromeInputDrag(params) {
|
|
|
935
1015
|
|
|
936
1016
|
async function chromeInputUpload(params) {
|
|
937
1017
|
const tab = await getTabByParams(params);
|
|
938
|
-
|
|
1018
|
+
await bringToFront(tab, params);
|
|
939
1019
|
await attachDebugger(tab.id);
|
|
940
1020
|
if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required");
|
|
941
1021
|
const paths = Array.isArray(params.paths) ? params.paths.map(String) : [];
|
|
@@ -1128,21 +1208,29 @@ async function dispatch(action, params) {
|
|
|
1128
1208
|
extensionVersion: chrome.runtime.getManifest().version,
|
|
1129
1209
|
bridgeUrl: BRIDGE_URL,
|
|
1130
1210
|
userAgent: navigator.userAgent,
|
|
1211
|
+
capabilities: { hardBackground: true },
|
|
1131
1212
|
};
|
|
1132
1213
|
case "tab.list": {
|
|
1133
1214
|
const tabs = await chrome.tabs.query({});
|
|
1134
1215
|
return Promise.all(tabs.map(formatTab));
|
|
1135
1216
|
}
|
|
1217
|
+
case "tab.new.background":
|
|
1218
|
+
case "page.screenshot.background":
|
|
1219
|
+
// Older workers reject these action names before touching tabs. Do not replace this with
|
|
1220
|
+
// a capability probe followed by an old action: a reload/profile change can race the probe.
|
|
1221
|
+
return dispatch(action.slice(0, -".background".length), { ...params, background: true, foreground: false });
|
|
1136
1222
|
case "tab.new": {
|
|
1137
1223
|
// Every Pi-opened tab must join a tab group. There is intentionally no opt-out: an ungrouped
|
|
1138
1224
|
// Pi-created tab is easy to lose among user tabs. If grouping fails after creation, close the
|
|
1139
1225
|
// tab best-effort before surfacing the error so tab.new never leaves an ungrouped Pi tab.
|
|
1140
1226
|
const groupTitle = params.groupTitle || "Pi";
|
|
1141
1227
|
const existingGroup = await findGroupRecordByTitle(groupTitle);
|
|
1142
|
-
const createParams = { url: params.url || "about:blank", active:
|
|
1228
|
+
const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
|
|
1143
1229
|
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
|
|
1144
1230
|
const tab = await chrome.tabs.create(createParams);
|
|
1231
|
+
await trackSessionTab(sessionKeyOf(params), tab.id, true);
|
|
1145
1232
|
try {
|
|
1233
|
+
await bringToFront(tab, params);
|
|
1146
1234
|
return await groupTab(tab, groupTitle, params.groupColor);
|
|
1147
1235
|
} catch (error) {
|
|
1148
1236
|
if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
|
|
@@ -1150,16 +1238,20 @@ async function dispatch(action, params) {
|
|
|
1150
1238
|
}
|
|
1151
1239
|
}
|
|
1152
1240
|
case "tab.activate": {
|
|
1241
|
+
if (!foregroundRequested(params)) {
|
|
1242
|
+
throw new Error("Tab activation is blocked by background mode. Ask the user to run /chrome background off to allow foreground work.");
|
|
1243
|
+
}
|
|
1153
1244
|
// Management actions never auto-create an automation target (createOwnedTarget:false): with
|
|
1154
1245
|
// no explicit target they act on an owned target if one exists, else error — they must never
|
|
1155
1246
|
// fall back to (or spawn a tab just to touch) the user's active tab.
|
|
1156
1247
|
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
1157
|
-
await
|
|
1158
|
-
return formatTab(await chrome.tabs.update(tab.id, { active: true }));
|
|
1248
|
+
return formatTab(await bringToFront(tab, params));
|
|
1159
1249
|
}
|
|
1160
1250
|
case "tab.group": {
|
|
1161
1251
|
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
1162
|
-
|
|
1252
|
+
const grouped = await groupTab(tab, params.groupTitle || "Pi", params.groupColor);
|
|
1253
|
+
if (!(tab.groupId >= 0)) await trackSessionTab(sessionKeyOf(params), tab.id, false, grouped.group?.id);
|
|
1254
|
+
return grouped;
|
|
1163
1255
|
}
|
|
1164
1256
|
case "tab.ungroup": {
|
|
1165
1257
|
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
@@ -1209,7 +1301,7 @@ async function dispatch(action, params) {
|
|
|
1209
1301
|
// Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling
|
|
1210
1302
|
// loop in-page with new Function() for expression checks, which fails under strict CSP.
|
|
1211
1303
|
const tab = await getTabByParams(params);
|
|
1212
|
-
|
|
1304
|
+
await bringToFront(tab, params);
|
|
1213
1305
|
const timeoutMs = params.timeoutMs || 10000;
|
|
1214
1306
|
const intervalMs = params.intervalMs || 250;
|
|
1215
1307
|
const started = Date.now();
|
|
@@ -1233,7 +1325,7 @@ async function dispatch(action, params) {
|
|
|
1233
1325
|
return executeInTab(params, probePage, []);
|
|
1234
1326
|
case "page.navigate": {
|
|
1235
1327
|
const tab = await getTabByParams(params);
|
|
1236
|
-
|
|
1328
|
+
await bringToFront(tab, params);
|
|
1237
1329
|
if (params.initScript) {
|
|
1238
1330
|
// Register a one-shot document_start content script. We register, navigate, wait, then unregister.
|
|
1239
1331
|
await registerInitScript(tab.id, params.initScript);
|
|
@@ -1256,9 +1348,9 @@ async function dispatch(action, params) {
|
|
|
1256
1348
|
return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
|
|
1257
1349
|
}
|
|
1258
1350
|
case "automation.cleanup":
|
|
1259
|
-
// Close only
|
|
1260
|
-
//
|
|
1261
|
-
return
|
|
1351
|
+
// Close recorded creations, and only ungroup user tabs still in their adopted group.
|
|
1352
|
+
// Group titles are not ownership evidence.
|
|
1353
|
+
return cleanupSessionTabs(sessionKeyOf(params));
|
|
1262
1354
|
default:
|
|
1263
1355
|
throw new Error(`Unknown action: ${action}`);
|
|
1264
1356
|
}
|
|
@@ -1345,18 +1437,19 @@ async function getTabByParams(params, { createOwnedTarget = true } = {}) {
|
|
|
1345
1437
|
// which tabs Pi is driving. We only adopt *ungrouped* tabs — never hijack a tab the user (or
|
|
1346
1438
|
// another Pi session) already grouped, since groupTab would otherwise rename that group.
|
|
1347
1439
|
if (params.joinSessionGroup && params.sessionGroupTitle) {
|
|
1348
|
-
await joinSessionGroup(tab, params.sessionGroupTitle);
|
|
1440
|
+
await joinSessionGroup(tab, params.sessionGroupTitle, sessionKeyOf(params));
|
|
1349
1441
|
}
|
|
1350
1442
|
return tab;
|
|
1351
1443
|
}
|
|
1352
1444
|
|
|
1353
1445
|
// Add an ungrouped tab to the session's tab group (reusing it by title, else creating it).
|
|
1354
1446
|
// No-op when the tab is already grouped or tabGroups is unavailable.
|
|
1355
|
-
async function joinSessionGroup(tab, title) {
|
|
1447
|
+
async function joinSessionGroup(tab, title, sessionKey) {
|
|
1356
1448
|
if (!chrome.tabGroups || typeof tab.id !== "number") return;
|
|
1357
1449
|
if (typeof tab.groupId === "number" && tab.groupId >= 0) return;
|
|
1358
1450
|
try {
|
|
1359
|
-
await groupTab(tab, title);
|
|
1451
|
+
const grouped = await groupTab(tab, title);
|
|
1452
|
+
await trackSessionTab(sessionKey, tab.id, false, grouped.group?.id);
|
|
1360
1453
|
} catch {
|
|
1361
1454
|
// Grouping is best-effort; never block the actual page action on a grouping failure.
|
|
1362
1455
|
}
|
|
@@ -1393,7 +1486,7 @@ const HELPER_FUNCS = [
|
|
|
1393
1486
|
|
|
1394
1487
|
async function executeInTab(params, func, args) {
|
|
1395
1488
|
const tab = await getTabByParams(params);
|
|
1396
|
-
|
|
1489
|
+
await bringToFront(tab, params);
|
|
1397
1490
|
|
|
1398
1491
|
// Phase 1: define the helpers and the action function as page globals via CDP
|
|
1399
1492
|
// Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the
|
|
@@ -1460,7 +1553,7 @@ function piEvalStringify(v) {
|
|
|
1460
1553
|
// pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`).
|
|
1461
1554
|
async function evaluateInTab(params) {
|
|
1462
1555
|
const tab = await getTabByParams(params);
|
|
1463
|
-
|
|
1556
|
+
await bringToFront(tab, params);
|
|
1464
1557
|
const expression = String(params.expression ?? "");
|
|
1465
1558
|
const stringifySrc = `(${piEvalStringify.toString()})`;
|
|
1466
1559
|
// Wrap the user expression so the result is run through piEvalStringify in-page before it
|
|
@@ -1507,7 +1600,7 @@ async function withOptionalSnapshot(params, actionFn) {
|
|
|
1507
1600
|
// It shares window.__PI_CHROME_STATE__ (same el- uid scheme) with the CDP-injected input helpers.
|
|
1508
1601
|
async function snapshotInTab(params) {
|
|
1509
1602
|
const tab = await getTabByParams(params);
|
|
1510
|
-
|
|
1603
|
+
await bringToFront(tab, params);
|
|
1511
1604
|
const args = [
|
|
1512
1605
|
params.maxElements || 80,
|
|
1513
1606
|
params.containingText ?? null,
|
|
@@ -1551,7 +1644,7 @@ async function snapshotInTab(params) {
|
|
|
1551
1644
|
async function inspectInTab(params) {
|
|
1552
1645
|
if (!params.uid && !params.selector) throw new Error("chrome_inspect requires uid or selector");
|
|
1553
1646
|
const tab = await getTabByParams(params);
|
|
1554
|
-
|
|
1647
|
+
await bringToFront(tab, params);
|
|
1555
1648
|
const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true];
|
|
1556
1649
|
await executeScriptTimed({
|
|
1557
1650
|
target: { tabId: tab.id, frameIds: [0] },
|
|
@@ -1621,9 +1714,15 @@ if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
|
|
|
1621
1714
|
});
|
|
1622
1715
|
}
|
|
1623
1716
|
|
|
1624
|
-
|
|
1717
|
+
function foregroundRequested(params) {
|
|
1718
|
+
// Fail quiet when unspecified, and let background veto even a contradictory foreground flag.
|
|
1719
|
+
return params?.foreground === true && params.background !== true;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async function bringToFront(tab, params) {
|
|
1723
|
+
if (!foregroundRequested(params)) return tab;
|
|
1625
1724
|
await chrome.windows.update(tab.windowId, { focused: true });
|
|
1626
|
-
|
|
1725
|
+
return chrome.tabs.update(tab.id, { active: true });
|
|
1627
1726
|
}
|
|
1628
1727
|
|
|
1629
1728
|
function waitForTabComplete(tabId, timeoutMs) {
|
|
@@ -1643,50 +1742,51 @@ function waitForTabComplete(tabId, timeoutMs) {
|
|
|
1643
1742
|
});
|
|
1644
1743
|
}
|
|
1645
1744
|
|
|
1646
|
-
async function
|
|
1647
|
-
const
|
|
1648
|
-
if (params.foreground) await bringToFront(tab);
|
|
1649
|
-
let previousActiveId;
|
|
1650
|
-
if (!tab.active) {
|
|
1651
|
-
const activeBefore = await chrome.tabs.query({ active: true, windowId: tab.windowId });
|
|
1652
|
-
previousActiveId = activeBefore[0]?.id;
|
|
1653
|
-
await chrome.tabs.update(tab.id, { active: true });
|
|
1654
|
-
}
|
|
1745
|
+
async function captureTabScreenshot(tabId, params) {
|
|
1746
|
+
const format = params.format || "png";
|
|
1655
1747
|
try {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1748
|
+
await attachDebugger(tabId);
|
|
1749
|
+
const captureParams = { format, fromSurface: true, captureBeyondViewport: false };
|
|
1750
|
+
if (format === "jpeg" && params.quality !== undefined) captureParams.quality = params.quality;
|
|
1751
|
+
const result = await cdp(tabId, "Page.captureScreenshot", captureParams);
|
|
1752
|
+
if (typeof result?.data !== "string" || !result.data) throw new Error("CDP returned no screenshot data");
|
|
1753
|
+
return `data:image/${format};base64,${result.data}`;
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
// captureVisibleTab requires activation and can race with the user switching tabs. Never
|
|
1756
|
+
// use it as a fallback, even when debugger attachment or background rendering fails.
|
|
1757
|
+
throw new Error(`Chrome screenshot via CDP failed; no tab-activation fallback was attempted. ${error?.message || error}`);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async function takeScreenshot(params) {
|
|
1762
|
+
const tab = await bringToFront(await getTabByParams(params), params);
|
|
1763
|
+
if (params.fullPage) {
|
|
1764
|
+
// Preserve the existing tile + manifest contract. Every tile captures the same resolved tab,
|
|
1765
|
+
// without activation; selector/title changes during capture must not retarget later tiles.
|
|
1766
|
+
const targetParams = { ...params, targetId: tab.id, foreground: false };
|
|
1767
|
+
const tiles = await executeInTab(targetParams, captureFullPageTiles, []);
|
|
1768
|
+
const captured = [];
|
|
1769
|
+
try {
|
|
1662
1770
|
for (const tile of tiles.tiles) {
|
|
1663
|
-
await executeInTab(
|
|
1664
|
-
|
|
1665
|
-
await
|
|
1666
|
-
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
|
1667
|
-
format: params.format || "png",
|
|
1668
|
-
quality: params.format === "jpeg" ? params.quality : undefined,
|
|
1669
|
-
});
|
|
1670
|
-
captured.push({ y: tile.y, dataUrl });
|
|
1771
|
+
await executeInTab(targetParams, scrollToY, [tile.scrollY]);
|
|
1772
|
+
await sleep(120); // Let scroll/lazy-load handlers settle.
|
|
1773
|
+
captured.push({ y: tile.y, dataUrl: await captureTabScreenshot(tab.id, params) });
|
|
1671
1774
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr },
|
|
1677
|
-
tiles: captured,
|
|
1678
|
-
};
|
|
1679
|
-
}
|
|
1680
|
-
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
|
1681
|
-
format: params.format || "png",
|
|
1682
|
-
quality: params.format === "jpeg" ? params.quality : undefined,
|
|
1683
|
-
});
|
|
1684
|
-
return { dataUrl, tab: await formatTab(tab) };
|
|
1685
|
-
} finally {
|
|
1686
|
-
if (previousActiveId !== undefined && previousActiveId !== tab.id) {
|
|
1687
|
-
await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined);
|
|
1775
|
+
} finally {
|
|
1776
|
+
// A failed tile must not strand the page at a new scroll position. Restore both axes
|
|
1777
|
+
// best-effort, without masking the capture error if the tab/debugger is gone.
|
|
1778
|
+
await executeInTab(targetParams, scrollToY, [tiles.originalScrollY, tiles.originalScrollX]).catch(() => undefined);
|
|
1688
1779
|
}
|
|
1780
|
+
return {
|
|
1781
|
+
fullPage: true,
|
|
1782
|
+
method: "cdp",
|
|
1783
|
+
tab: await formatTab(tab),
|
|
1784
|
+
dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr },
|
|
1785
|
+
tiles: captured,
|
|
1786
|
+
};
|
|
1689
1787
|
}
|
|
1788
|
+
const dataUrl = await captureTabScreenshot(tab.id, params);
|
|
1789
|
+
return { dataUrl, method: "cdp", tab: await formatTab(tab) };
|
|
1690
1790
|
}
|
|
1691
1791
|
|
|
1692
1792
|
// ---------------------------------------------------------------------------
|
|
@@ -2113,8 +2213,7 @@ function probePage() {
|
|
|
2113
2213
|
}
|
|
2114
2214
|
|
|
2115
2215
|
function captureFullPageTiles() {
|
|
2116
|
-
// Returns the
|
|
2117
|
-
// in the SW. We just report the scroll positions and metrics.
|
|
2216
|
+
// Returns the plan for CDP tile capture in the worker: scroll positions and page metrics.
|
|
2118
2217
|
const html = document.documentElement;
|
|
2119
2218
|
const body = document.body;
|
|
2120
2219
|
const width = Math.max(html.scrollWidth, body ? body.scrollWidth : 0, innerWidth);
|
|
@@ -2122,17 +2221,18 @@ function captureFullPageTiles() {
|
|
|
2122
2221
|
const viewportHeight = innerHeight;
|
|
2123
2222
|
const dpr = window.devicePixelRatio || 1;
|
|
2124
2223
|
const originalScrollY = scrollY;
|
|
2224
|
+
const originalScrollX = scrollX;
|
|
2125
2225
|
const tiles = [];
|
|
2126
2226
|
let y = 0;
|
|
2127
2227
|
while (y < height) {
|
|
2128
2228
|
tiles.push({ y, scrollY: y });
|
|
2129
2229
|
y += viewportHeight;
|
|
2130
2230
|
}
|
|
2131
|
-
return { width, height, viewportHeight, dpr, originalScrollY, tiles };
|
|
2231
|
+
return { width, height, viewportHeight, dpr, originalScrollY, originalScrollX, tiles };
|
|
2132
2232
|
}
|
|
2133
2233
|
|
|
2134
|
-
function scrollToY(y) {
|
|
2135
|
-
window.scrollTo({ top: y, left:
|
|
2234
|
+
function scrollToY(y, x = 0) {
|
|
2235
|
+
window.scrollTo({ top: y, left: x, behavior: "instant" });
|
|
2136
2236
|
return { scrollY };
|
|
2137
2237
|
}
|
|
2138
2238
|
|