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.
@@ -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 = false;
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 = 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
- });
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
- automationTargets.delete(sessionKey);
148
- await persistAutomationTargets();
149
- if (!t) return { closedWindowId: null, closedTabId: null };
150
- const { windowId, tabId } = t;
151
- if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") {
152
- const win = await chrome.windows.get(windowId).catch(() => null);
153
- if (win) {
154
- await chrome.windows.remove(windowId).catch(() => {});
155
- return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null };
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
- if (typeof tabId === "number") {
159
- const tab = await chrome.tabs.get(tabId).catch(() => null);
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
- return { closedWindowId: null, closedTabId: null };
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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: true };
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 chrome.windows.update(tab.windowId, { focused: true });
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
- return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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 THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or
1260
- // another Pi session's target.
1261
- return cleanupAutomationTarget(sessionKeyOf(params));
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- if (params.foreground) await bringToFront(tab);
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
- async function bringToFront(tab) {
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
- await chrome.tabs.update(tab.id, { active: true });
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 takeScreenshot(params) {
1647
- const tab = await getTabByParams(params);
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
- if (params.fullPage) {
1657
- // Tile-stitched full page capture: scroll, capture, paste, repeat.
1658
- const tiles = await executeInTab({ ...params, foreground: false }, captureFullPageTiles, []);
1659
- // captureFullPageTiles only computes scroll positions / metrics; we capture per scroll here
1660
- // (chrome.tabs.captureVisibleTab can't be called from MAIN world).
1661
- const captured = [];
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({ ...params, foreground: false }, scrollToY, [tile.scrollY]);
1664
- // Small settle delay; many sites have on-scroll animations / lazy-load.
1665
- await sleep(120);
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
- await executeInTab({ ...params, foreground: false }, scrollToY, [tiles.originalScrollY]);
1673
- return {
1674
- fullPage: true,
1675
- tab: await formatTab(tab),
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 *plan* for tile capture; the actual chrome.tabs.captureVisibleTab calls happen
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: 0, behavior: "instant" });
2234
+ function scrollToY(y, x = 0) {
2235
+ window.scrollTo({ top: y, left: x, behavior: "instant" });
2136
2236
  return { scrollY };
2137
2237
  }
2138
2238