@zucker-framework/ext-kit 1.0.2 → 1.0.5

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/dist/ext-kit.js CHANGED
@@ -1,24 +1,60 @@
1
1
  "use strict";
2
2
  (() => {
3
- // src/util.ts
4
- function delay(ms) {
5
- return new Promise((resolve) => setTimeout(resolve, ms));
3
+ var __defProp = Object.defineProperty;
4
+ var __export = (target, all) => {
5
+ for (var name in all)
6
+ __defProp(target, name, { get: all[name], enumerable: true });
7
+ };
8
+
9
+ // src/util.js
10
+ function cancelled() {
11
+ return new Error("Cancelled");
6
12
  }
7
- function clamp(value, min, max) {
8
- return Math.min(Math.max(value, min), max);
13
+ function throwIfAborted(signal) {
14
+ if (signal?.aborted) throw cancelled();
9
15
  }
10
- function normalizeError(error) {
11
- return error instanceof Error ? error.message : String(error);
16
+ function withAbort(promise, signal) {
17
+ if (!signal) return promise;
18
+ if (signal.aborted) {
19
+ promise.catch(() => {
20
+ });
21
+ return Promise.reject(cancelled());
22
+ }
23
+ return new Promise((resolve, reject) => {
24
+ const abort = () => reject(cancelled());
25
+ signal.addEventListener("abort", abort, { once: true });
26
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
27
+ });
12
28
  }
13
- function storageGet(keys) {
14
- return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
29
+ function delay(ms, signal) {
30
+ throwIfAborted(signal);
31
+ return new Promise((resolve, reject) => {
32
+ const finish = () => {
33
+ signal?.removeEventListener("abort", abort);
34
+ resolve();
35
+ };
36
+ const timer = setTimeout(finish, ms);
37
+ const abort = () => {
38
+ clearTimeout(timer);
39
+ reject(cancelled());
40
+ };
41
+ signal?.addEventListener("abort", abort, { once: true });
42
+ });
15
43
  }
16
- function storageSet(value) {
17
- return new Promise((resolve) => chrome.storage.local.set(value, () => resolve()));
44
+ function normalizeError(error) {
45
+ return error instanceof Error ? error.message : String(error);
18
46
  }
19
- function storageRemove(keys) {
20
- return new Promise((resolve) => chrome.storage.local.remove(keys, () => resolve()));
47
+ function storageCall(area, method, value) {
48
+ return new Promise(
49
+ (resolve, reject) => chrome.storage[area][method](value, (result) => {
50
+ const error = chrome.runtime.lastError;
51
+ error ? reject(new Error(error.message)) : resolve(result);
52
+ })
53
+ );
21
54
  }
55
+ var storageGet = (keys) => storageCall("local", "get", keys);
56
+ var storageSet = (value) => storageCall("local", "set", value);
57
+ var storageRemove = (keys) => storageCall("local", "remove", keys);
22
58
 
23
59
  // src/naming.ts
24
60
  function sanitize(value) {
@@ -34,7 +70,7 @@
34
70
  function normalizeUrls(urls) {
35
71
  const out = [];
36
72
  const seen = /* @__PURE__ */ new Set();
37
- for (const raw of urls || []) {
73
+ for (const raw of Array.isArray(urls) ? urls : []) {
38
74
  const value = String(raw || "").trim();
39
75
  if (!/^https?:\/\//i.test(value)) {
40
76
  continue;
@@ -42,7 +78,6 @@
42
78
  let key = value;
43
79
  try {
44
80
  const parsed = new URL(value);
45
- parsed.hash = "";
46
81
  key = parsed.toString();
47
82
  } catch {
48
83
  continue;
@@ -112,226 +147,779 @@
112
147
  return `${brand}/${quickFolder}/${base}-${fileStamp()}.${extension}`;
113
148
  }
114
149
 
115
- // src/licensing.ts
116
- var CACHE_TTL_MS = 12 * 60 * 60 * 1e3;
117
- function unwrap(payload) {
118
- const obj = payload;
119
- return obj && typeof obj === "object" && "result" in obj ? obj.result : obj || {};
120
- }
150
+ // src/licensing.js
121
151
  function createLicensing(product, brand, config) {
152
+ for (const key of ["cacheTtlMs", "offlineGraceMs", "requestTimeoutMs", "retryDelayMs"]) {
153
+ if (!Number.isFinite(config[key]) || config[key] < 0 || key === "requestTimeoutMs" && !config[key])
154
+ throw new RangeError(`Invalid licensing ${key}`);
155
+ }
122
156
  const TOKEN_KEY = `${product}_license_token`;
123
157
  const ENTITLEMENT_KEY = `${product}_entitlement`;
124
158
  const DEVICE_KEY = `${product}_device_id`;
125
- async function getDeviceId() {
126
- const data = await storageGet(DEVICE_KEY);
127
- if (data[DEVICE_KEY]) {
128
- return data[DEVICE_KEY];
159
+ let refresh = null;
160
+ let nextRefreshAt = 0;
161
+ let generation = 0;
162
+ let mutation = false;
163
+ let lastStatus = null;
164
+ const unavailable = () => ({
165
+ state: "unavailable",
166
+ message: "Could not verify your saved Pro license. Check your connection and use Recheck Pro in License. Your activation is still saved."
167
+ });
168
+ const proStatus = (entitlement) => ({ state: "pro", entitlement });
169
+ function fallbackStatus(cached) {
170
+ if (lastStatus?.state === "invalid") return lastStatus;
171
+ const cachedEntitlement = usableCache(cached);
172
+ return cachedEntitlement ? proStatus(cachedEntitlement) : unavailable();
173
+ }
174
+ function normalize(value) {
175
+ if (!value || value.invalid === true) return null;
176
+ const normalized = config.normalizeEntitlement(value);
177
+ if (!normalized || typeof normalized.expiresAt !== "string") return null;
178
+ const expires = Date.parse(normalized.expiresAt);
179
+ return Number.isFinite(expires) && expires > Date.now() ? normalized : null;
180
+ }
181
+ function usableCache(cached) {
182
+ const age = Date.now() - Number(cached?.checkedAt);
183
+ return Number.isFinite(age) && age >= 0 && age < config.offlineGraceMs ? normalize(cached) : null;
184
+ }
185
+ async function request(operation, input = {}) {
186
+ const controller = new AbortController();
187
+ const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs);
188
+ try {
189
+ return await withAbort(Promise.resolve().then(() => config.request(operation, input, controller.signal)), controller.signal);
190
+ } catch {
191
+ throw new Error(
192
+ "License service unreachable. Check your connection and retry."
193
+ );
194
+ } finally {
195
+ clearTimeout(timer);
129
196
  }
130
- const bytes = new Uint8Array(16);
131
- crypto.getRandomValues(bytes);
132
- const id = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
197
+ }
198
+ async function getDeviceId() {
199
+ const stored = await storageGet(DEVICE_KEY);
200
+ if (stored[DEVICE_KEY]) return stored[DEVICE_KEY];
201
+ const id = crypto.randomUUID();
133
202
  await storageSet({ [DEVICE_KEY]: id });
134
203
  return id;
135
204
  }
136
205
  async function activate(code) {
137
- const deviceId = await getDeviceId();
138
- let response;
206
+ if (mutation) throw new Error("A license change is already in progress");
207
+ mutation = true;
208
+ generation += 1;
139
209
  try {
140
- const platform = navigator.userAgentData?.platform || "Chrome";
141
- response = await fetch(`${config.apiBase}/extension/licenses/activate`, {
142
- method: "POST",
143
- headers: { "Content-Type": "application/json" },
144
- body: JSON.stringify({
145
- code: String(code || "").trim().toUpperCase().replace(/[^A-Z0-9]/g, ""),
146
- deviceId,
147
- deviceName: `${brand} \xB7 ${platform}`
148
- })
210
+ const deviceId = await getDeviceId();
211
+ const data = await request("activate", { code, deviceId, brand });
212
+ if (!data.accepted)
213
+ throw new Error(data.denied ? "Invalid or expired code, or its device limit is reached." : "Activation failed. Please retry later.");
214
+ const entitlement = normalize(data?.entitlement);
215
+ if (!entitlement || typeof data?.token !== "string" || !data.token)
216
+ throw new Error(
217
+ "License service returned invalid activation data. Please retry."
218
+ );
219
+ await storageSet({
220
+ [TOKEN_KEY]: data.token,
221
+ [ENTITLEMENT_KEY]: { ...entitlement, checkedAt: Date.now() }
149
222
  });
150
- } catch {
151
- throw new Error("License service unreachable. Check your connection and retry.");
152
- }
153
- const payload = await response.json().catch(() => ({}));
154
- if (!response.ok) {
155
- if (response.status === 403) {
156
- throw new Error("Invalid code, or its device limit is reached.");
157
- }
158
- throw new Error("Activation failed. Please retry later.");
223
+ nextRefreshAt = 0;
224
+ lastStatus = proStatus(entitlement);
225
+ return entitlement;
226
+ } finally {
227
+ mutation = false;
159
228
  }
160
- const data = unwrap(payload);
161
- await storageSet({
162
- [TOKEN_KEY]: data.token || "",
163
- [ENTITLEMENT_KEY]: { ...data.entitlement || {}, checkedAt: Date.now() }
164
- });
165
- return data.entitlement;
166
229
  }
167
- function normalize(value) {
168
- const v = value;
169
- if (!v) {
170
- return null;
230
+ async function refreshEntitlement(token, cached, version) {
231
+ try {
232
+ const data = await request("me", { token });
233
+ if (version !== generation) return null;
234
+ if (data.denied === true) {
235
+ const denied = {
236
+ state: "invalid",
237
+ message: "Your saved Pro license could not be validated. Use Recheck Pro or enter your original activation code in License to restore it."
238
+ };
239
+ lastStatus = denied;
240
+ try {
241
+ await storageRemove(ENTITLEMENT_KEY);
242
+ } catch {
243
+ await storageSet({
244
+ [ENTITLEMENT_KEY]: { invalid: true, active: false, checkedAt: Date.now() }
245
+ }).catch(() => {
246
+ console.warn(
247
+ `${brand}: rejected license cache could not be invalidated`
248
+ );
249
+ });
250
+ }
251
+ return denied;
252
+ }
253
+ const entitlement = data.accepted ? normalize(data.entitlement) : null;
254
+ if (entitlement) {
255
+ await storageSet({
256
+ [ENTITLEMENT_KEY]: { ...entitlement, checkedAt: Date.now() }
257
+ });
258
+ return proStatus(entitlement);
259
+ }
260
+ return fallbackStatus(cached);
261
+ } catch {
262
+ if (version !== generation) return null;
263
+ return fallbackStatus(cached);
171
264
  }
172
- const expired = v.expiresAt && new Date(v.expiresAt).getTime() <= Date.now();
173
- return expired ? null : { plan: v.plan || "pro", expiresAt: v.expiresAt || "" };
174
265
  }
175
- async function getEntitlement() {
266
+ async function getStatus({ cachedOnly = false, force = false } = {}) {
176
267
  const data = await storageGet([TOKEN_KEY, ENTITLEMENT_KEY]);
177
- const token = data[TOKEN_KEY];
178
- const cached = data[ENTITLEMENT_KEY];
179
- if (!token) {
180
- return null;
181
- }
182
- if (cached?.checkedAt && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
183
- return normalize(cached);
184
- }
185
- let response;
186
- try {
187
- response = await fetch(`${config.apiBase}/extension/licenses/me`, {
188
- headers: { Authorization: `Bearer ${token}` }
268
+ const token = data[TOKEN_KEY], cached = data[ENTITLEMENT_KEY];
269
+ if (!token) return { state: "free" };
270
+ const valid = lastStatus?.state === "invalid" ? null : usableCache(cached);
271
+ if (!force && valid && Date.now() - cached.checkedAt < config.cacheTtlMs)
272
+ return proStatus(valid);
273
+ if (mutation) return { state: "checking", refreshing: true };
274
+ if (!refresh && (force || Date.now() >= nextRefreshAt)) {
275
+ const version = generation;
276
+ nextRefreshAt = Date.now() + config.retryDelayMs;
277
+ refresh = refreshEntitlement(token, cached, version).then((status2) => {
278
+ if (version === generation && status2) lastStatus = status2;
279
+ return status2;
280
+ }).finally(() => {
281
+ refresh = null;
189
282
  });
190
- } catch {
191
- return cached ? normalize(cached) : null;
192
283
  }
193
- if (!response.ok) {
194
- if (response.status === 401 || response.status === 403) {
195
- await clear();
196
- return null;
197
- }
198
- return cached ? normalize(cached) : null;
284
+ if (refresh && !cachedOnly) {
285
+ const status2 = await refresh;
286
+ return status2 || getStatus();
199
287
  }
200
- const payload = await response.json().catch(() => ({}));
201
- const entitlement = unwrap(payload)?.entitlement;
202
- await storageSet({
203
- [ENTITLEMENT_KEY]: { ...entitlement || {}, checkedAt: Date.now() }
204
- });
205
- return normalize(entitlement);
288
+ const remembered = lastStatus?.state === "pro" ? null : lastStatus;
289
+ const status = valid ? proStatus(valid) : refresh ? { state: "checking" } : remembered || unavailable();
290
+ return { ...status, refreshing: Boolean(refresh) };
206
291
  }
207
- async function isPro() {
208
- return Boolean(await getEntitlement());
292
+ async function getEntitlement(options) {
293
+ return (await getStatus(options)).entitlement || null;
209
294
  }
210
- async function clear() {
211
- await storageRemove([TOKEN_KEY, ENTITLEMENT_KEY]);
295
+ async function deactivate() {
296
+ if (mutation) throw new Error("A license change is already in progress");
297
+ mutation = true;
298
+ generation += 1;
299
+ try {
300
+ const data = await storageGet(TOKEN_KEY);
301
+ if (data[TOKEN_KEY]) {
302
+ const result = await request("deactivate", { token: data[TOKEN_KEY] });
303
+ if (!result.accepted || result.deactivated !== true)
304
+ throw new Error(
305
+ "Could not release this device. Your license is still saved; please retry."
306
+ );
307
+ }
308
+ await storageRemove([TOKEN_KEY, ENTITLEMENT_KEY]);
309
+ nextRefreshAt = 0;
310
+ lastStatus = null;
311
+ } finally {
312
+ mutation = false;
313
+ }
212
314
  }
213
- return { activate, getEntitlement, isPro, clear, purchaseUrl: config.purchaseUrl };
315
+ return {
316
+ activate,
317
+ getEntitlement,
318
+ getStatus,
319
+ deactivate,
320
+ purchaseUrl: config.purchaseUrl
321
+ };
214
322
  }
215
323
 
216
- // src/download.ts
217
- function createDownloader(justification) {
218
- const pendingDownloadNames = /* @__PURE__ */ new Map();
324
+ // src/download.js
325
+ function createDownloader(justification, options = {}) {
326
+ const deadlineMs = options.deadlineMs ?? 12e4;
327
+ const blobLeaseMs = options.blobLeaseMs ?? 18e4;
328
+ const pollMs = options.pollMs ?? 1e3;
329
+ if (![deadlineMs, blobLeaseMs, pollMs].every((n) => Number.isFinite(n) && n > 0) || blobLeaseMs <= deadlineMs)
330
+ throw new RangeError("Invalid download deadline, polling or blob lease");
331
+ const names = /* @__PURE__ */ new Map();
332
+ const waiters = /* @__PURE__ */ new Map();
219
333
  let offscreenSetup = null;
220
334
  chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
221
- const fileName = pendingDownloadNames.get(item.url) || pendingDownloadNames.get(item.finalUrl || "");
222
- if (!fileName) {
223
- return;
224
- }
225
- suggest({ filename: fileName, conflictAction: "uniquify" });
226
- return true;
335
+ const filename = names.get(item.url) || names.get(item.finalUrl);
336
+ if (filename) suggest({ filename, conflictAction: "uniquify" });
337
+ });
338
+ chrome.downloads.onChanged.addListener((delta) => {
339
+ if (delta.state?.current === "complete" || delta.state?.current === "interrupted")
340
+ waiters.get(delta.id)?.();
227
341
  });
228
342
  async function ensureOffscreen() {
229
- if (await chrome.offscreen.hasDocument()) {
230
- return;
231
- }
232
- if (!offscreenSetup) {
233
- offscreenSetup = chrome.offscreen.createDocument({
234
- url: "offscreen.html",
235
- reasons: [chrome.offscreen.Reason.BLOBS],
236
- justification
237
- }).catch((error) => {
238
- if (!String(error?.message || "").includes("Only a single")) {
239
- throw error;
240
- }
241
- }).finally(() => {
343
+ if (!offscreenSetup)
344
+ offscreenSetup = (async () => {
345
+ if (!await chrome.offscreen.hasDocument())
346
+ await chrome.offscreen.createDocument({
347
+ url: options.offscreenUrl || "offscreen.html",
348
+ reasons: [chrome.offscreen.Reason.BLOBS],
349
+ justification
350
+ });
351
+ })().finally(() => {
242
352
  offscreenSetup = null;
243
353
  });
244
- }
245
354
  return offscreenSetup;
246
355
  }
247
- async function downloadViaBlob(base64, mime, filename) {
356
+ async function cleanup(url) {
357
+ names.delete(url);
358
+ if (url)
359
+ await chrome.runtime.sendMessage({ target: "offscreen", type: "REVOKE_BLOB_URL", url }).catch(() => {
360
+ });
361
+ }
362
+ async function waitForDownload(id, signal) {
363
+ return new Promise((resolve, reject) => {
364
+ let finished = false;
365
+ let deadlineError;
366
+ const finish = (error, item) => {
367
+ if (finished) return;
368
+ finished = true;
369
+ clearInterval(poll);
370
+ clearTimeout(timeout);
371
+ signal?.removeEventListener("abort", abort);
372
+ waiters.delete(id);
373
+ error ? reject(error) : resolve(item);
374
+ };
375
+ const inspect = async () => {
376
+ try {
377
+ const [item] = await chrome.downloads.search({ id });
378
+ if (!item) return finish(new Error("Download no longer exists"));
379
+ if (item.state === "complete") finish(null, item);
380
+ else if (item.state === "interrupted")
381
+ finish(
382
+ deadlineError || new Error(
383
+ item.error === "USER_CANCELED" ? "Cancelled" : `Download interrupted: ${item.error || "unknown error"}`
384
+ )
385
+ );
386
+ } catch (error) {
387
+ finish(error);
388
+ }
389
+ };
390
+ const abort = async () => {
391
+ await chrome.downloads.cancel(id).catch(() => {
392
+ });
393
+ const [item] = await chrome.downloads.search({ id }).catch(() => []);
394
+ item?.state === "complete" ? finish(null, item) : finish(cancelled());
395
+ };
396
+ const poll = setInterval(inspect, pollMs);
397
+ const timeout = setTimeout(async () => {
398
+ deadlineError = new Error("Download completion timed out");
399
+ await chrome.downloads.cancel(id).catch(() => {
400
+ });
401
+ finish(deadlineError);
402
+ }, deadlineMs);
403
+ waiters.set(id, inspect);
404
+ signal?.addEventListener("abort", abort, { once: true });
405
+ if (signal?.aborted) void abort();
406
+ else void inspect();
407
+ });
408
+ }
409
+ async function recover(item, signal) {
410
+ const matches = item.downloadId != null ? await chrome.downloads.search({ id: item.downloadId }) : await chrome.downloads.search({
411
+ url: `^${item.downloadUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`
412
+ });
413
+ const download = matches.find(
414
+ (d) => d.byExtensionId === chrome.runtime.id && (!item.downloadUrl || d.url === item.downloadUrl)
415
+ );
416
+ if (!download) {
417
+ await cleanup(item.downloadUrl);
418
+ throw new Error(
419
+ "Interrupted download could not be confirmed. Retry this page."
420
+ );
421
+ }
422
+ try {
423
+ return await waitForDownload(download.id, signal);
424
+ } finally {
425
+ await cleanup(item.downloadUrl);
426
+ }
427
+ }
428
+ async function downloadViaBlob(base64, mime, filename, { signal, onPrepared, onCreated } = {}) {
429
+ throwIfAborted(signal);
248
430
  await ensureOffscreen();
431
+ throwIfAborted(signal);
249
432
  const created = await chrome.runtime.sendMessage({
250
433
  target: "offscreen",
251
434
  type: "CREATE_BLOB_URL",
252
435
  base64,
253
- mime
436
+ mime,
437
+ leaseMs: blobLeaseMs
254
438
  });
255
- if (!created?.ok || !created.url) {
439
+ if (!created?.ok || !created.url)
256
440
  throw new Error(created?.error || "Failed to prepare download");
257
- }
258
441
  const url = created.url;
259
- pendingDownloadNames.set(url, filename);
260
- const cleanup = () => {
261
- pendingDownloadNames.delete(url);
262
- chrome.runtime.sendMessage({ target: "offscreen", type: "REVOKE_BLOB_URL", url }).catch(() => {
263
- });
264
- };
442
+ names.set(url, filename);
443
+ let downloadId;
265
444
  try {
266
- const downloadId = await new Promise((resolve, reject) => {
267
- chrome.downloads.download(
268
- { url, filename, conflictAction: "uniquify", saveAs: false },
269
- (id) => {
270
- const error = chrome.runtime.lastError;
271
- error ? reject(new Error(error.message)) : resolve(id);
272
- }
273
- );
445
+ await onPrepared?.(url);
446
+ throwIfAborted(signal);
447
+ downloadId = await chrome.downloads.download({
448
+ url,
449
+ filename,
450
+ conflictAction: "uniquify",
451
+ saveAs: false
274
452
  });
275
- setTimeout(cleanup, 6e4);
453
+ await onCreated?.(downloadId);
454
+ await waitForDownload(downloadId, signal);
276
455
  return downloadId;
277
456
  } catch (error) {
278
- cleanup();
457
+ if (downloadId != null)
458
+ await chrome.downloads.cancel(downloadId).catch(() => {
459
+ });
279
460
  throw error;
461
+ } finally {
462
+ await cleanup(url);
280
463
  }
281
464
  }
282
- return { downloadViaBlob };
465
+ return { downloadViaBlob, recover };
283
466
  }
284
467
 
285
- // src/session.ts
468
+ // src/scroll-capture.js
469
+ var scroll_capture_exports = {};
470
+ __export(scroll_capture_exports, {
471
+ prepare: () => prepare,
472
+ restore: () => restore
473
+ });
474
+ var sessions = /* @__PURE__ */ new WeakMap();
475
+ function pageTask(action, id, index, leaseMs) {
476
+ const key = "__zuckerScrollCapture";
477
+ let state = window[key];
478
+ if (action === "restore") {
479
+ if (state?.id === id) state.restore();
480
+ return {};
481
+ }
482
+ if (action === "init") {
483
+ if (state) throw new Error("Another scrolling-content capture is active");
484
+ const elements = [...document.querySelectorAll("*")];
485
+ if (elements.length > 3e4)
486
+ throw new Error(
487
+ "This page is too complex to expand its scrolling content safely."
488
+ );
489
+ const panels = elements.filter((el) => {
490
+ if (el === document.body || el === document.documentElement || el === document.scrollingElement || el.closest(
491
+ 'nav,aside,[role="navigation"],[role="menu"],textarea,select'
492
+ ))
493
+ return false;
494
+ const rect = el.getBoundingClientRect(), css = getComputedStyle(el);
495
+ if (rect.width < 120 || rect.height < 80 || el.scrollHeight <= el.clientHeight + 2 || !/^(auto|scroll|overlay)$/.test(css.overflowY) || css.visibility !== "visible")
496
+ return false;
497
+ for (let parent = el; parent; parent = parent.parentElement) {
498
+ const style = getComputedStyle(parent);
499
+ if (style.display === "none" || Number(style.opacity) === 0)
500
+ return false;
501
+ }
502
+ return true;
503
+ });
504
+ if (!panels.length) return { count: 0 };
505
+ if (panels.length > 24)
506
+ throw new Error(
507
+ "This page has too many independent scrolling areas. Use Visible window or export smaller sections."
508
+ );
509
+ const virtual = '.rc-virtual-list-holder,.ant-table-tbody-virtual-holder,[data-virtuoso-scroller],.ReactVirtualized__Grid,.ReactVirtualized__List,[data-virtualized="true"]';
510
+ if (panels.some(
511
+ (el) => el.matches(virtual) || [...el.querySelectorAll(virtual)].some(
512
+ (list) => list.getClientRects().length && list.checkVisibility({
513
+ checkOpacity: true,
514
+ checkVisibilityCSS: true
515
+ })
516
+ )
517
+ ))
518
+ throw new Error(
519
+ "This page uses a virtual scrolling list. A complete full-page capture is not available; use the table's export or Visible window."
520
+ );
521
+ const changes = /* @__PURE__ */ new Map(), scrolls = /* @__PURE__ */ new Map();
522
+ state = {
523
+ id,
524
+ panels,
525
+ changes,
526
+ scrolls,
527
+ boxes: /* @__PURE__ */ new Map(),
528
+ watchdog: null,
529
+ leaseMs
530
+ };
531
+ state.set = (el, property, value) => {
532
+ if (!changes.has(el))
533
+ changes.set(el, {
534
+ hadStyle: el.hasAttribute("style"),
535
+ properties: /* @__PURE__ */ new Map()
536
+ });
537
+ const saved = changes.get(el).properties;
538
+ if (!saved.has(property))
539
+ saved.set(property, {
540
+ value: el.style.getPropertyValue(property),
541
+ priority: el.style.getPropertyPriority(property)
542
+ });
543
+ el.style.setProperty(property, value, "important");
544
+ };
545
+ state.restore = () => {
546
+ clearTimeout(state.watchdog);
547
+ const scrollingProperties = /* @__PURE__ */ new Set([
548
+ "scroll-behavior",
549
+ "scroll-snap-type",
550
+ "overflow-anchor"
551
+ ]);
552
+ const restoreProperty = (el, property, old) => old.value ? el.style.setProperty(property, old.value, old.priority) : el.style.removeProperty(property);
553
+ for (const [el, saved] of [...changes].reverse())
554
+ for (const [property, old] of saved.properties)
555
+ if (!scrollingProperties.has(property))
556
+ restoreProperty(el, property, old);
557
+ for (const [el, saved] of scrolls) {
558
+ el.scrollLeft = saved.x;
559
+ el.scrollTop = saved.y;
560
+ }
561
+ for (const [el, saved] of changes) {
562
+ for (const [property, old] of saved.properties)
563
+ if (scrollingProperties.has(property))
564
+ restoreProperty(el, property, old);
565
+ if (!saved.hadStyle && !el.style.length) {
566
+ el.getAttribute("style");
567
+ el.removeAttribute("style");
568
+ }
569
+ }
570
+ if (window[key]?.id === id) delete window[key];
571
+ };
572
+ window[key] = state;
573
+ state.watchdog = setTimeout(state.restore, state.leaseMs);
574
+ for (const el of elements) {
575
+ if (!el.scrollTop && !el.scrollLeft) continue;
576
+ scrolls.set(el, { x: el.scrollLeft, y: el.scrollTop });
577
+ state.set(el, "scroll-behavior", "auto");
578
+ state.set(el, "scroll-snap-type", "none");
579
+ state.set(el, "overflow-anchor", "none");
580
+ }
581
+ for (const panel of panels) {
582
+ const rect = panel.getBoundingClientRect();
583
+ state.boxes.set(panel, { width: rect.width, x: rect.x });
584
+ scrolls.set(panel, { x: panel.scrollLeft, y: panel.scrollTop });
585
+ state.set(panel, "scroll-behavior", "auto");
586
+ state.set(panel, "scroll-snap-type", "none");
587
+ state.set(panel, "overflow-anchor", "none");
588
+ panel.scrollTop = 0;
589
+ }
590
+ return { count: panels.length };
591
+ }
592
+ if (!state || state.id !== id)
593
+ throw new Error(
594
+ "The page changed while preparing its scrolling content."
595
+ );
596
+ clearTimeout(state.watchdog);
597
+ state.watchdog = setTimeout(state.restore, state.leaseMs);
598
+ if (action === "scroll" || action === "position") {
599
+ const panel = state.panels[index];
600
+ if (!panel.isConnected)
601
+ throw new Error(
602
+ "A scrolling area changed during capture. Please retry."
603
+ );
604
+ if (action === "scroll")
605
+ panel.scrollTop += Math.max(80, panel.clientHeight * 0.8);
606
+ return {
607
+ bottom: panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 1,
608
+ height: panel.scrollHeight
609
+ };
610
+ }
611
+ if (action === "expand") {
612
+ for (const panel of [...state.panels].reverse()) {
613
+ if (!panel.isConnected)
614
+ throw new Error(
615
+ "A scrolling area changed during capture. Please retry."
616
+ );
617
+ let el = panel;
618
+ while (el) {
619
+ const rect = el.getBoundingClientRect(), css = getComputedStyle(el);
620
+ if (!state.scrolls.has(el))
621
+ state.scrolls.set(el, { x: el.scrollLeft, y: el.scrollTop });
622
+ if (!state.boxes.has(el))
623
+ state.boxes.set(el, { width: rect.width, x: rect.x });
624
+ state.set(el, "scroll-behavior", "auto");
625
+ state.set(el, "overflow-anchor", "none");
626
+ state.set(el, "transition", "none");
627
+ state.set(el, "box-sizing", "border-box");
628
+ state.set(el, "width", `${state.boxes.get(el).width}px`);
629
+ state.set(el, "max-height", "none");
630
+ state.set(el, "flex-shrink", "0");
631
+ if (css.position === "fixed") {
632
+ state.set(el, "position", "absolute");
633
+ state.set(el, "top", `${rect.top + scrollY}px`);
634
+ state.set(el, "left", `${rect.left + scrollX}px`);
635
+ state.set(el, "right", "auto");
636
+ }
637
+ if (css.position === "fixed" || css.position === "absolute")
638
+ state.set(el, "bottom", "auto");
639
+ if (state.panels.includes(el)) {
640
+ const height = el.scrollHeight + el.offsetHeight - el.clientHeight;
641
+ state.set(el, "height", `${height}px`);
642
+ state.set(el, "min-height", `${height}px`);
643
+ state.set(el, "scrollbar-gutter", "stable");
644
+ state.set(el, "overflow-y", "hidden");
645
+ el.scrollTop = 0;
646
+ } else {
647
+ state.set(el, "height", "auto");
648
+ state.set(
649
+ el,
650
+ "min-height",
651
+ `${Math.max(rect.height, el.clientHeight)}px`
652
+ );
653
+ if (/hidden|clip|auto|scroll/.test(css.overflowY)) {
654
+ state.set(el, "overflow-x", "clip");
655
+ state.set(el, "overflow-y", "visible");
656
+ }
657
+ }
658
+ if (css.contain !== "none") state.set(el, "contain", "none");
659
+ el = el.parentElement;
660
+ }
661
+ }
662
+ return { height: document.documentElement.scrollHeight };
663
+ }
664
+ if (action === "measure") {
665
+ for (const panel of state.panels) {
666
+ const rect = panel.getBoundingClientRect(), original = state.boxes.get(panel);
667
+ if (Math.abs(rect.width - original.width) > 1 || Math.abs(rect.x - original.x) > 1)
668
+ throw new Error(
669
+ "This scrolling layout cannot be expanded without changing its width. Use Visible window or the website's export."
670
+ );
671
+ }
672
+ return {
673
+ height: Math.max(
674
+ document.documentElement.scrollHeight,
675
+ document.body?.scrollHeight || 0
676
+ ),
677
+ panels: state.panels.map((el) => [el.scrollHeight, el.clientHeight]),
678
+ ready: document.fonts.status === "loaded" && [...document.images].every(
679
+ (image) => image.complete || !state.panels.some((panel) => panel.contains(image)) || !image.getClientRects().length || image.checkVisibility?.({
680
+ checkOpacity: true,
681
+ checkVisibilityCSS: true
682
+ }) === false
683
+ )
684
+ };
685
+ }
686
+ return {};
687
+ }
688
+ async function command(ctx, id, action, index, leaseMs) {
689
+ const response = await ctx[action === "restore" ? "cleanup" : "send"](
690
+ "Runtime.evaluate",
691
+ {
692
+ expression: `(${pageTask.toString()})(${JSON.stringify(action)},${JSON.stringify(id)},${JSON.stringify(index)},${JSON.stringify(leaseMs)})`,
693
+ returnByValue: true
694
+ }
695
+ );
696
+ if (response?.exceptionDetails)
697
+ throw new Error(
698
+ response.exceptionDetails.exception?.description?.split("\n")[0] || "Could not prepare scrolling content"
699
+ );
700
+ return response?.result?.value || {};
701
+ }
702
+ async function prepare(ctx, waitForContent = async () => {
703
+ }) {
704
+ const id = crypto.randomUUID();
705
+ sessions.set(ctx, id);
706
+ const readinessMs = Math.min(
707
+ 6e4,
708
+ Math.max(0, Number(ctx.options?.readyTimeoutMs) || 25e3)
709
+ );
710
+ const initial = await command(
711
+ ctx,
712
+ id,
713
+ "init",
714
+ null,
715
+ Math.max(6e4, readinessMs + 15e3)
716
+ );
717
+ if (!initial.count) {
718
+ sessions.delete(ctx);
719
+ return;
720
+ }
721
+ let deadline = Date.now() + readinessMs + 15e3;
722
+ for (let index = 0; index < initial.count; index++) {
723
+ let bottom = false;
724
+ for (let step = 0; step < 60 && Date.now() < deadline; step++) {
725
+ const position = await command(ctx, id, "scroll", index);
726
+ await new Promise((resolve) => setTimeout(resolve, 120));
727
+ if (position.bottom) {
728
+ await waitForContent();
729
+ const loaded = await command(ctx, id, "position", index);
730
+ if (loaded.bottom && loaded.height === position.height) {
731
+ bottom = true;
732
+ break;
733
+ }
734
+ }
735
+ }
736
+ if (!bottom)
737
+ throw new Error(
738
+ "Scrolling content keeps growing or exceeds the capture limit. Use Visible window or a smaller section."
739
+ );
740
+ }
741
+ await waitForContent();
742
+ await command(ctx, id, "expand");
743
+ await waitForContent();
744
+ deadline = Date.now() + 15e3;
745
+ let previous = "", stable = 0;
746
+ while (Date.now() < deadline) {
747
+ await command(ctx, id, "expand");
748
+ await new Promise((resolve) => setTimeout(resolve, 250));
749
+ const measured = await command(ctx, id, "measure");
750
+ const signature = JSON.stringify([measured.height, measured.panels]);
751
+ stable = measured.ready && signature === previous && measured.panels.every(([full, view]) => full <= view + 2) ? stable + 1 : 0;
752
+ if (stable >= 2) return;
753
+ previous = signature;
754
+ }
755
+ throw new Error(
756
+ "Scrolling content did not settle in time. Wait for it to finish loading and retry."
757
+ );
758
+ }
759
+ async function restore(ctx) {
760
+ const id = sessions.get(ctx);
761
+ if (!id) return;
762
+ sessions.delete(ctx);
763
+ await command(ctx, id, "restore");
764
+ }
765
+
766
+ // src/session.js
286
767
  function debuggerAttach(target, version) {
287
- return new Promise((resolve, reject) => {
288
- chrome.debugger.attach(target, version, () => {
768
+ return new Promise(
769
+ (resolve, reject) => chrome.debugger.attach(target, version, () => {
289
770
  const error = chrome.runtime.lastError;
290
771
  error ? reject(new Error(error.message)) : resolve();
291
- });
292
- });
772
+ })
773
+ );
293
774
  }
294
775
  function debuggerDetach(target) {
295
- return new Promise((resolve, reject) => {
296
- chrome.debugger.detach(target, () => {
776
+ return new Promise(
777
+ (resolve, reject) => chrome.debugger.detach(target, () => {
297
778
  const error = chrome.runtime.lastError;
298
779
  error ? reject(new Error(error.message)) : resolve();
299
- });
300
- });
780
+ })
781
+ );
301
782
  }
302
- function sendCommand(target, method, params) {
303
- return new Promise((resolve, reject) => {
304
- chrome.debugger.sendCommand(target, method, params || {}, (result) => {
305
- const error = chrome.runtime.lastError;
306
- error ? reject(new Error(error.message)) : resolve(result || {});
307
- });
783
+ function sendCommand(target, method, params, signal) {
784
+ throwIfAborted(signal);
785
+ return withAbort(
786
+ new Promise(
787
+ (resolve, reject) => chrome.debugger.sendCommand(target, method, params || {}, (result) => {
788
+ const error = chrome.runtime.lastError;
789
+ if (error) reject(new Error(error.message));
790
+ else if (result?.exceptionDetails)
791
+ reject(new Error("Page script could not be evaluated"));
792
+ else resolve(result || {});
793
+ })
794
+ ),
795
+ signal
796
+ );
797
+ }
798
+ async function waitForLoad(tabId, timeoutMs, signal) {
799
+ const start = Date.now();
800
+ while (Date.now() - start < timeoutMs) {
801
+ throwIfAborted(signal);
802
+ const tab = await chrome.tabs.get(tabId);
803
+ if (tab.status === "complete") return;
804
+ await delay(250, signal);
805
+ }
806
+ throw new Error("Page load timeout");
807
+ }
808
+ async function evaluatePage(target, fn, args, signal) {
809
+ const result = await sendCommand(
810
+ target,
811
+ "Runtime.evaluate",
812
+ {
813
+ expression: `(${fn.toString()})(${JSON.stringify(args ?? null)})`,
814
+ returnByValue: true
815
+ },
816
+ signal
817
+ );
818
+ return result.result?.value;
819
+ }
820
+ function watchNetwork(target) {
821
+ const pending = /* @__PURE__ */ new Set();
822
+ let changedAt = Date.now();
823
+ const listener = (source, method, params) => {
824
+ if (source.tabId !== target.tabId) return;
825
+ if (method === "Network.requestWillBeSent" && ["Document", "XHR", "Fetch", "Stylesheet", "Font"].includes(params.type)) {
826
+ pending.add(params.requestId);
827
+ changedAt = Date.now();
828
+ } else if (["Network.loadingFinished", "Network.loadingFailed"].includes(method) && pending.delete(params.requestId))
829
+ changedAt = Date.now();
830
+ };
831
+ chrome.debugger.onEvent.addListener(listener);
832
+ return {
833
+ busy: () => pending.size > 0 || Date.now() - changedAt < 750,
834
+ reset: () => {
835
+ pending.clear();
836
+ changedAt = Date.now();
837
+ },
838
+ dispose: () => chrome.debugger.onEvent.removeListener(listener)
839
+ };
840
+ }
841
+ async function preparePageForCapture(target, signal) {
842
+ await sendCommand(
843
+ target,
844
+ "Emulation.setFocusEmulationEnabled",
845
+ { enabled: true },
846
+ signal
847
+ ).catch((error) => {
848
+ throwIfAborted(signal);
308
849
  });
309
850
  }
310
- function waitForLoad(tabId, timeoutMs) {
311
- return new Promise((resolve, reject) => {
312
- let settled = false;
313
- const finish = (fn, value) => {
314
- if (!settled) {
315
- settled = true;
316
- chrome.tabs.onUpdated.removeListener(listener);
317
- clearTimeout(timer);
318
- fn(value);
319
- }
320
- };
321
- const listener = (updatedTabId, changeInfo) => {
322
- if (updatedTabId === tabId && changeInfo.status === "complete") {
323
- finish(resolve);
324
- }
325
- };
326
- const timer = setTimeout(() => finish(reject, new Error("Page load timeout")), timeoutMs);
327
- chrome.tabs.onUpdated.addListener(listener);
328
- chrome.tabs.get(tabId).then((tab) => {
329
- if (tab.status === "complete") {
330
- finish(resolve);
331
- }
332
- }).catch(() => {
851
+ function pageMetrics({ maxSeenY = 0 } = {}) {
852
+ function visible(el) {
853
+ if (!el?.getBoundingClientRect) return false;
854
+ const rect = el.getBoundingClientRect();
855
+ if (rect.width <= 2 || rect.height <= 2 || rect.top > maxSeenY + innerHeight)
856
+ return false;
857
+ for (let node = el; node instanceof Element; node = node.parentElement) {
858
+ const style = getComputedStyle(node);
859
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) < 0.05)
860
+ return false;
861
+ }
862
+ return true;
863
+ }
864
+ const text = (document.body?.innerText || "").replace(/\s+/g, " ").trim();
865
+ const strong = '[aria-busy="true"],.ant-spin-spinning,.ant-skeleton,.mantine-Loader-root,.MuiCircularProgress-root,.v-progress-circular--indeterminate,.el-loading-mask,.chakra-spinner,[role="progressbar"]:not([aria-valuenow])';
866
+ let loading = [...document.querySelectorAll(strong)].some(visible);
867
+ if (!loading)
868
+ loading = [
869
+ ...document.querySelectorAll(".loading,.loader,.spinner,.skeleton")
870
+ ].some((el) => {
871
+ if (!visible(el)) return false;
872
+ const words = (el.innerText || "").trim();
873
+ const rect = el.getBoundingClientRect();
874
+ return words.length < 160 && /^(loading\b|please wait\b|加载中|正在加载|読み込み中|로딩)/i.test(
875
+ words
876
+ ) || !words && rect.width <= 256 && rect.height <= 256 && getComputedStyle(el).animationName !== "none";
333
877
  });
334
- });
878
+ const pendingImages = [...document.images].filter(
879
+ (img) => !img.complete && visible(img) && img.width * img.height > 4e3
880
+ ).length;
881
+ const fontsPending = document.fonts?.status === "loading";
882
+ let hash = 0;
883
+ for (let i = 0; i < text.length; i += Math.max(1, Math.floor(text.length / 2e3)))
884
+ hash = Math.imul(hash, 31) + text.charCodeAt(i) | 0;
885
+ const height = Math.max(
886
+ document.body?.scrollHeight || 0,
887
+ document.documentElement.scrollHeight
888
+ );
889
+ return {
890
+ complete: document.readyState === "complete",
891
+ loading,
892
+ pendingImages,
893
+ fontsPending,
894
+ signature: [
895
+ location.href,
896
+ text.length,
897
+ hash,
898
+ height,
899
+ pendingImages,
900
+ fontsPending
901
+ ].join("|")
902
+ };
903
+ }
904
+ async function waitForPageStable(target, options, signal, network, maxSeenY = 0) {
905
+ const start = Date.now();
906
+ let signature = "", stableSince = start, last;
907
+ while (Date.now() - start < options.readyTimeoutMs) {
908
+ throwIfAborted(signal);
909
+ last = await evaluatePage(target, pageMetrics, { maxSeenY }, signal);
910
+ if (!last) throw new Error("Could not inspect page readiness");
911
+ if (last.signature !== signature || !last.complete || last.loading || last.pendingImages || last.fontsPending || network?.busy())
912
+ stableSince = Date.now();
913
+ signature = last.signature;
914
+ if (Date.now() - stableSince >= 1e3) return "";
915
+ await delay(250, signal);
916
+ }
917
+ const reason = last?.loading ? "Page still shows loading UI" : "Page content is still changing or loading";
918
+ if (options.allowUnstable)
919
+ return `${reason}; captured after the readiness timeout at your request.`;
920
+ throw new Error(
921
+ `${reason}. Increase readiness wait, or choose Capture after timeout in Settings if this page is ready.`
922
+ );
335
923
  }
336
924
  function challengeError(name) {
337
925
  const error = new Error(`Blocked by ${name} verification`);
@@ -341,122 +929,96 @@
341
929
  function isChallengeError(error) {
342
930
  return Boolean(error?.isChallenge);
343
931
  }
344
- var CHALLENGE_TITLE_RE = /just a moment|checking your browser|attention required|verifying|security checkpoint|正在验证|安全检查点|verify you are human|are you a robot|请完成验证|人机验证|captcha/;
345
- async function probePage(target) {
346
- const r = await sendCommand(target, "Runtime.evaluate", {
347
- returnByValue: true,
348
- expression: `(function(){
349
- var uri=document.documentURI||"";
350
- if(uri.indexOf("chrome-error://")===0) return {error:"Page failed to load (DNS or network error)",challenge:""};
351
- var t=(document.title||"").toLowerCase();
352
- var b=(document.body?document.body.innerText:"").slice(0,500).toLowerCase();
353
- var u=(location.href||"").toLowerCase();
354
- var s=t+" "+b+" "+u;
355
- var c="";
356
- if(/just a moment|checking your browser|attention required|cf-browser-verification|challenges\\.cloudflare/.test(s)) c="Cloudflare";
357
- else if(/verifying your browser|\u6B63\u5728\u9A8C\u8BC1\u60A8\u7684\u6D4F\u89C8\u5668|security checkpoint|\u5B89\u5168\u68C0\u67E5\u70B9/.test(s)) c="Vercel";
358
- else if(/verify you are human|are you a robot|\u8BF7\u5B8C\u6210\u9A8C\u8BC1|\u4EBA\u673A\u9A8C\u8BC1|captcha/.test(s)) c="bot-check";
359
- return {error:"",challenge:c};
360
- })()`
932
+ function pageProbe() {
933
+ if (document.documentURI.startsWith("chrome-error://"))
934
+ return {
935
+ error: "Page failed to load (DNS or network error)",
936
+ challenge: ""
937
+ };
938
+ const shown = (selector) => [...document.querySelectorAll(selector)].some((el) => {
939
+ const r = el.getBoundingClientRect();
940
+ return r.width > 0 && r.height > 0 && el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
361
941
  });
362
- const value = r.result?.value;
363
- return value || { error: "", challenge: "" };
942
+ const title = document.title.trim().toLowerCase();
943
+ let challenge = "";
944
+ if (shown("#challenge-form,#challenge-stage,#cf-challenge-running") && /just a moment|checking your browser|attention required|verify/.test(
945
+ title
946
+ ))
947
+ challenge = "Cloudflare";
948
+ else if (shown("#vercel-security-checkpoint,#challenge-container") && /security checkpoint|verifying your browser|安全检查点/.test(title))
949
+ challenge = "Vercel";
950
+ else if (/^(verify you are human|human verification|are you a robot|captcha|人机验证)[\s.!?—-]*$/i.test(
951
+ title
952
+ ) && shown(
953
+ 'iframe[src*="recaptcha"],iframe[src*="hcaptcha"],iframe[src*="challenges.cloudflare.com"],input[name*="captcha"],[data-sitekey]'
954
+ ))
955
+ challenge = "bot-check";
956
+ return { error: "", challenge };
957
+ }
958
+ async function probePage(target, signal) {
959
+ return await evaluatePage(target, pageProbe, null, signal) || {
960
+ error: "",
961
+ challenge: ""
962
+ };
364
963
  }
365
- async function waitForVerification(tabId, shouldStop, timeoutMs = 18e4) {
964
+ async function waitForVerification(tabId, shouldStop, signal, timeoutMs = 18e4) {
366
965
  const start = Date.now();
367
966
  while (Date.now() - start < timeoutMs) {
368
- if (shouldStop()) {
369
- return false;
370
- }
371
- await delay(2500);
967
+ throwIfAborted(signal);
968
+ if (shouldStop()) return false;
969
+ await delay(2500, signal);
372
970
  const tab = await chrome.tabs.get(tabId).catch(() => null);
373
- if (!tab) {
374
- return false;
375
- }
376
- const title = (tab.title || "").toLowerCase();
377
- const url = (tab.url || "").toLowerCase();
378
- if (url.startsWith("chrome-error")) {
379
- continue;
380
- }
381
- if (title && !CHALLENGE_TITLE_RE.test(title)) {
382
- return true;
971
+ if (!tab) return false;
972
+ if (tab.status !== "complete") continue;
973
+ const target = { tabId };
974
+ let attached = false;
975
+ try {
976
+ await debuggerAttach(target, "1.3");
977
+ attached = true;
978
+ const result = await probePage(target, signal);
979
+ if (!result.challenge && !result.error) return true;
980
+ } catch {
981
+ throwIfAborted(signal);
982
+ } finally {
983
+ if (attached) await debuggerDetach(target).catch(() => {
984
+ });
383
985
  }
384
986
  }
385
987
  return false;
386
988
  }
387
989
  async function focusTab(tabId) {
388
- await chrome.tabs.update(tabId, { active: true }).catch(() => {
389
- });
390
- try {
391
- const tab = await chrome.tabs.get(tabId);
392
- if (tab.windowId != null) {
393
- await chrome.windows.update(tab.windowId, { focused: true });
394
- }
395
- } catch {
396
- }
397
- }
398
- function autoScroll(target) {
399
- return sendCommand(target, "Runtime.evaluate", {
400
- awaitPromise: true,
401
- expression: `new Promise(function(resolve){
402
- var distance=600, scrolled=0, guard=0;
403
- var maxHeight=Math.min(document.body?document.body.scrollHeight:0, 20000);
404
- var timer=setInterval(function(){
405
- window.scrollBy(0, distance);
406
- scrolled+=distance; guard+=1;
407
- var reached=scrolled>=Math.max(maxHeight, document.body?document.body.scrollHeight:0);
408
- if(reached || guard>60){
409
- clearInterval(timer);
410
- setTimeout(function(){ window.scrollTo(0,0); setTimeout(resolve, 400); }, 300);
411
- }
412
- }, 120);
413
- })`
414
- }).catch(() => void 0);
990
+ const tab = await chrome.tabs.update(tabId, { active: true });
991
+ await chrome.windows.update(tab.windowId, { focused: true });
415
992
  }
416
- function revealAnimations(target, reversible = false) {
417
- return sendCommand(target, "Runtime.evaluate", {
418
- expression: `(function(){
419
- try{
420
- var reversible=${reversible ? "true" : "false"};
421
- var state=reversible?{style:null,aos:[],inline:[]}:null;
422
- var css=document.createElement('style');
423
- css.textContent=[
424
- '[data-aos]{opacity:1!important;transform:none!important;transition:none!important;}',
425
- '.wow{visibility:visible!important;opacity:1!important;animation-name:none!important;}',
426
- '.animate__animated,.animated{opacity:1!important;}',
427
- '[data-scroll]{opacity:1!important;transform:none!important;}',
428
- '.reveal,[data-sr],[data-sr-id],.sr-reveal{opacity:1!important;transform:none!important;visibility:visible!important;}'
429
- ].join('');
430
- (document.head||document.documentElement).appendChild(css);
431
- if(state) state.style=css;
432
- document.querySelectorAll('[data-aos]').forEach(function(el){
433
- if(state&&!el.classList.contains('aos-animate')) state.aos.push(el);
434
- el.classList.add('aos-animate');
435
- });
436
- // \u901A\u7528\u515C\u5E95\uFF1A\u88AB\u52A8\u753B\u85CF\u8D77\u6765\u7684\u5185\u5BB9\u5757\uFF08opacity\u22480 \u4E14\u5E26 transition/animation\uFF09\u5F3A\u5236\u663E\u793A\u3002
437
- // \u53EA\u5904\u7406 static/relative\uFF08\u666E\u901A\u5185\u5BB9\u6D41\uFF09\uFF0C\u8DF3\u8FC7 fixed/absolute\uFF08\u591A\u4E3A\u5F39\u7A97/\u83DC\u5355/\u8F6E\u64AD\uFF09\uFF0C\u907F\u514D\u8BEF\u4F24
438
- var all=document.body?document.body.querySelectorAll('*'):[];
439
- for(var i=0;i<all.length;i++){
440
- var el=all[i], s=getComputedStyle(el);
441
- if(parseFloat(s.opacity)<0.05 && (s.transitionDuration!=='0s'||s.animationName!=='none')){
442
- var p=s.position;
443
- if(p==='static'||p==='relative'){
444
- if(state) state.inline.push({
445
- el:el,
446
- opacity:el.style.getPropertyValue('opacity'),
447
- opacityPriority:el.style.getPropertyPriority('opacity'),
448
- transform:el.style.getPropertyValue('transform'),
449
- transformPriority:el.style.getPropertyPriority('transform')
450
- });
451
- el.style.setProperty('opacity','1','important');
452
- el.style.setProperty('transform','none','important');
453
- }
454
- }
455
- }
456
- if(state) window.__zuckerExtRevealState=state;
457
- }catch(e){}
458
- })()`
459
- }).catch(() => void 0);
993
+ async function autoScroll(target, signal) {
994
+ let maxSeenY = 0;
995
+ for (let step = 0; step < 60; step++) {
996
+ const position = await evaluatePage(
997
+ target,
998
+ () => {
999
+ window.scrollBy({ top: 600, behavior: "instant" });
1000
+ return {
1001
+ y: scrollY,
1002
+ bottom: scrollY + innerHeight >= Math.max(
1003
+ document.documentElement.scrollHeight,
1004
+ document.body?.scrollHeight || 0
1005
+ ) - 1
1006
+ };
1007
+ },
1008
+ null,
1009
+ signal
1010
+ );
1011
+ maxSeenY = Math.max(maxSeenY, position.y);
1012
+ await delay(120, signal);
1013
+ if (position.bottom) break;
1014
+ }
1015
+ await evaluatePage(
1016
+ target,
1017
+ () => window.scrollTo({ left: 0, top: 0, behavior: "instant" }),
1018
+ null,
1019
+ signal
1020
+ );
1021
+ return maxSeenY;
460
1022
  }
461
1023
  async function getScrollPosition(target) {
462
1024
  const result = await sendCommand(target, "Runtime.evaluate", {
@@ -466,71 +1028,91 @@
466
1028
  const value = result.result?.value;
467
1029
  return value || { x: 0, y: 0 };
468
1030
  }
1031
+ function hidePageCaptureDecorations() {
1032
+ const hosts = [...document.querySelectorAll("browser-mcp-container")];
1033
+ window.__zuckerExtCaptureDecorations = hosts.map((el) => ({
1034
+ el,
1035
+ display: el.style.getPropertyValue("display"),
1036
+ priority: el.style.getPropertyPriority("display")
1037
+ }));
1038
+ hosts.forEach((el) => el.style.setProperty("display", "none", "important"));
1039
+ return {
1040
+ width: innerWidth,
1041
+ height: innerHeight,
1042
+ dpr: devicePixelRatio || 1
1043
+ };
1044
+ }
1045
+ function showPageCaptureDecorations() {
1046
+ for (const saved of window.__zuckerExtCaptureDecorations || []) {
1047
+ if (saved.display)
1048
+ saved.el.style.setProperty("display", saved.display, saved.priority);
1049
+ else saved.el.style.removeProperty("display");
1050
+ }
1051
+ delete window.__zuckerExtCaptureDecorations;
1052
+ }
1053
+ function suppressCaptureDecorations(target, signal) {
1054
+ return sendCommand(
1055
+ target,
1056
+ "Runtime.evaluate",
1057
+ { expression: `(${hidePageCaptureDecorations.toString()})()` },
1058
+ signal
1059
+ );
1060
+ }
1061
+ function restoreCaptureDecorations(target) {
1062
+ return sendCommand(target, "Runtime.evaluate", {
1063
+ expression: `(${showPageCaptureDecorations.toString()})()`
1064
+ });
1065
+ }
469
1066
  function restorePageAfterQuick(target, position) {
470
1067
  const x = Number(position?.x) || 0;
471
1068
  const y = Number(position?.y) || 0;
472
1069
  return sendCommand(target, "Runtime.evaluate", {
473
- expression: `(function(){
474
- try{
475
- var state=window.__zuckerExtRevealState;
476
- if(state){
477
- if(state.style&&state.style.remove) state.style.remove();
478
- (state.aos||[]).forEach(function(el){ el.classList.remove('aos-animate'); });
479
- (state.inline||[]).forEach(function(saved){
480
- if(saved.opacity) saved.el.style.setProperty('opacity',saved.opacity,saved.opacityPriority||'');
481
- else saved.el.style.removeProperty('opacity');
482
- if(saved.transform) saved.el.style.setProperty('transform',saved.transform,saved.transformPriority||'');
483
- else saved.el.style.removeProperty('transform');
484
- });
485
- delete window.__zuckerExtRevealState;
486
- }
487
- }catch(e){}
488
- window.scrollTo(${x}, ${y});
489
- })()`
1070
+ expression: `window.scrollTo({ left: ${x}, top: ${y}, behavior: "instant" })`
490
1071
  });
491
1072
  }
492
1073
 
493
- // src/engine.ts
494
- var LOAD_TIMEOUT_MS = 3e4;
495
- var SAME_HOST_MIN_GAP_MS = 1800;
496
- var DEFAULT_TEXTS = {
497
- alreadyRunning: "Another capture is already running",
498
- cannotQuick: "This page cannot be captured. Open an http(s) website first.",
499
- challengeBeforeQuick: (challenge) => `Complete the ${challenge} verification before capturing this page.`,
500
- quickFolder: "Quick Captures"
501
- };
1074
+ // src/engine.js
502
1075
  function initExtension(config) {
503
- const texts = { ...DEFAULT_TEXTS, ...config.texts || {} };
504
- const freeDailyLimit = config.freeDailyLimit ?? 30;
505
- const USAGE_KEY = `${config.product}_free_usage`;
506
- const BATCH_KEY = `${config.product}_batch_state`;
1076
+ if (!Number.isSafeInteger(config.freeDailyLimit) || config.freeDailyLimit < 0)
1077
+ throw new RangeError("freeDailyLimit must be an explicit non-negative integer");
1078
+ const normalizeSettings = (defaults, input) => {
1079
+ const settings = config.normalizeSettings ? config.normalizeSettings(defaults, input) : { ...defaults, ...input && typeof input === "object" && !Array.isArray(input) ? input : {} };
1080
+ return {
1081
+ ...settings,
1082
+ delayMs: Math.min(1e4, Math.max(0, Number(settings.delayMs) || 0)),
1083
+ readyTimeoutMs: Math.min(6e4, Math.max(5e3, Number(settings.readyTimeoutMs) || 25e3)),
1084
+ allowUnstable: settings.allowUnstable === true,
1085
+ ignoreChallenge: settings.ignoreChallenge === true,
1086
+ expandScrollAreas: settings.expandScrollAreas !== false
1087
+ };
1088
+ };
1089
+ const scrollCapture = config.scrollCapture || scroll_capture_exports;
1090
+ const texts = {
1091
+ alreadyRunning: "Another capture is already running",
1092
+ cannotQuick: "Open an http(s) website first.",
1093
+ challengeBeforeQuick: (name) => `Complete the ${name} verification, or enable Capture verification page in Settings if this is a normal page.`,
1094
+ quickFolder: "Quick Captures",
1095
+ ...config.texts
1096
+ };
507
1097
  const prefix = config.messagePrefix;
508
- const licensing = createLicensing(config.product, config.brand, config.licensing);
1098
+ const BATCH_KEY = `${config.product}_batch_state`, QUICK_KEY = `${config.product}_quick_state`;
1099
+ const USAGE_KEY = `${config.product}_free_usage`, TAB_KEY = `${config.product}_owned_tab`;
1100
+ const licensing = createLicensing(
1101
+ config.product,
1102
+ config.brand,
1103
+ config.licensing
1104
+ );
509
1105
  const downloader = createDownloader(
510
- `Convert large ${config.brand} output into a downloadable blob URL.`
1106
+ `Prepare local ${config.brand} downloads.`,
1107
+ config.downloads
511
1108
  );
1109
+ let activeRun = null;
512
1110
  let batch = idleBatch();
513
- let restorePromise = restoreBatch();
514
- let quickRunning = false;
515
- chrome.runtime.onInstalled.addListener(() => {
516
- const previousRestore = restorePromise;
517
- restorePromise = (async () => {
518
- await previousRestore.catch(() => {
519
- });
520
- batch = idleBatch();
521
- await storageRemove(BATCH_KEY);
522
- setBadge("");
523
- })();
524
- });
525
- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
526
- if (message?.target === "offscreen") {
527
- return false;
528
- }
529
- handleMessage(message).then(sendResponse).catch((error) => sendResponse({ ok: false, error: normalizeError(error) }));
530
- return true;
531
- });
1111
+ let restorePromise;
532
1112
  function idleBatch() {
533
1113
  return {
1114
+ version: 2,
1115
+ id: crypto.randomUUID(),
534
1116
  running: false,
535
1117
  cancelRequested: false,
536
1118
  startedAt: null,
@@ -539,417 +1121,777 @@
539
1121
  done: 0,
540
1122
  success: 0,
541
1123
  failed: 0,
1124
+ cancelled: 0,
542
1125
  current: "",
543
1126
  freeLimited: false,
544
1127
  needVerify: null,
545
- skipVerify: false,
546
- settings: { ...config.defaultSettings },
1128
+ settings: normalizeSettings(config.defaultSettings),
547
1129
  pro: false,
548
1130
  items: []
549
1131
  };
550
1132
  }
551
- async function handleMessage(message) {
552
- await restorePromise;
553
- switch (message.type) {
554
- case `${prefix}_START`:
555
- return startBatch(message.urls, message.settings);
556
- // 两个后缀都接受:CAPTURE_CURRENT(截图类)/ CONVERT_CURRENT(转换类),兼容既有 popup
557
- case `${prefix}_CAPTURE_CURRENT`:
558
- case `${prefix}_CONVERT_CURRENT`:
559
- return quickCapture(message.tabId, message.settings);
560
- case `${prefix}_CANCEL`:
561
- return cancelBatch();
562
- case `${prefix}_CLEAR_BATCH`:
563
- return clearBatch();
564
- case `${prefix}_SKIP_VERIFY`:
565
- batch.skipVerify = true;
566
- await persistBatch();
567
- return { ok: true };
568
- case `${prefix}_STATE`:
569
- return { ok: true, batch: publicBatch(), quota: await getQuota() };
570
- case `${prefix}_ACTIVATE`:
571
- await licensing.activate(message.code);
572
- return { ok: true, quota: await getQuota() };
573
- case `${prefix}_CLEAR_LICENSE`:
574
- await licensing.clear();
575
- return { ok: true, quota: await getQuota() };
576
- default:
577
- return { ok: false, error: "Unknown message" };
578
- }
1133
+ function reserveRun(restoring = false) {
1134
+ if (activeRun || !restoring && batch.running) return null;
1135
+ activeRun = {
1136
+ controller: new AbortController(),
1137
+ job: null,
1138
+ skipVerify: false,
1139
+ windowId: null,
1140
+ idleTabId: null
1141
+ };
1142
+ return activeRun;
579
1143
  }
580
- async function getQuota() {
581
- const entitlement = await licensing.getEntitlement().catch(() => null);
582
- if (entitlement) {
583
- return { pro: true, expiresAt: entitlement.expiresAt, limit: null, used: 0, remaining: 999999 };
1144
+ function releaseRun(run) {
1145
+ if (activeRun === run) {
1146
+ activeRun = null;
1147
+ setBadge("");
584
1148
  }
585
- const data = await storageGet(USAGE_KEY);
586
- const usage = data[USAGE_KEY];
587
- const today = todayString();
588
- const used = usage && usage.date === today ? usage.used || 0 : 0;
589
- return { pro: false, limit: freeDailyLimit, used, remaining: Math.max(0, freeDailyLimit - used) };
590
1149
  }
591
- async function recordUsage(count) {
592
- const data = await storageGet(USAGE_KEY);
593
- const today = todayString();
1150
+ function recount(job) {
1151
+ job.success = job.items.filter((i) => i.status === "success").length;
1152
+ job.failed = job.items.filter((i) => i.status === "failed").length;
1153
+ job.cancelled = job.items.filter((i) => i.status === "cancelled").length;
1154
+ job.done = job.success + job.failed + job.cancelled;
1155
+ }
1156
+ function publicBatch() {
1157
+ return {
1158
+ ...batch,
1159
+ busy: Boolean(activeRun),
1160
+ // Marked running with no live controller: a final write failed and left
1161
+ // downloads unconfirmed. The popup must stay usable in this state.
1162
+ stalled: Boolean(batch.running && !activeRun),
1163
+ items: batch.items.map(
1164
+ ({ url, index, status, fileName, error, warning }) => ({
1165
+ url,
1166
+ index,
1167
+ status,
1168
+ fileName,
1169
+ error,
1170
+ warning
1171
+ })
1172
+ )
1173
+ };
1174
+ }
1175
+ const persist = (job) => storageSet({ [BATCH_KEY]: job });
1176
+ function usageFrom(data) {
594
1177
  const usage = data[USAGE_KEY];
595
- const used = usage && usage.date === today ? usage.used || 0 : 0;
596
- await storageSet({ [USAGE_KEY]: { date: today, used: used + count } });
1178
+ return usage?.date === todayString() && Number.isSafeInteger(usage.used) && usage.used >= 0 ? usage.used : 0;
597
1179
  }
598
- async function startBatch(urls, settings) {
599
- if (batch.running || quickRunning) {
600
- return { ok: false, error: texts.alreadyRunning };
601
- }
602
- const normalized = normalizeUrls(urls);
603
- if (!normalized.length) {
604
- return { ok: false, error: "No valid http(s) URLs" };
605
- }
606
- const quota = await getQuota();
607
- if (!quota.pro && quota.remaining <= 0) {
1180
+ async function getQuota(cachedOnly = false, force = false) {
1181
+ const status = await licensing.getStatus({ cachedOnly, force });
1182
+ const license = {
1183
+ licenseStatus: status.state,
1184
+ licenseMessage: status.message || "",
1185
+ licenseRefreshing: Boolean(status.refreshing),
1186
+ hasLicense: status.state !== "free"
1187
+ };
1188
+ if (status.entitlement)
608
1189
  return {
609
- ok: false,
610
- error: `Daily free limit reached (${quota.limit} pages/day). Go Pro for unlimited pages.`
1190
+ ...license,
1191
+ pro: true,
1192
+ expiresAt: status.entitlement.expiresAt,
1193
+ limit: null,
1194
+ used: 0,
1195
+ remaining: null
611
1196
  };
612
- }
613
- const freeLimited = !quota.pro && normalized.length > quota.remaining;
614
- const accepted = quota.pro ? normalized : normalized.slice(0, quota.remaining);
615
- const options = { ...config.defaultSettings, ...settings || {} };
616
- batch = {
617
- ...idleBatch(),
618
- running: true,
619
- startedAt: Date.now(),
620
- folder: `${config.brand}/${folderNameFor(accepted)}`,
621
- total: accepted.length,
622
- freeLimited,
623
- settings: options,
624
- pro: quota.pro,
625
- items: accepted.map((url, index) => ({
626
- url,
627
- index: index + 1,
628
- status: "pending",
629
- fileName: "",
630
- error: "",
631
- warning: "",
632
- usageRecorded: false
633
- }))
1197
+ if (status.state !== "free")
1198
+ return {
1199
+ ...license,
1200
+ pro: false,
1201
+ limit: null,
1202
+ used: 0,
1203
+ remaining: null
1204
+ };
1205
+ const used = usageFrom(await storageGet(USAGE_KEY));
1206
+ const limit = config.freeDailyLimit;
1207
+ return {
1208
+ ...license,
1209
+ pro: false,
1210
+ limit,
1211
+ used,
1212
+ remaining: Math.max(0, limit - used)
634
1213
  };
635
- await persistBatch();
636
- runBatch(options).catch((error) => {
637
- batch.running = false;
638
- batch.current = normalizeError(error);
639
- persistBatch();
640
- });
641
- return { ok: true, batch: publicBatch(), quota: await getQuota() };
642
1214
  }
643
- async function quickCapture(tabId, settings) {
644
- if (batch.running || quickRunning) {
645
- return { ok: false, error: texts.alreadyRunning };
1215
+ function assertQuota(quota) {
1216
+ if (quota.pro) return;
1217
+ if (quota.hasLicense)
1218
+ throw new Error(
1219
+ quota.licenseMessage || "Checking your Pro license. Use Recheck Pro in License and retry."
1220
+ );
1221
+ if (!quota.remaining)
1222
+ throw new Error(
1223
+ `Daily free limit reached (${quota.limit} pages/day). Already purchased Pro? Enter your original activation code in License to restore unlimited exports.`
1224
+ );
1225
+ }
1226
+ async function commitResult(job, item, status, error = "", quick = false) {
1227
+ const previous = {
1228
+ status: item.status,
1229
+ error: item.error,
1230
+ usageRecorded: item.usageRecorded
1231
+ };
1232
+ let used;
1233
+ if (status === "success" && item.chargeFree && !item.usageRecorded) {
1234
+ used = usageFrom(await storageGet(USAGE_KEY)) + 1;
646
1235
  }
647
- if (!Number.isInteger(tabId)) {
648
- return { ok: false, error: "No active web page" };
1236
+ item.status = status;
1237
+ item.error = error;
1238
+ if (used !== void 0) item.usageRecorded = true;
1239
+ if (!quick) recount(job);
1240
+ try {
1241
+ await storageSet({
1242
+ [quick ? QUICK_KEY : BATCH_KEY]: quick ? null : job,
1243
+ ...used !== void 0 ? { [USAGE_KEY]: { date: todayString(), used } } : {}
1244
+ });
1245
+ } catch (error2) {
1246
+ Object.assign(item, previous);
1247
+ if (!quick) recount(job);
1248
+ throw error2;
649
1249
  }
650
- const tab = await chrome.tabs.get(tabId).catch(() => null);
651
- if (!tab?.url || !/^https?:\/\//i.test(tab.url)) {
652
- return { ok: false, error: texts.cannotQuick };
1250
+ }
1251
+ function makeItem(url, index) {
1252
+ return {
1253
+ url,
1254
+ index,
1255
+ status: "pending",
1256
+ fileName: "",
1257
+ error: "",
1258
+ warning: "",
1259
+ usageRecorded: false
1260
+ };
1261
+ }
1262
+ async function recoverQuick(signal) {
1263
+ const quick = (await storageGet(QUICK_KEY))[QUICK_KEY];
1264
+ if (!quick) return;
1265
+ if (quick.downloadId == null && !quick.downloadUrl) {
1266
+ await storageRemove(QUICK_KEY);
1267
+ return;
653
1268
  }
654
- const quota = await getQuota();
655
- if (!quota.pro && quota.remaining <= 0) {
656
- return {
657
- ok: false,
658
- error: `Daily free limit reached (${quota.limit} pages/day). Go Pro for unlimited pages.`
1269
+ let recovered = false;
1270
+ try {
1271
+ await downloader.recover(quick, signal);
1272
+ recovered = true;
1273
+ } catch {
1274
+ await storageRemove(QUICK_KEY);
1275
+ }
1276
+ if (recovered) await commitResult(null, quick, "success", "", true);
1277
+ }
1278
+ async function startBatch(urls, settings) {
1279
+ const run = reserveRun();
1280
+ if (!run) return { ok: false, error: texts.alreadyRunning };
1281
+ try {
1282
+ await recoverQuick(run.controller.signal);
1283
+ const normalized = normalizeUrls(urls);
1284
+ if (!normalized.length) throw new Error("No valid http(s) URLs");
1285
+ const quota = await withAbort(getQuota(), run.controller.signal);
1286
+ throwIfAborted(run.controller.signal);
1287
+ assertQuota(quota);
1288
+ const accepted = quota.pro ? normalized : normalized.slice(0, quota.remaining);
1289
+ batch = {
1290
+ ...idleBatch(),
1291
+ running: true,
1292
+ startedAt: Date.now(),
1293
+ folder: `${config.brand}/${folderNameFor(accepted)}-${crypto.randomUUID().slice(0, 6)}`,
1294
+ total: accepted.length,
1295
+ freeLimited: accepted.length < normalized.length,
1296
+ settings: normalizeSettings(config.defaultSettings, settings),
1297
+ pro: quota.pro,
1298
+ items: accepted.map((url, index) => makeItem(url, index + 1))
659
1299
  };
1300
+ run.job = batch;
1301
+ await persist(batch);
1302
+ void runBatch(batch, run);
1303
+ return { ok: true, batch: publicBatch(), quota };
1304
+ } catch (error) {
1305
+ releaseRun(run);
1306
+ throw error;
660
1307
  }
661
- quickRunning = true;
662
- setBadge("1");
1308
+ }
1309
+ async function runBatch(job, run, { keepLock = false } = {}) {
1310
+ const signal = run.controller.signal;
1311
+ const lastHostAt = /* @__PURE__ */ new Map();
663
1312
  try {
664
- const options = { ...config.defaultSettings, ...settings || {} };
665
- const result = await captureExistingTab(tabId, tab.url, options);
666
- if (!quota.pro) {
667
- await recordUsage(1);
1313
+ for (const item of job.items) {
1314
+ if (["success", "failed", "cancelled"].includes(item.status))
1315
+ continue;
1316
+ if (item.downloadId != null || item.downloadUrl) {
1317
+ let error2;
1318
+ try {
1319
+ await downloader.recover(item, signal);
1320
+ } catch (failure) {
1321
+ error2 = failure;
1322
+ }
1323
+ await commitResult(
1324
+ job,
1325
+ item,
1326
+ error2 ? signal.aborted || error2.message === "Cancelled" ? "cancelled" : "failed" : "success",
1327
+ error2 ? normalizeError(error2) : ""
1328
+ );
1329
+ continue;
1330
+ }
1331
+ throwIfAborted(signal);
1332
+ const quota = await withAbort(getQuota(), signal);
1333
+ assertQuota(quota);
1334
+ const host = hostOf(item.url), previous = lastHostAt.get(host) || 0;
1335
+ await delay(Math.max(0, (config.sameHostGapMs ?? 1800) - (Date.now() - previous)), signal);
1336
+ lastHostAt.set(host, Date.now());
1337
+ item.chargeFree = !quota.pro;
1338
+ item.status = "running";
1339
+ job.current = item.url;
1340
+ setBadge(`${job.done + 1}/${job.total}`);
1341
+ await persist(job);
1342
+ let error;
1343
+ try {
1344
+ await captureUrl(item, job, run);
1345
+ } catch (failure) {
1346
+ error = failure;
1347
+ }
1348
+ await commitResult(
1349
+ job,
1350
+ item,
1351
+ error ? signal.aborted || error.message === "Cancelled" ? "cancelled" : "failed" : "success",
1352
+ error ? normalizeError(error) : ""
1353
+ );
668
1354
  }
669
- setBadge("OK");
670
- setTimeout(() => setBadge(""), 4e3);
671
- return { ok: true, result, quota: await getQuota() };
672
1355
  } catch (error) {
673
- setBadge("ERR");
674
- setTimeout(() => setBadge(""), 4e3);
675
- return { ok: false, error: normalizeError(error), quota: await getQuota() };
1356
+ const message = normalizeError(error);
1357
+ job.current = message;
1358
+ if (!signal.aborted && !job.cancelRequested) {
1359
+ for (const item of job.items)
1360
+ if (item.status === "pending") {
1361
+ item.status = "failed";
1362
+ item.error = message;
1363
+ }
1364
+ }
676
1365
  } finally {
677
- quickRunning = false;
1366
+ if (signal.aborted || job.cancelRequested) {
1367
+ job.cancelRequested = true;
1368
+ for (const item of job.items)
1369
+ if (["pending", "running"].includes(item.status))
1370
+ item.status = "cancelled";
1371
+ job.current = "Stopped";
1372
+ } else if (job.items.every(
1373
+ (i) => ["success", "failed", "cancelled"].includes(i.status)
1374
+ ))
1375
+ job.current = "Done";
1376
+ job.running = job.items.some((item) => item.status === "downloading");
1377
+ if (job.running)
1378
+ job.current = "Some downloads could not be confirmed. Press Stop to reconcile them.";
1379
+ job.needVerify = null;
1380
+ recount(job);
1381
+ try {
1382
+ await persist(job);
1383
+ } catch {
1384
+ console.warn(`${config.brand}: could not save final task state`);
1385
+ }
1386
+ try {
1387
+ await closeBatchWindow(run);
1388
+ } finally {
1389
+ if (!keepLock) releaseRun(run);
1390
+ }
678
1391
  }
679
1392
  }
680
- async function captureExistingTab(tabId, pageUrl, options) {
681
- const target = { tabId };
682
- let attached = false;
683
- let scrollPosition = { x: 0, y: 0 };
1393
+ async function quickCapture(tabId, settings) {
1394
+ const run = reserveRun();
1395
+ if (!run) return { ok: false, error: texts.alreadyRunning };
1396
+ const signal = run.controller.signal;
684
1397
  try {
685
- await debuggerAttach(target, "1.3");
686
- attached = true;
687
- await sendCommand(target, "Page.enable");
688
- await sendCommand(target, "Runtime.enable");
689
- const probe = await probePage(target);
690
- if (probe.error) {
691
- throw new Error(probe.error);
692
- }
693
- if (probe.challenge) {
694
- throw new Error(texts.challengeBeforeQuick(probe.challenge));
1398
+ await recoverQuick(signal);
1399
+ if (!Number.isInteger(tabId)) throw new Error("No active web page");
1400
+ const tab = await chrome.tabs.get(tabId);
1401
+ if (!/^https?:\/\//i.test(tab?.url || ""))
1402
+ throw new Error(texts.cannotQuick);
1403
+ const quota = await withAbort(getQuota(), signal);
1404
+ assertQuota(quota);
1405
+ const item = { ...makeItem(tab.url, 1), chargeFree: !quota.pro };
1406
+ const options = normalizeSettings(config.defaultSettings, settings);
1407
+ await storageSet({ [QUICK_KEY]: item });
1408
+ const visibleOutput = await config.captureQuick?.({
1409
+ tab,
1410
+ options,
1411
+ signal
1412
+ });
1413
+ if (visibleOutput)
1414
+ return await deliverQuick(item, visibleOutput, signal);
1415
+ const target = { tabId };
1416
+ let attached = false, position = null;
1417
+ const network = watchNetwork(target);
1418
+ const ctx = makeContext(
1419
+ "quick",
1420
+ tabId,
1421
+ item.url,
1422
+ options,
1423
+ target,
1424
+ signal
1425
+ );
1426
+ try {
1427
+ await debuggerAttach(target, "1.3");
1428
+ attached = true;
1429
+ if ((config.captureScope?.(options) || "full") === "full")
1430
+ position = await getScrollPosition(target);
1431
+ await config.beforeQuick?.(ctx, tab);
1432
+ await sendCommand(target, "Network.enable", {}, signal);
1433
+ if ((config.captureScope?.(options) || "full") === "full") {
1434
+ await preparePageForCapture(target, signal);
1435
+ }
1436
+ await suppressCaptureDecorations(target, signal);
1437
+ await prepareCapture(target, item, options, run, network, "quick");
1438
+ const output = await capturePrepared(ctx, options, run, network);
1439
+ throwIfAborted(signal);
1440
+ return await deliverQuick(item, output, signal);
1441
+ } finally {
1442
+ network.dispose();
1443
+ if (attached) {
1444
+ await config.afterQuick?.(ctx).catch(() => {
1445
+ });
1446
+ await restoreCaptureDecorations(target).catch(() => {
1447
+ });
1448
+ if (position && await isCurrentQuickPage(ctx))
1449
+ await restorePageAfterQuick(target, position).catch(() => {
1450
+ });
1451
+ await debuggerDetach(target).catch(() => {
1452
+ });
1453
+ }
695
1454
  }
696
- scrollPosition = await getScrollPosition(target);
697
- await delay(clamp(Number(options.delayMs) || Number(config.defaultSettings.delayMs) || 1500, 0, 1e4));
698
- await autoScroll(target);
699
- await revealAnimations(target, true);
700
- await delay(400);
701
- const output = await config.capture(makeContext("quick", tabId, pageUrl, options, target));
702
- const fileName = buildQuickFileName(config.brand, texts.quickFolder, pageUrl, output.extension);
703
- await downloader.downloadViaBlob(output.base64, output.mime, fileName);
704
- return { fileName, warning: output.warning || "" };
1455
+ } catch (error) {
1456
+ const saved = (await storageGet(QUICK_KEY))[QUICK_KEY];
1457
+ if (!saved?.downloadId && !saved?.downloadUrl)
1458
+ await storageRemove(QUICK_KEY);
1459
+ return {
1460
+ ok: false,
1461
+ error: normalizeError(error),
1462
+ quota: await getQuota(true)
1463
+ };
705
1464
  } finally {
706
- if (attached) {
707
- await restorePageAfterQuick(target, scrollPosition).catch(() => {
708
- });
709
- await debuggerDetach(target).catch(() => {
710
- });
711
- }
1465
+ releaseRun(run);
712
1466
  }
713
1467
  }
714
- async function cancelBatch() {
715
- if (batch.running) {
716
- batch.cancelRequested = true;
717
- batch.current = "Stopping\u2026";
718
- await persistBatch();
1468
+ async function deliverQuick(item, output, signal) {
1469
+ throwIfAborted(signal);
1470
+ item.warning = [item.warning, output.warning].filter(Boolean).join(" ");
1471
+ item.fileName = buildQuickFileName(
1472
+ config.brand,
1473
+ texts.quickFolder,
1474
+ item.url,
1475
+ output.extension
1476
+ );
1477
+ await saveOutput(
1478
+ item,
1479
+ output,
1480
+ signal,
1481
+ () => storageSet({ [QUICK_KEY]: item })
1482
+ );
1483
+ await commitResult(null, item, "success", "", true);
1484
+ return {
1485
+ ok: true,
1486
+ result: { fileName: item.fileName, warning: item.warning },
1487
+ quota: await getQuota(true)
1488
+ };
1489
+ }
1490
+ async function isCurrentQuickPage(ctx) {
1491
+ if (ctx.mode !== "quick") return true;
1492
+ try {
1493
+ const current = await chrome.tabs.get(ctx.tabId);
1494
+ return current.url === ctx.url && (!current.pendingUrl || current.pendingUrl === ctx.url);
1495
+ } catch {
1496
+ return false;
719
1497
  }
720
- return { ok: true, batch: publicBatch() };
721
1498
  }
722
- async function clearBatch() {
723
- if (batch.running) {
724
- return { ok: false, error: "Stop the running batch before clearing it" };
1499
+ async function assertCurrentQuickPage(ctx) {
1500
+ if (!await isCurrentQuickPage(ctx))
1501
+ throw new Error(
1502
+ "The page changed during capture. Return to the requested page and try again."
1503
+ );
1504
+ }
1505
+ async function capturePrepared(ctx, options, run, network) {
1506
+ const expand = (config.captureScope?.(options) || "full") === "full" && options.expandScrollAreas;
1507
+ try {
1508
+ await assertCurrentQuickPage(ctx);
1509
+ const warnings = /* @__PURE__ */ new Set();
1510
+ if (expand)
1511
+ await scrollCapture?.prepare(ctx, async () => {
1512
+ await assertCurrentQuickPage(ctx);
1513
+ const warning = await waitForPageStable(
1514
+ { tabId: ctx.tabId },
1515
+ options,
1516
+ run.controller.signal,
1517
+ network
1518
+ );
1519
+ if (warning) warnings.add(warning);
1520
+ });
1521
+ await assertCurrentQuickPage(ctx);
1522
+ const output = await config.capture(ctx);
1523
+ await assertCurrentQuickPage(ctx);
1524
+ if (output.warning) warnings.add(output.warning);
1525
+ return { ...output, warning: [...warnings].join(" ") };
1526
+ } finally {
1527
+ if (expand) await scrollCapture?.restore(ctx);
725
1528
  }
726
- batch = idleBatch();
727
- await storageRemove(BATCH_KEY);
728
- setBadge("");
729
- return { ok: true, batch: publicBatch() };
730
1529
  }
731
- async function runBatch(options) {
732
- const lastHostAt = {};
733
- for (const item of batch.items) {
734
- if (item.status !== "pending") {
735
- continue;
736
- }
737
- if (batch.cancelRequested) {
738
- break;
739
- }
740
- const host = hostOf(item.url);
741
- const since = Date.now() - (lastHostAt[host] || 0);
742
- if (lastHostAt[host] && since < SAME_HOST_MIN_GAP_MS) {
743
- batch.current = "Pacing to avoid rate-limits\u2026";
744
- await delay(SAME_HOST_MIN_GAP_MS - since);
745
- if (batch.cancelRequested) {
746
- break;
747
- }
748
- }
749
- lastHostAt[host] = Date.now();
750
- item.status = "running";
751
- batch.current = item.url;
752
- setBadge(`${batch.done + 1}/${batch.total}`);
753
- await persistBatch();
754
- let shouldCharge = false;
755
- try {
756
- const result = await captureUrl(item, options);
757
- item.fileName = result.fileName;
758
- item.warning = result.warning;
759
- item.status = "success";
760
- batch.success += 1;
761
- if (!batch.pro && !item.usageRecorded) {
762
- item.usageRecorded = true;
763
- shouldCharge = true;
1530
+ async function prepareCapture(target, item, options, run, network, mode = "batch") {
1531
+ const signal = run.controller.signal;
1532
+ const probe = await probePage(target, signal);
1533
+ if (probe.error) throw new Error(probe.error);
1534
+ if (probe.challenge && !options.ignoreChallenge)
1535
+ throw challengeError(probe.challenge);
1536
+ if (probe.challenge)
1537
+ item.warning = `Captured a ${probe.challenge} verification page at your request.`;
1538
+ const scope = config.captureScope?.(options) || "full";
1539
+ if (scope === "viewport" && mode === "quick") return;
1540
+ await delay(options.delayMs, signal);
1541
+ const before = await waitForPageStable(target, options, signal, network);
1542
+ const maxSeenY = scope === "full" ? await autoScroll(target, signal) : 0;
1543
+ const after = await waitForPageStable(
1544
+ target,
1545
+ options,
1546
+ signal,
1547
+ network,
1548
+ maxSeenY
1549
+ );
1550
+ item.warning = [
1551
+ ...new Set([item.warning, before, after].filter(Boolean))
1552
+ ].join(" ");
1553
+ }
1554
+ async function saveOutput(item, output, signal, save) {
1555
+ throwIfAborted(signal);
1556
+ await downloader.downloadViaBlob(
1557
+ output.base64,
1558
+ output.mime,
1559
+ item.fileName,
1560
+ {
1561
+ signal,
1562
+ onPrepared: async (url) => {
1563
+ item.downloadUrl = url;
1564
+ item.status = "downloading";
1565
+ await save();
1566
+ },
1567
+ onCreated: async (id) => {
1568
+ item.downloadId = id;
1569
+ await save();
764
1570
  }
765
- } catch (error) {
766
- item.status = "failed";
767
- item.error = normalizeError(error);
768
- batch.failed += 1;
769
- }
770
- batch.done = batch.success + batch.failed;
771
- await persistBatch();
772
- if (shouldCharge) {
773
- await recordUsage(1);
774
- }
775
- }
776
- batch.running = false;
777
- batch.current = batch.cancelRequested ? "Stopped" : "Done";
778
- batch.needVerify = null;
779
- await persistBatch();
780
- setBadge(batch.failed ? "ERR" : "OK");
781
- setTimeout(() => setBadge(""), 4e3);
782
- }
783
- async function captureUrl(item, options) {
784
- const maxChallengeRetry = 2;
785
- let lastError;
786
- for (let attempt = 0; attempt <= maxChallengeRetry; attempt += 1) {
787
- if (batch.cancelRequested) {
788
- throw new Error("Cancelled");
789
1571
  }
1572
+ );
1573
+ }
1574
+ async function captureUrl(item, job, run) {
1575
+ const signal = run.controller.signal;
1576
+ for (let attempt = 0; attempt <= 3; attempt++) {
1577
+ throwIfAborted(signal);
790
1578
  try {
791
- return await captureOnce(item, options, false);
1579
+ return await captureOnce(item, job, run, attempt === 3);
792
1580
  } catch (error) {
793
- lastError = error;
794
- if (!isChallengeError(error)) {
795
- throw error;
796
- }
797
- if (attempt < maxChallengeRetry) {
798
- const backoff = 8e3 * (attempt + 1);
799
- batch.current = `Rate-limited \u2014 cooling down ${backoff / 1e3}s (retry ${attempt + 1}/${maxChallengeRetry})`;
800
- await delay(backoff);
801
- }
1581
+ if (!isChallengeError(error) || attempt === 3) throw error;
1582
+ job.current = "Waiting before retrying verification\u2026";
1583
+ await persist(job);
1584
+ await delay(8e3 * (attempt + 1), signal);
802
1585
  }
803
1586
  }
804
- if (isChallengeError(lastError) && !batch.cancelRequested) {
805
- return await captureOnce(item, options, true);
806
- }
807
- throw lastError;
808
1587
  }
809
- async function captureOnce(item, options, interactive) {
810
- const tab = await chrome.tabs.create({ url: item.url, active: Boolean(interactive) });
811
- const tabId = tab.id;
812
- if (!tabId) {
813
- throw new Error("Could not open tab");
1588
+ async function ensureBatchWindow(run) {
1589
+ if (Number.isInteger(run.windowId)) {
1590
+ const live = await chrome.windows.get(run.windowId).catch(() => null);
1591
+ if (live) return run.windowId;
1592
+ run.windowId = null;
1593
+ run.idleTabId = null;
814
1594
  }
815
- const target = { tabId };
1595
+ const created = await chrome.windows.create({ url: "about:blank", focused: false }).catch(() => null);
1596
+ run.windowId = Number.isInteger(created?.id) ? created.id : null;
1597
+ run.idleTabId = created?.tabs?.[0]?.id ?? null;
1598
+ await saveTabLease(run);
1599
+ return run.windowId;
1600
+ }
1601
+ function saveTabLease(run, tabId = null) {
1602
+ return storageCall("session", "set", {
1603
+ [TAB_KEY]: {
1604
+ tabId,
1605
+ jobId: run.job?.id,
1606
+ windowId: run.windowId,
1607
+ idleTabId: run.idleTabId
1608
+ }
1609
+ });
1610
+ }
1611
+ async function closeIdleTab(lease) {
1612
+ if (!Number.isInteger(lease?.idleTabId)) return;
1613
+ const tab = await chrome.tabs.get(lease.idleTabId).catch(() => null);
1614
+ if (tab?.windowId === lease.windowId && tab.url === "about:blank" && (!tab.pendingUrl || tab.pendingUrl === "about:blank"))
1615
+ await chrome.tabs.remove(tab.id).catch(() => {
1616
+ });
1617
+ }
1618
+ async function closeBatchWindow(run) {
1619
+ await closeIdleTab(run);
1620
+ run.windowId = null;
1621
+ run.idleTabId = null;
1622
+ await storageCall("session", "remove", TAB_KEY).catch(() => {
1623
+ });
1624
+ }
1625
+ async function captureOnce(item, job, run, interactive) {
1626
+ const signal = run.controller.signal, options = job.settings;
1627
+ const windowId = await ensureBatchWindow(run);
1628
+ const tab = await chrome.tabs.create({
1629
+ url: "about:blank",
1630
+ active: interactive,
1631
+ ...Number.isInteger(windowId) ? { windowId } : {}
1632
+ });
1633
+ const tabId = tab.id, target = { tabId };
816
1634
  let attached = false;
1635
+ const network = watchNetwork(target);
817
1636
  try {
818
- await waitForLoad(tabId, LOAD_TIMEOUT_MS);
819
- await delay(Number(options.delayMs) || 0);
820
- if (batch.cancelRequested) {
821
- throw new Error("Cancelled");
822
- }
1637
+ await saveTabLease(run, tabId);
1638
+ throwIfAborted(signal);
823
1639
  await debuggerAttach(target, "1.3");
824
1640
  attached = true;
825
- await sendCommand(target, "Page.enable");
826
- await sendCommand(target, "Runtime.enable");
827
- const probe = await probePage(target);
828
- if (probe.error) {
829
- throw new Error(probe.error);
830
- }
831
- if (probe.challenge) {
832
- if (!interactive) {
833
- throw challengeError(probe.challenge);
834
- }
835
- await debuggerDetach(target).catch(() => {
836
- });
1641
+ await sendCommand(target, "Page.enable", {}, signal);
1642
+ await sendCommand(target, "Network.enable", {}, signal);
1643
+ const ctx = makeContext(
1644
+ "batch",
1645
+ tabId,
1646
+ item.url,
1647
+ options,
1648
+ target,
1649
+ signal
1650
+ );
1651
+ if (config.beforeScroll) await config.beforeScroll(ctx);
1652
+ await preparePageForCapture(target, signal);
1653
+ await chrome.tabs.update(tabId, { url: item.url });
1654
+ await waitForLoad(tabId, 3e4, signal);
1655
+ await suppressCaptureDecorations(target, signal);
1656
+ const probe = await probePage(target, signal);
1657
+ if (probe.challenge && !options.ignoreChallenge && interactive) {
1658
+ await debuggerDetach(target);
837
1659
  attached = false;
838
1660
  await focusTab(tabId);
839
- batch.needVerify = { url: item.url, challenge: probe.challenge };
840
- batch.current = `Complete ${probe.challenge} verification in the opened tab\u2026`;
841
- await persistBatch();
842
- batch.skipVerify = false;
843
- const passed = await waitForVerification(tabId, () => batch.cancelRequested || batch.skipVerify);
844
- batch.needVerify = null;
845
- batch.current = item.url;
846
- await persistBatch();
847
- if (!passed) {
848
- throw challengeError(`${probe.challenge} verification not completed`);
849
- }
850
- await delay(800);
1661
+ job.needVerify = { url: item.url, challenge: probe.challenge };
1662
+ job.current = `Complete ${probe.challenge} verification in the opened tab\u2026`;
1663
+ run.skipVerify = false;
1664
+ await persist(job);
1665
+ const passed = await waitForVerification(
1666
+ tabId,
1667
+ () => run.skipVerify,
1668
+ signal
1669
+ );
1670
+ job.needVerify = null;
1671
+ job.current = item.url;
1672
+ await persist(job);
1673
+ if (!passed)
1674
+ throw challengeError(
1675
+ `${probe.challenge} verification not completed`
1676
+ );
851
1677
  await debuggerAttach(target, "1.3");
852
1678
  attached = true;
853
- await sendCommand(target, "Page.enable");
854
- await sendCommand(target, "Runtime.enable");
855
- }
856
- const ctx = makeContext("batch", tabId, item.url, options, target);
857
- if (config.beforeScroll) {
858
- await config.beforeScroll(ctx);
1679
+ network.reset();
1680
+ await sendCommand(target, "Network.enable", {}, signal);
1681
+ await preparePageForCapture(target, signal);
859
1682
  }
860
- await autoScroll(target);
861
- await revealAnimations(target);
862
- await delay(400);
863
- const output = await config.capture(ctx);
864
- const fileName = buildBatchFileName(batch.folder, item, output.extension);
865
- await downloader.downloadViaBlob(output.base64, output.mime, fileName);
866
- return { fileName, warning: output.warning || "" };
1683
+ await prepareCapture(target, item, options, run, network);
1684
+ const output = await capturePrepared(ctx, options, run, network);
1685
+ throwIfAborted(signal);
1686
+ item.warning = [item.warning, output.warning].filter(Boolean).join(" ");
1687
+ item.fileName = buildBatchFileName(job.folder, item, output.extension);
1688
+ await saveOutput(item, output, signal, () => persist(job));
867
1689
  } finally {
1690
+ network.dispose();
868
1691
  if (attached) {
1692
+ await restoreCaptureDecorations(target).catch(() => {
1693
+ });
869
1694
  await debuggerDetach(target).catch(() => {
870
1695
  });
871
1696
  }
872
1697
  await chrome.tabs.remove(tabId).catch(() => {
873
1698
  });
1699
+ await saveTabLease(run);
874
1700
  }
875
1701
  }
876
- function makeContext(mode, tabId, url, options, target) {
1702
+ function makeContext(mode, tabId, url, options, target, signal) {
877
1703
  return {
878
1704
  mode,
879
1705
  tabId,
880
1706
  url,
881
1707
  options,
882
- send: (method, params) => sendCommand(target, method, params)
1708
+ send: (method, params) => sendCommand(target, method, params, signal),
1709
+ cleanup: (method, params) => sendCommand(target, method, params)
883
1710
  };
884
1711
  }
885
- function publicBatch() {
886
- return {
887
- running: batch.running,
888
- total: batch.total,
889
- done: batch.done,
890
- success: batch.success,
891
- failed: batch.failed,
892
- current: batch.current,
893
- folder: batch.folder,
894
- freeLimited: batch.freeLimited,
895
- needVerify: batch.needVerify,
896
- items: batch.items.map((item) => ({
897
- url: item.url,
898
- index: item.index,
899
- status: item.status,
900
- fileName: item.fileName,
901
- error: item.error,
902
- warning: item.warning
903
- }))
904
- };
905
- }
906
- async function persistBatch() {
907
- await storageSet({ [BATCH_KEY]: batch });
908
- }
909
1712
  async function restoreBatch() {
910
- const data = await storageGet(BATCH_KEY);
1713
+ const data = await storageGet([BATCH_KEY, QUICK_KEY]);
1714
+ const lease = (await storageCall("session", "get", TAB_KEY))[TAB_KEY];
1715
+ if (Number.isInteger(lease?.tabId) || Number.isInteger(lease?.windowId)) {
1716
+ if (Number.isInteger(lease.tabId))
1717
+ await chrome.tabs.remove(lease.tabId).catch(() => {
1718
+ });
1719
+ await closeIdleTab(lease);
1720
+ await storageCall("session", "remove", TAB_KEY);
1721
+ }
1722
+ await recoverQuick();
911
1723
  const saved = data[BATCH_KEY];
912
- if (!saved?.items?.length) {
913
- return;
1724
+ if (!Array.isArray(saved?.items) || !saved.items.length) return;
1725
+ const items = saved.items.filter(
1726
+ (item) => typeof item?.url === "string" && normalizeUrls([item.url]).length
1727
+ ).slice(0, 500);
1728
+ batch = {
1729
+ ...idleBatch(),
1730
+ ...saved,
1731
+ version: 2,
1732
+ settings: normalizeSettings(
1733
+ config.defaultSettings,
1734
+ config.migrateSettings ? config.migrateSettings(saved.settings) : saved.settings
1735
+ ),
1736
+ needVerify: null,
1737
+ items: items.map((item, i) => ({
1738
+ ...makeItem(item.url, i + 1),
1739
+ ...item,
1740
+ chargeFree: item.chargeFree ?? !saved.pro,
1741
+ status: ["success", "failed", "cancelled", "downloading"].includes(
1742
+ item.status
1743
+ ) ? item.status : "pending"
1744
+ }))
1745
+ };
1746
+ batch.total = batch.items.length;
1747
+ if (!batch.folder)
1748
+ batch.folder = `${config.brand}/${folderNameFor(batch.items.map((i) => i.url))}`;
1749
+ recount(batch);
1750
+ if (saved.running) {
1751
+ const run = reserveRun(true);
1752
+ run.job = batch;
1753
+ if (saved.cancelRequested) run.controller.abort();
1754
+ batch.current = saved.cancelRequested ? "Stopping\u2026" : "Resuming interrupted batch\u2026";
1755
+ await persist(batch);
1756
+ void runBatch(batch, run);
914
1757
  }
915
- batch = { ...idleBatch(), ...saved };
916
- batch.settings = { ...config.defaultSettings, ...saved.settings || {} };
917
- batch.items = saved.items.map((item) => ({
918
- url: item.url || "",
919
- index: item.index || 0,
920
- status: item.status === "running" ? "pending" : item.status || "pending",
921
- fileName: item.fileName || "",
922
- error: item.error || "",
923
- warning: item.warning || "",
924
- usageRecorded: item.usageRecorded || false
925
- }));
926
- batch.done = batch.items.filter((item) => item.status === "success" || item.status === "failed").length;
927
- batch.success = batch.items.filter((item) => item.status === "success").length;
928
- batch.failed = batch.items.filter((item) => item.status === "failed").length;
929
- batch.needVerify = null;
930
- batch.skipVerify = false;
931
- if (saved.running && batch.items.some((item) => item.status === "pending")) {
932
- batch.running = true;
933
- batch.cancelRequested = false;
934
- batch.current = "Resuming interrupted batch\u2026";
935
- await persistBatch();
936
- runBatch(batch.settings).catch(async (error) => {
937
- batch.running = false;
938
- batch.current = normalizeError(error);
939
- await persistBatch();
940
- });
941
- } else if (saved.running) {
942
- batch.running = false;
943
- batch.current = "Done";
944
- await persistBatch();
1758
+ }
1759
+ async function handleMessage(message) {
1760
+ await restorePromise;
1761
+ switch (message?.type) {
1762
+ case `${prefix}_START`:
1763
+ return startBatch(message.urls, message.settings);
1764
+ case `${prefix}_CAPTURE_CURRENT`:
1765
+ case `${prefix}_CONVERT_CURRENT`:
1766
+ return quickCapture(message.tabId, message.settings);
1767
+ case `${prefix}_CANCEL`:
1768
+ if (activeRun) {
1769
+ activeRun.controller.abort();
1770
+ if (activeRun.job) {
1771
+ activeRun.job.cancelRequested = true;
1772
+ activeRun.job.current = "Stopping\u2026";
1773
+ await persist(activeRun.job);
1774
+ }
1775
+ } else if (batch.running) {
1776
+ const run = reserveRun(true);
1777
+ if (run) {
1778
+ run.job = batch;
1779
+ batch.cancelRequested = true;
1780
+ batch.current = "Stopping\u2026";
1781
+ run.controller.abort();
1782
+ await persist(batch).catch(() => {
1783
+ });
1784
+ void runBatch(batch, run);
1785
+ }
1786
+ }
1787
+ return { ok: true, batch: publicBatch() };
1788
+ case `${prefix}_CLEAR_BATCH`: {
1789
+ const run = reserveRun(true);
1790
+ if (!run)
1791
+ return {
1792
+ ok: false,
1793
+ error: "Stop the running task before clearing it"
1794
+ };
1795
+ try {
1796
+ run.job = batch;
1797
+ if (batch.running || batch.items.some((item) => item.status === "downloading")) {
1798
+ batch.cancelRequested = true;
1799
+ run.controller.abort();
1800
+ await runBatch(batch, run, { keepLock: true });
1801
+ if (batch.running)
1802
+ throw new Error(
1803
+ "Downloads could not be confirmed. Results were kept; retry Stop or Clear."
1804
+ );
1805
+ }
1806
+ await storageRemove(BATCH_KEY);
1807
+ batch = idleBatch();
1808
+ } finally {
1809
+ releaseRun(run);
1810
+ }
1811
+ return {
1812
+ ok: true,
1813
+ batch: publicBatch(),
1814
+ quota: await getQuota(true)
1815
+ };
1816
+ }
1817
+ case `${prefix}_SKIP_VERIFY`:
1818
+ if (activeRun) activeRun.skipVerify = true;
1819
+ return { ok: true };
1820
+ case `${prefix}_STATE`:
1821
+ return {
1822
+ ok: true,
1823
+ batch: publicBatch(),
1824
+ quota: await getQuota(true)
1825
+ };
1826
+ case `${prefix}_ACTIVATE`:
1827
+ await licensing.activate(message.code);
1828
+ return { ok: true, quota: await getQuota(true) };
1829
+ case `${prefix}_REFRESH_LICENSE`:
1830
+ return { ok: true, quota: await getQuota(false, true) };
1831
+ case `${prefix}_CLEAR_LICENSE`:
1832
+ await licensing.deactivate();
1833
+ return { ok: true, quota: await getQuota(true) };
1834
+ default:
1835
+ return { ok: false, error: "Unknown message" };
945
1836
  }
946
1837
  }
1838
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1839
+ if (message?.target === "offscreen" || sender.id && sender.id !== chrome.runtime.id)
1840
+ return false;
1841
+ handleMessage(message).then(sendResponse).catch(
1842
+ (error) => sendResponse({ ok: false, error: normalizeError(error) })
1843
+ );
1844
+ return true;
1845
+ });
1846
+ chrome.runtime.onInstalled.addListener(() => {
1847
+ void restorePromise.catch(() => {
1848
+ });
1849
+ });
947
1850
  function setBadge(text) {
948
1851
  chrome.action.setBadgeText({ text });
949
1852
  chrome.action.setBadgeBackgroundColor({ color: config.badgeColor });
950
1853
  }
1854
+ restorePromise = restoreBatch();
1855
+ }
1856
+
1857
+ // src/capture-budget.js
1858
+ var CAPTURE_LIMITS = {
1859
+ maxEdgeDevicePx: 16384,
1860
+ maxDevicePixels: 64 * 1024 * 1024
1861
+ };
1862
+ function captureScale(dpr) {
1863
+ return Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
1864
+ }
1865
+ function captureFits(width, height, dpr) {
1866
+ const scale = captureScale(dpr);
1867
+ return [width, height].every((n) => Number.isFinite(n) && n > 0) && width * scale <= CAPTURE_LIMITS.maxEdgeDevicePx && height * scale <= CAPTURE_LIMITS.maxEdgeDevicePx && width * height * scale ** 2 <= CAPTURE_LIMITS.maxDevicePixels;
1868
+ }
1869
+ function assertCaptureFits(width, height, dpr, message) {
1870
+ if (!captureFits(width, height, dpr)) throw new Error(message);
1871
+ }
1872
+ function captureBudget(dpr) {
1873
+ const scale = captureScale(dpr);
1874
+ return {
1875
+ maxEdge: Math.floor(CAPTURE_LIMITS.maxEdgeDevicePx / scale),
1876
+ maxArea: Math.floor(CAPTURE_LIMITS.maxDevicePixels / scale ** 2)
1877
+ };
1878
+ }
1879
+ async function readDevicePixelRatio(ctx) {
1880
+ const result = await ctx.send("Runtime.evaluate", {
1881
+ expression: "devicePixelRatio || 1",
1882
+ returnByValue: true
1883
+ });
1884
+ return captureScale(result?.result?.value);
951
1885
  }
952
1886
 
953
1887
  // src/background.entry.ts
954
- globalThis.ZuckerExt = { init: initExtension };
1888
+ globalThis.ZuckerExt = {
1889
+ init: initExtension,
1890
+ limits: CAPTURE_LIMITS,
1891
+ pageDecorations: { hide: hidePageCaptureDecorations, show: showPageCaptureDecorations },
1892
+ captureFits,
1893
+ assertCaptureFits,
1894
+ captureBudget,
1895
+ readDevicePixelRatio
1896
+ };
955
1897
  })();