pi-chrome 0.15.47 → 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 +8 -0
- package/README.md +9 -1
- package/SECURITY.md +1 -1
- package/docs/ARCHITECTURE.md +16 -4
- 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 +80 -64
- package/extensions/chrome-profile-bridge/index.ts +92 -103
- 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 +1 -1
- package/test-suite/unit/background-policy.test.mjs +469 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-facing changes to `pi-chrome`.
|
|
4
4
|
|
|
5
|
+
## 0.15.48 — 2026-09-09
|
|
6
|
+
|
|
7
|
+
- **Existing background mode is now hard background.** `/chrome background on` (still the default) overrides per-call foreground requests, keeps new tabs inactive, and blocks `chrome_tab activate`. Use the existing `/chrome background off` for foreground/watch mode; no new command or lock state.
|
|
8
|
+
- **Screenshots without tab activation.** PNG/JPEG and full-page tiles use CDP instead of `captureVisibleTab`. Debugger/capture failures never fall back to switching tabs. Old companions reject background creation/capture with a reload instruction. Screenshot tools now require debugger access.
|
|
9
|
+
- **Trusted input preserved.** Background policy does not replace Chrome input with synthetic events. It blocks explicit focus/activation, not page/native/Chrome/OS side effects; inactive-page rendering and focus-gated workflows can still vary by environment.
|
|
10
|
+
- **Regression coverage.** Added policy/worker/screenshot unit tests and challenge 43 for inactive-tab visibility plus trusted input. Full-page capture restores both scroll axes best-effort after success or failure.
|
|
11
|
+
- **Live validation and unresolved limitation.** Chrome 152/macOS checks passed for inactive tab creation, blocked activation, background PNG/JPEG/full-page capture, and scroll restoration. The trusted-click check encountered debugger detachment, then a visibility failure on retry; the cause remains unresolved and human interference was not ruled out. This release does not promise zero focus changes during trusted input. Chrome-behind-another-app and macOS Spaces behavior remain unverified.
|
|
12
|
+
|
|
5
13
|
## 0.15.47 — 2026-09-09
|
|
6
14
|
|
|
7
15
|
- **Bounded session cleanup.** On exit, Pi waits up to two seconds for cleanup before stopping the bridge. Reload preserves browser resources; revoke remains non-blocking.
|
package/README.md
CHANGED
|
@@ -108,13 +108,21 @@ Security details: [`SECURITY.md`](./SECURITY.md). Architecture details: [`docs/A
|
|
|
108
108
|
/chrome status # connection + auth + background state
|
|
109
109
|
/chrome authorize [duration]
|
|
110
110
|
/chrome revoke
|
|
111
|
-
/chrome background on # default:
|
|
111
|
+
/chrome background on # default: hard background policy
|
|
112
112
|
/chrome background off # foreground/watch mode
|
|
113
113
|
/chrome background status
|
|
114
114
|
```
|
|
115
115
|
|
|
116
116
|
If loaded extension is older than installed `pi-chrome`, `/chrome doctor` tells you to reload it from `chrome://extensions`.
|
|
117
117
|
|
|
118
|
+
### Background policy
|
|
119
|
+
|
|
120
|
+
`/chrome background on` is enforced, not an overridable default. Per-call `background:false` cannot bring Chrome forward, new tabs stay inactive, and `chrome_tab activate` is blocked. Use the existing `/chrome background off` for foreground/watch mode; per-call `background:true` still works when that mode is off.
|
|
121
|
+
|
|
122
|
+
Screenshots use CDP without activating background tabs. Debugger/capture failures return errors, never an activation fallback. Reload both Pi and the Chrome companion after upgrading; old companions reject background tab creation/screenshots rather than silently switching tabs.
|
|
123
|
+
|
|
124
|
+
This prevents explicit pi-chrome focus/activation, not every Chrome/OS side effect. Trusted input, page popups, native prompts, debugger banners, and macOS Spaces can still affect focus. Inactive pages may throttle rendering or reject focus-gated actions. See [scope and risks](./docs/ARCHITECTURE.md#scope-and-risks).
|
|
125
|
+
|
|
118
126
|
---
|
|
119
127
|
|
|
120
128
|
## Limits
|
package/SECURITY.md
CHANGED
|
@@ -27,7 +27,7 @@ The Chrome extension under `extensions/chrome-profile-bridge/browser-extension/`
|
|
|
27
27
|
- Loopback bridge only. No remote port. No telemetry.
|
|
28
28
|
- Chrome real input layer for interactive controls.
|
|
29
29
|
- Chrome control locked by default; `/chrome authorize` unlocks current Pi session after terminal confirmation, `/chrome revoke` locks it again.
|
|
30
|
-
-
|
|
30
|
+
- Hard background mode is on by default: tools cannot override it to explicitly focus windows or activate tabs. `/chrome background off` allows foreground/watch mode. This is not a security sandbox: trusted input, page scripts, native prompts, and Chrome/OS behavior can still affect focus.
|
|
31
31
|
|
|
32
32
|
## Custom ports
|
|
33
33
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -46,14 +46,26 @@ To point pi-chrome at an existing tab, pass `targetId`, `urlIncludes`, or `title
|
|
|
46
46
|
|
|
47
47
|
## Background mode
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
The existing background setting is a hard session policy, enabled by default. No separate lock/unlock command is needed.
|
|
50
50
|
|
|
51
51
|
```text
|
|
52
|
-
/chrome background on #
|
|
53
|
-
/chrome background off # foreground/watch mode
|
|
52
|
+
/chrome background on # enforce no explicit window focus/tab activation
|
|
53
|
+
/chrome background off # allow foreground/watch mode
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
- With background on, per-call `background:false` and legacy `foreground:true` cannot override the policy. `chrome_tab activate` errors with instructions to ask the user to turn background off.
|
|
57
|
+
- With background off, calls may focus Chrome; per-call `background:true` still avoids explicit focus/tab activation.
|
|
58
|
+
- Policy is applied in `authorizedBridgeSend` for every tool, including `chrome_launch(url)`, tab creation, and tools without a background parameter. Each session sends its own effective `background`/`foreground` flags through the shared bridge.
|
|
59
|
+
- All worker window-focus/tab-activation writes go through a guarded helper. Background tab creation uses `active:false`; implicit automation windows remain `focused:false`.
|
|
60
|
+
- Screenshots use CDP `Page.captureScreenshot` with `fromSurface:true` and `captureBeyondViewport:false`. They target a tab, not whichever tab happens to be visible. There is no `captureVisibleTab`/activation fallback on debugger or capture failure. PNG/JPEG output is unchanged; full-page capture retains tiles plus a JSON manifest and restores scroll position best-effort, including on failure.
|
|
61
|
+
- Background tab creation and screenshots use internal `tab.new.background` / `page.screenshot.background` wire actions. Old companions reject them before changing tabs; Pi reports a reload instruction instead of retrying an unsafe legacy action. No capability-probe race or extra round trip is needed.
|
|
62
|
+
- Real CDP input and existing explicit DOM-fallback controls are unchanged. Background mode never silently substitutes synthetic input to avoid focus.
|
|
63
|
+
|
|
64
|
+
### Scope and risks
|
|
65
|
+
|
|
66
|
+
This is a policy against **explicit pi-chrome focus/activation**, not an OS focus sandbox. Page scripts (`window.open`, `window.focus`), trusted input, native dialogs, debugger banners, Chrome window/Spaces behavior, and closing an active tab can still change focus or selection. Other sessions and human actions remain independent. Requests already dispatched before a mode change keep their earlier policy.
|
|
67
|
+
|
|
68
|
+
Inactive/minimized tabs can throttle timers or rendering, and clipboard/fullscreen/other focus-gated workflows may fail. Screenshots now require debugger attachment, which can conflict with DevTools or other extensions; hidden-tab rendering can differ or be unavailable. No automatic foreground retry is allowed. Full-page capture temporarily scrolls the target page. Reload both Pi and the Chrome companion after upgrading, and live-test tab selection, OS focus, and screenshot fidelity on supported Chrome/OS versions.
|
|
57
69
|
|
|
58
70
|
## Authorization
|
|
59
71
|
|
package/docs/COMPARISON.md
CHANGED
|
@@ -134,7 +134,7 @@ If your threat model excludes extensions with broad permissions, neither approac
|
|
|
134
134
|
|
|
135
135
|
## Public benchmarks worth knowing (for axis 2 / axis 3 comparison)
|
|
136
136
|
|
|
137
|
-
Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **
|
|
137
|
+
Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **43 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks** covering trusted input, pointer humanization, keyboard fidelity, drag/drop, Shadow DOM, iframes, file uploads, strict-CSP screenshot fallback and CDP eval/snapshot bypass, dynamic waits, tab lifecycle, network observability, fingerprint leaks, and agent-safety honeypots. Scoring tracks expected outcomes per challenge instead of raw PASS count, with `core`, `conditional`, and `quality` gate buckets. That's **driver-level** grading.
|
|
138
138
|
|
|
139
139
|
For **agent-level** comparison (axis 2), the public benchmarks worth citing:
|
|
140
140
|
|
|
@@ -745,7 +745,7 @@ async function domClickFallback(tabId, params, cause) {
|
|
|
745
745
|
|
|
746
746
|
async function chromeInputClick(params) {
|
|
747
747
|
const tab = await getTabByParams(params);
|
|
748
|
-
|
|
748
|
+
await bringToFront(tab, params);
|
|
749
749
|
try {
|
|
750
750
|
await attachDebugger(tab.id);
|
|
751
751
|
const resolved = await resolveTargetInTab(tab.id, params);
|
|
@@ -782,7 +782,7 @@ async function chromeInputClick(params) {
|
|
|
782
782
|
|
|
783
783
|
async function chromeInputHover(params) {
|
|
784
784
|
const tab = await getTabByParams(params);
|
|
785
|
-
|
|
785
|
+
await bringToFront(tab, params);
|
|
786
786
|
await attachDebugger(tab.id);
|
|
787
787
|
const resolved = await resolveTargetInTab(tab.id, params);
|
|
788
788
|
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
@@ -793,7 +793,7 @@ async function chromeInputHover(params) {
|
|
|
793
793
|
|
|
794
794
|
async function chromeInputKey(params) {
|
|
795
795
|
const tab = await getTabByParams(params);
|
|
796
|
-
|
|
796
|
+
await bringToFront(tab, params);
|
|
797
797
|
await attachDebugger(tab.id);
|
|
798
798
|
const key = String(params.key || "");
|
|
799
799
|
if (!key) throw new Error("chrome.key: missing key");
|
|
@@ -831,7 +831,7 @@ async function chromeInputKey(params) {
|
|
|
831
831
|
|
|
832
832
|
async function chromeInputType(params) {
|
|
833
833
|
const tab = await getTabByParams(params);
|
|
834
|
-
|
|
834
|
+
await bringToFront(tab, params);
|
|
835
835
|
await attachDebugger(tab.id);
|
|
836
836
|
if (params.selector || params.uid) {
|
|
837
837
|
// Focus target by clicking it first.
|
|
@@ -894,7 +894,7 @@ async function domFillFallback(tabId, params, cause) {
|
|
|
894
894
|
|
|
895
895
|
async function chromeInputFill(params) {
|
|
896
896
|
const tab = await getTabByParams(params);
|
|
897
|
-
|
|
897
|
+
await bringToFront(tab, params);
|
|
898
898
|
try {
|
|
899
899
|
await attachDebugger(tab.id);
|
|
900
900
|
if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
|
|
@@ -924,7 +924,7 @@ async function chromeInputFill(params) {
|
|
|
924
924
|
|
|
925
925
|
async function chromeInputScroll(params) {
|
|
926
926
|
const tab = await getTabByParams(params);
|
|
927
|
-
|
|
927
|
+
await bringToFront(tab, params);
|
|
928
928
|
await attachDebugger(tab.id);
|
|
929
929
|
const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
|
|
930
930
|
const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
|
|
@@ -974,7 +974,7 @@ async function chromeInputScroll(params) {
|
|
|
974
974
|
|
|
975
975
|
async function chromeInputTap(params) {
|
|
976
976
|
const tab = await getTabByParams(params);
|
|
977
|
-
|
|
977
|
+
await bringToFront(tab, params);
|
|
978
978
|
await attachDebugger(tab.id);
|
|
979
979
|
const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number"))
|
|
980
980
|
? await resolveTargetInTab(tab.id, params)
|
|
@@ -990,7 +990,7 @@ async function chromeInputTap(params) {
|
|
|
990
990
|
|
|
991
991
|
async function chromeInputDrag(params) {
|
|
992
992
|
const tab = await getTabByParams(params);
|
|
993
|
-
|
|
993
|
+
await bringToFront(tab, params);
|
|
994
994
|
await attachDebugger(tab.id);
|
|
995
995
|
const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
|
|
996
996
|
const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
|
|
@@ -1015,7 +1015,7 @@ async function chromeInputDrag(params) {
|
|
|
1015
1015
|
|
|
1016
1016
|
async function chromeInputUpload(params) {
|
|
1017
1017
|
const tab = await getTabByParams(params);
|
|
1018
|
-
|
|
1018
|
+
await bringToFront(tab, params);
|
|
1019
1019
|
await attachDebugger(tab.id);
|
|
1020
1020
|
if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required");
|
|
1021
1021
|
const paths = Array.isArray(params.paths) ? params.paths.map(String) : [];
|
|
@@ -1208,22 +1208,29 @@ async function dispatch(action, params) {
|
|
|
1208
1208
|
extensionVersion: chrome.runtime.getManifest().version,
|
|
1209
1209
|
bridgeUrl: BRIDGE_URL,
|
|
1210
1210
|
userAgent: navigator.userAgent,
|
|
1211
|
+
capabilities: { hardBackground: true },
|
|
1211
1212
|
};
|
|
1212
1213
|
case "tab.list": {
|
|
1213
1214
|
const tabs = await chrome.tabs.query({});
|
|
1214
1215
|
return Promise.all(tabs.map(formatTab));
|
|
1215
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 });
|
|
1216
1222
|
case "tab.new": {
|
|
1217
1223
|
// Every Pi-opened tab must join a tab group. There is intentionally no opt-out: an ungrouped
|
|
1218
1224
|
// Pi-created tab is easy to lose among user tabs. If grouping fails after creation, close the
|
|
1219
1225
|
// tab best-effort before surfacing the error so tab.new never leaves an ungrouped Pi tab.
|
|
1220
1226
|
const groupTitle = params.groupTitle || "Pi";
|
|
1221
1227
|
const existingGroup = await findGroupRecordByTitle(groupTitle);
|
|
1222
|
-
const createParams = { url: params.url || "about:blank", active:
|
|
1228
|
+
const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
|
|
1223
1229
|
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
|
|
1224
1230
|
const tab = await chrome.tabs.create(createParams);
|
|
1225
1231
|
await trackSessionTab(sessionKeyOf(params), tab.id, true);
|
|
1226
1232
|
try {
|
|
1233
|
+
await bringToFront(tab, params);
|
|
1227
1234
|
return await groupTab(tab, groupTitle, params.groupColor);
|
|
1228
1235
|
} catch (error) {
|
|
1229
1236
|
if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
|
|
@@ -1231,12 +1238,14 @@ async function dispatch(action, params) {
|
|
|
1231
1238
|
}
|
|
1232
1239
|
}
|
|
1233
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
|
+
}
|
|
1234
1244
|
// Management actions never auto-create an automation target (createOwnedTarget:false): with
|
|
1235
1245
|
// no explicit target they act on an owned target if one exists, else error — they must never
|
|
1236
1246
|
// fall back to (or spawn a tab just to touch) the user's active tab.
|
|
1237
1247
|
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
1238
|
-
await
|
|
1239
|
-
return formatTab(await chrome.tabs.update(tab.id, { active: true }));
|
|
1248
|
+
return formatTab(await bringToFront(tab, params));
|
|
1240
1249
|
}
|
|
1241
1250
|
case "tab.group": {
|
|
1242
1251
|
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
@@ -1292,7 +1301,7 @@ async function dispatch(action, params) {
|
|
|
1292
1301
|
// Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling
|
|
1293
1302
|
// loop in-page with new Function() for expression checks, which fails under strict CSP.
|
|
1294
1303
|
const tab = await getTabByParams(params);
|
|
1295
|
-
|
|
1304
|
+
await bringToFront(tab, params);
|
|
1296
1305
|
const timeoutMs = params.timeoutMs || 10000;
|
|
1297
1306
|
const intervalMs = params.intervalMs || 250;
|
|
1298
1307
|
const started = Date.now();
|
|
@@ -1316,7 +1325,7 @@ async function dispatch(action, params) {
|
|
|
1316
1325
|
return executeInTab(params, probePage, []);
|
|
1317
1326
|
case "page.navigate": {
|
|
1318
1327
|
const tab = await getTabByParams(params);
|
|
1319
|
-
|
|
1328
|
+
await bringToFront(tab, params);
|
|
1320
1329
|
if (params.initScript) {
|
|
1321
1330
|
// Register a one-shot document_start content script. We register, navigate, wait, then unregister.
|
|
1322
1331
|
await registerInitScript(tab.id, params.initScript);
|
|
@@ -1477,7 +1486,7 @@ const HELPER_FUNCS = [
|
|
|
1477
1486
|
|
|
1478
1487
|
async function executeInTab(params, func, args) {
|
|
1479
1488
|
const tab = await getTabByParams(params);
|
|
1480
|
-
|
|
1489
|
+
await bringToFront(tab, params);
|
|
1481
1490
|
|
|
1482
1491
|
// Phase 1: define the helpers and the action function as page globals via CDP
|
|
1483
1492
|
// Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the
|
|
@@ -1544,7 +1553,7 @@ function piEvalStringify(v) {
|
|
|
1544
1553
|
// pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`).
|
|
1545
1554
|
async function evaluateInTab(params) {
|
|
1546
1555
|
const tab = await getTabByParams(params);
|
|
1547
|
-
|
|
1556
|
+
await bringToFront(tab, params);
|
|
1548
1557
|
const expression = String(params.expression ?? "");
|
|
1549
1558
|
const stringifySrc = `(${piEvalStringify.toString()})`;
|
|
1550
1559
|
// Wrap the user expression so the result is run through piEvalStringify in-page before it
|
|
@@ -1591,7 +1600,7 @@ async function withOptionalSnapshot(params, actionFn) {
|
|
|
1591
1600
|
// It shares window.__PI_CHROME_STATE__ (same el- uid scheme) with the CDP-injected input helpers.
|
|
1592
1601
|
async function snapshotInTab(params) {
|
|
1593
1602
|
const tab = await getTabByParams(params);
|
|
1594
|
-
|
|
1603
|
+
await bringToFront(tab, params);
|
|
1595
1604
|
const args = [
|
|
1596
1605
|
params.maxElements || 80,
|
|
1597
1606
|
params.containingText ?? null,
|
|
@@ -1635,7 +1644,7 @@ async function snapshotInTab(params) {
|
|
|
1635
1644
|
async function inspectInTab(params) {
|
|
1636
1645
|
if (!params.uid && !params.selector) throw new Error("chrome_inspect requires uid or selector");
|
|
1637
1646
|
const tab = await getTabByParams(params);
|
|
1638
|
-
|
|
1647
|
+
await bringToFront(tab, params);
|
|
1639
1648
|
const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true];
|
|
1640
1649
|
await executeScriptTimed({
|
|
1641
1650
|
target: { tabId: tab.id, frameIds: [0] },
|
|
@@ -1705,9 +1714,15 @@ if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
|
|
|
1705
1714
|
});
|
|
1706
1715
|
}
|
|
1707
1716
|
|
|
1708
|
-
|
|
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;
|
|
1709
1724
|
await chrome.windows.update(tab.windowId, { focused: true });
|
|
1710
|
-
|
|
1725
|
+
return chrome.tabs.update(tab.id, { active: true });
|
|
1711
1726
|
}
|
|
1712
1727
|
|
|
1713
1728
|
function waitForTabComplete(tabId, timeoutMs) {
|
|
@@ -1727,50 +1742,51 @@ function waitForTabComplete(tabId, timeoutMs) {
|
|
|
1727
1742
|
});
|
|
1728
1743
|
}
|
|
1729
1744
|
|
|
1730
|
-
async function
|
|
1731
|
-
const
|
|
1732
|
-
if (params.foreground) await bringToFront(tab);
|
|
1733
|
-
let previousActiveId;
|
|
1734
|
-
if (!tab.active) {
|
|
1735
|
-
const activeBefore = await chrome.tabs.query({ active: true, windowId: tab.windowId });
|
|
1736
|
-
previousActiveId = activeBefore[0]?.id;
|
|
1737
|
-
await chrome.tabs.update(tab.id, { active: true });
|
|
1738
|
-
}
|
|
1745
|
+
async function captureTabScreenshot(tabId, params) {
|
|
1746
|
+
const format = params.format || "png";
|
|
1739
1747
|
try {
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
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 {
|
|
1746
1770
|
for (const tile of tiles.tiles) {
|
|
1747
|
-
await executeInTab(
|
|
1748
|
-
|
|
1749
|
-
await
|
|
1750
|
-
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
|
1751
|
-
format: params.format || "png",
|
|
1752
|
-
quality: params.format === "jpeg" ? params.quality : undefined,
|
|
1753
|
-
});
|
|
1754
|
-
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) });
|
|
1755
1774
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr },
|
|
1761
|
-
tiles: captured,
|
|
1762
|
-
};
|
|
1763
|
-
}
|
|
1764
|
-
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
|
1765
|
-
format: params.format || "png",
|
|
1766
|
-
quality: params.format === "jpeg" ? params.quality : undefined,
|
|
1767
|
-
});
|
|
1768
|
-
return { dataUrl, tab: await formatTab(tab) };
|
|
1769
|
-
} finally {
|
|
1770
|
-
if (previousActiveId !== undefined && previousActiveId !== tab.id) {
|
|
1771
|
-
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);
|
|
1772
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
|
+
};
|
|
1773
1787
|
}
|
|
1788
|
+
const dataUrl = await captureTabScreenshot(tab.id, params);
|
|
1789
|
+
return { dataUrl, method: "cdp", tab: await formatTab(tab) };
|
|
1774
1790
|
}
|
|
1775
1791
|
|
|
1776
1792
|
// ---------------------------------------------------------------------------
|
|
@@ -2197,8 +2213,7 @@ function probePage() {
|
|
|
2197
2213
|
}
|
|
2198
2214
|
|
|
2199
2215
|
function captureFullPageTiles() {
|
|
2200
|
-
// Returns the
|
|
2201
|
-
// 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.
|
|
2202
2217
|
const html = document.documentElement;
|
|
2203
2218
|
const body = document.body;
|
|
2204
2219
|
const width = Math.max(html.scrollWidth, body ? body.scrollWidth : 0, innerWidth);
|
|
@@ -2206,17 +2221,18 @@ function captureFullPageTiles() {
|
|
|
2206
2221
|
const viewportHeight = innerHeight;
|
|
2207
2222
|
const dpr = window.devicePixelRatio || 1;
|
|
2208
2223
|
const originalScrollY = scrollY;
|
|
2224
|
+
const originalScrollX = scrollX;
|
|
2209
2225
|
const tiles = [];
|
|
2210
2226
|
let y = 0;
|
|
2211
2227
|
while (y < height) {
|
|
2212
2228
|
tiles.push({ y, scrollY: y });
|
|
2213
2229
|
y += viewportHeight;
|
|
2214
2230
|
}
|
|
2215
|
-
return { width, height, viewportHeight, dpr, originalScrollY, tiles };
|
|
2231
|
+
return { width, height, viewportHeight, dpr, originalScrollY, originalScrollX, tiles };
|
|
2216
2232
|
}
|
|
2217
2233
|
|
|
2218
|
-
function scrollToY(y) {
|
|
2219
|
-
window.scrollTo({ top: y, left:
|
|
2234
|
+
function scrollToY(y, x = 0) {
|
|
2235
|
+
window.scrollTo({ top: y, left: x, behavior: "instant" });
|
|
2220
2236
|
return { scrollY };
|
|
2221
2237
|
}
|
|
2222
2238
|
|