tabctl 0.6.0-rc.7 → 0.6.0-rc.9
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/README.md +3 -2
- package/dist/extension/background.js +517 -3
- package/dist/extension/lib/content.js +115 -0
- package/dist/extension/manifest.json +4 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -170,7 +170,7 @@ tabctl query 'query { inspectTabs(tabIds: [456], signals: ["page-meta"]) { entri
|
|
|
170
170
|
tabctl query 'query { inspectTabs(windowId: 123, selectors: [{ name: "prices", selector: ".price", attr: "text", all: true }, { name: "buy_now_visible", selector: "button.buy-now", attr: "visible" }, { name: "buy_now_style", selector: "button.buy-now", attr: "styles", styleProps: ["color", "background-color"] }, { name: "review_count", selector: ".review", attr: "count" }, { name: "email_value", selector: "input[type=email]", attr: "value" }]) { entries { tabId url signals { name valueJson } } } }'
|
|
171
171
|
|
|
172
172
|
# Read tab content as Markdown (Kreuzberg preprocessing by default)
|
|
173
|
-
tabctl query 'query { readTabs(windowId: 123, extract: true, maxChars: 30000) { entries { tabId title url markdown chars truncated extracted status emptyReason diagnostics { sourceHtmlChars sourceTextChars documentReadyState truncatedHtml } error } } }'
|
|
173
|
+
tabctl query 'query { readTabs(windowId: 123, extract: true, maxChars: 30000) { entries { tabId title url markdown chars truncated extracted cached status emptyReason diagnostics { source sourceHtmlChars sourceTextChars documentReadyState truncatedHtml cachedAt cacheAgeMs } error } } }'
|
|
174
174
|
|
|
175
175
|
# Generate reports
|
|
176
176
|
tabctl query '{ reportTabs(windowId: 123) { entries { tabId title url description } } }'
|
|
@@ -427,7 +427,8 @@ Notes:
|
|
|
427
427
|
- Browser reads and mutations now go through GraphQL via `tabctl query`.
|
|
428
428
|
- Reports include short descriptions from page metadata and a fallback snippet.
|
|
429
429
|
- `inspectTabs` supports `page-meta` plus selector reads with `all`, `text`, `textMode`, `styleProps`, and attrs such as `html`, `value`, `count`, `box`, `styles`, `visible`, `enabled`, and `checked`.
|
|
430
|
-
- `readTabs` converts main-frame page HTML to Markdown with Kreuzberg `html-to-markdown`; per-tab `status`, `emptyReason`, `diagnostics`, and `error` distinguish empty pages, unsupported URLs, injection failures, conversion failures, and
|
|
430
|
+
- `readTabs` converts main-frame page HTML to Markdown with Kreuzberg `html-to-markdown`; per-tab `status`, `emptyReason`, `diagnostics`, and `error` distinguish empty pages, unsupported URLs, injection failures, conversion failures, timeouts, and cached fallbacks (`status: CACHED`, `cached: true`, `diagnostics.source: "cache"`).
|
|
431
|
+
- Cached fallbacks use a bounded profile-local open-tab HTML cache refreshed after successful reads, active tab switches, and quiescent active pages; diagnostics include cache age and match mode when cached content is used.
|
|
431
432
|
- `captureScreenshots` returns tile metadata and image data from GraphQL.
|
|
432
433
|
- `undoAction` accepts either an explicit `txid` or `latest: true`.
|
|
433
434
|
- `tabctl history --json` returns a top-level JSON array.
|
|
@@ -341,7 +341,8 @@
|
|
|
341
341
|
extractPageHtml: () => extractPageHtml,
|
|
342
342
|
extractPageMarkdown: () => extractPageMarkdown,
|
|
343
343
|
extractPageMeta: () => extractPageMeta,
|
|
344
|
-
extractSelectorSignal: () => extractSelectorSignal
|
|
344
|
+
extractSelectorSignal: () => extractSelectorSignal,
|
|
345
|
+
probePageQuiescence: () => probePageQuiescence
|
|
345
346
|
});
|
|
346
347
|
function delay(ms) {
|
|
347
348
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -485,6 +486,114 @@
|
|
|
485
486
|
error: null
|
|
486
487
|
};
|
|
487
488
|
}
|
|
489
|
+
async function probePageQuiescence(tabId, timeoutMs, sampleWindowMs) {
|
|
490
|
+
const result = await executeWithTimeoutDetailed(tabId, timeoutMs, async (rawWindowMs) => {
|
|
491
|
+
const startedAt = Date.now();
|
|
492
|
+
const windowMs = Math.max(50, Math.min(Number(rawWindowMs) || 350, 1500));
|
|
493
|
+
const htmlCap = 25e4;
|
|
494
|
+
const sample = () => {
|
|
495
|
+
const root = document.documentElement;
|
|
496
|
+
const bodyText = document.body?.innerText || root?.textContent || "";
|
|
497
|
+
const html = root?.outerHTML || "";
|
|
498
|
+
const resources = typeof performance !== "undefined" && typeof performance.getEntriesByType === "function" ? performance.getEntriesByType("resource").length : null;
|
|
499
|
+
return {
|
|
500
|
+
textChars: bodyText.length,
|
|
501
|
+
htmlChars: Math.min(html.length, htmlCap),
|
|
502
|
+
domElements: document.getElementsByTagName("*").length,
|
|
503
|
+
resourceCount: resources
|
|
504
|
+
};
|
|
505
|
+
};
|
|
506
|
+
const waitForIdleWindow = () => new Promise((resolve) => {
|
|
507
|
+
const win = window;
|
|
508
|
+
const done = () => setTimeout(resolve, windowMs);
|
|
509
|
+
if (typeof win.requestIdleCallback === "function") {
|
|
510
|
+
let resolved = false;
|
|
511
|
+
const finish = () => {
|
|
512
|
+
if (resolved) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
resolved = true;
|
|
516
|
+
done();
|
|
517
|
+
};
|
|
518
|
+
const fallback = setTimeout(finish, windowMs + 100);
|
|
519
|
+
win.requestIdleCallback(() => {
|
|
520
|
+
clearTimeout(fallback);
|
|
521
|
+
finish();
|
|
522
|
+
}, { timeout: windowMs });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
setTimeout(resolve, windowMs);
|
|
526
|
+
});
|
|
527
|
+
try {
|
|
528
|
+
const readyState = document.readyState;
|
|
529
|
+
if (readyState !== "interactive" && readyState !== "complete") {
|
|
530
|
+
return {
|
|
531
|
+
quiet: false,
|
|
532
|
+
reason: "not-ready",
|
|
533
|
+
documentReadyState: readyState,
|
|
534
|
+
before: null,
|
|
535
|
+
after: null,
|
|
536
|
+
elapsedMs: Date.now() - startedAt,
|
|
537
|
+
error: null
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const before = sample();
|
|
541
|
+
await waitForIdleWindow();
|
|
542
|
+
const after = sample();
|
|
543
|
+
const stable = before.textChars === after.textChars && before.htmlChars === after.htmlChars && before.domElements === after.domElements && before.resourceCount === after.resourceCount;
|
|
544
|
+
return {
|
|
545
|
+
quiet: stable,
|
|
546
|
+
reason: stable ? "stable" : "changed",
|
|
547
|
+
documentReadyState: document.readyState,
|
|
548
|
+
before,
|
|
549
|
+
after,
|
|
550
|
+
elapsedMs: Date.now() - startedAt,
|
|
551
|
+
error: null
|
|
552
|
+
};
|
|
553
|
+
} catch (err) {
|
|
554
|
+
return {
|
|
555
|
+
quiet: false,
|
|
556
|
+
reason: "probe-error",
|
|
557
|
+
documentReadyState: typeof document !== "undefined" ? document.readyState : null,
|
|
558
|
+
before: null,
|
|
559
|
+
after: null,
|
|
560
|
+
elapsedMs: Date.now() - startedAt,
|
|
561
|
+
error: err instanceof Error ? err.message : String(err)
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}, [sampleWindowMs]);
|
|
565
|
+
if (result.kind === "timeout") {
|
|
566
|
+
return {
|
|
567
|
+
quiet: false,
|
|
568
|
+
reason: "timed-out",
|
|
569
|
+
documentReadyState: null,
|
|
570
|
+
before: null,
|
|
571
|
+
after: null,
|
|
572
|
+
elapsedMs: timeoutMs,
|
|
573
|
+
error: `Timed out after ${timeoutMs}ms`
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (result.kind === "error") {
|
|
577
|
+
return {
|
|
578
|
+
quiet: false,
|
|
579
|
+
reason: "injection-error",
|
|
580
|
+
documentReadyState: null,
|
|
581
|
+
before: null,
|
|
582
|
+
after: null,
|
|
583
|
+
elapsedMs: 0,
|
|
584
|
+
error: result.message
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
return result.value ?? {
|
|
588
|
+
quiet: false,
|
|
589
|
+
reason: "no-result",
|
|
590
|
+
documentReadyState: null,
|
|
591
|
+
before: null,
|
|
592
|
+
after: null,
|
|
593
|
+
elapsedMs: 0,
|
|
594
|
+
error: "Content script returned no quiescence probe payload"
|
|
595
|
+
};
|
|
596
|
+
}
|
|
488
597
|
async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLength) {
|
|
489
598
|
if (!specs.length) {
|
|
490
599
|
return null;
|
|
@@ -674,6 +783,18 @@
|
|
|
674
783
|
var RECONNECT_ALARM = "tabctl-reconnect";
|
|
675
784
|
var KEEPALIVE_INTERVAL_MINUTES = 1;
|
|
676
785
|
var BROWSER_STATE_SYNC_DEBOUNCE_MS = 750;
|
|
786
|
+
var ACTIVE_PAGE_CACHE_DEBOUNCE_MS = 1e3;
|
|
787
|
+
var ACTIVE_PAGE_CACHE_TIMEOUT_MS = 5e3;
|
|
788
|
+
var MAX_PAGE_HTML_CHARS = 15e5;
|
|
789
|
+
var ACTIVE_PAGE_CACHE_MAX_HTML_CHARS = MAX_PAGE_HTML_CHARS;
|
|
790
|
+
var ACTIVE_PAGE_CACHE_QUIESCENT_DELAY_MS = 6e3;
|
|
791
|
+
var ACTIVE_PAGE_CACHE_QUIESCENT_RETRY_MS = 1e3;
|
|
792
|
+
var ACTIVE_PAGE_CACHE_QUIESCENT_TIMEOUT_MS = 2500;
|
|
793
|
+
var ACTIVE_PAGE_CACHE_QUIESCENT_SAMPLE_MS = 350;
|
|
794
|
+
var ACTIVE_PAGE_CACHE_QUIESCENT_COOLDOWN_MS = 3e4;
|
|
795
|
+
var ACTIVE_PAGE_CACHE_STATUS_TIMEOUT_MS = 3e4;
|
|
796
|
+
var CACHE_AVAILABLE_BADGE_TEXT = "C";
|
|
797
|
+
var CACHE_AVAILABLE_BADGE_COLOR = "#2da44e";
|
|
677
798
|
var RECONNECT_INITIAL_DELAY_MS = 250;
|
|
678
799
|
var RECONNECT_MAX_DELAY_MS = 3e4;
|
|
679
800
|
var RECONNECT_ALARM_MIN_DELAY_MS = 3e4;
|
|
@@ -701,6 +822,18 @@
|
|
|
701
822
|
incognitoTabIds: /* @__PURE__ */ new Set(),
|
|
702
823
|
incognitoGroupIds: /* @__PURE__ */ new Set()
|
|
703
824
|
};
|
|
825
|
+
var activePageCache = {
|
|
826
|
+
nextId: 1,
|
|
827
|
+
timer: null,
|
|
828
|
+
pending: null,
|
|
829
|
+
quiescentTimer: null,
|
|
830
|
+
quiescentPending: null,
|
|
831
|
+
inFlightKeys: /* @__PURE__ */ new Set(),
|
|
832
|
+
lastCapturedKey: null,
|
|
833
|
+
lastQuiescentCapturedKey: null,
|
|
834
|
+
lastQuiescentCapturedAt: 0,
|
|
835
|
+
statusRequests: /* @__PURE__ */ new Map()
|
|
836
|
+
};
|
|
704
837
|
function log(...args) {
|
|
705
838
|
console.log("[tabctl]", ...args);
|
|
706
839
|
}
|
|
@@ -781,6 +914,9 @@
|
|
|
781
914
|
state.port = port;
|
|
782
915
|
resetReconnectBackoffAfterStablePort(port);
|
|
783
916
|
port.onMessage.addListener((message) => {
|
|
917
|
+
if (handlePageCacheStatusMessage(message)) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
784
920
|
void handleNativeMessage(port, message);
|
|
785
921
|
});
|
|
786
922
|
port.onDisconnect.addListener(() => {
|
|
@@ -793,11 +929,13 @@
|
|
|
793
929
|
if (state.port === port) {
|
|
794
930
|
state.port = null;
|
|
795
931
|
}
|
|
932
|
+
activePageCache.statusRequests.clear();
|
|
796
933
|
clearReconnectStableTimer();
|
|
797
934
|
scheduleReconnect("disconnect");
|
|
798
935
|
});
|
|
799
936
|
log("Native host connected");
|
|
800
937
|
queueBrowserStateSync("startup");
|
|
938
|
+
void refreshActivePageCacheIndicator("connectNative");
|
|
801
939
|
} catch (error) {
|
|
802
940
|
log("Native host connection failed", error);
|
|
803
941
|
scheduleReconnect("connect-failed");
|
|
@@ -808,6 +946,360 @@
|
|
|
808
946
|
browserState.nextId += 1;
|
|
809
947
|
return `browser-state-${Date.now()}-${id}`;
|
|
810
948
|
}
|
|
949
|
+
function nextActivePageCacheId() {
|
|
950
|
+
const id = activePageCache.nextId;
|
|
951
|
+
activePageCache.nextId += 1;
|
|
952
|
+
return `page-cache-capture-${Date.now()}-${id}`;
|
|
953
|
+
}
|
|
954
|
+
function nextActivePageCacheStatusId() {
|
|
955
|
+
const id = activePageCache.nextId;
|
|
956
|
+
activePageCache.nextId += 1;
|
|
957
|
+
return `page-cache-status-${Date.now()}-${id}`;
|
|
958
|
+
}
|
|
959
|
+
function trackPageCacheStatusRequest(id, tabId, url) {
|
|
960
|
+
activePageCache.statusRequests.set(id, { tabId, url });
|
|
961
|
+
setTimeout(() => {
|
|
962
|
+
activePageCache.statusRequests.delete(id);
|
|
963
|
+
}, ACTIVE_PAGE_CACHE_STATUS_TIMEOUT_MS);
|
|
964
|
+
}
|
|
965
|
+
function isScriptableUrl(url) {
|
|
966
|
+
const lower = url.toLowerCase();
|
|
967
|
+
return lower.startsWith("http://") || lower.startsWith("https://");
|
|
968
|
+
}
|
|
969
|
+
function activePageCacheKey(tab, url) {
|
|
970
|
+
return `${tab.id}:${url}`;
|
|
971
|
+
}
|
|
972
|
+
function activePageCacheUrl(tab) {
|
|
973
|
+
return tab.url || tab.pendingUrl || "";
|
|
974
|
+
}
|
|
975
|
+
async function tabUrlMatches(tabId, expectedUrl) {
|
|
976
|
+
try {
|
|
977
|
+
return activePageCacheUrl(await chrome.tabs.get(tabId)) === expectedUrl;
|
|
978
|
+
} catch {
|
|
979
|
+
return false;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async function clearCacheAvailableIndicator(tabId) {
|
|
983
|
+
try {
|
|
984
|
+
await chrome.action?.setBadgeText?.({ tabId, text: "" });
|
|
985
|
+
await chrome.action?.setTitle?.({ tabId, title: "Tab Control" });
|
|
986
|
+
} catch (error) {
|
|
987
|
+
log("clear cache indicator failed", { tabId, error });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async function setCacheAvailableIndicator(tabId, expectedUrl) {
|
|
991
|
+
try {
|
|
992
|
+
const tab = await chrome.tabs.get(tabId);
|
|
993
|
+
if (!isEligibleActivePageCacheTab(tab) || activePageCacheUrl(tab) !== expectedUrl) {
|
|
994
|
+
await clearCacheAvailableIndicator(tabId);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
await chrome.action?.setBadgeBackgroundColor?.({ tabId, color: CACHE_AVAILABLE_BADGE_COLOR });
|
|
998
|
+
await chrome.action?.setBadgeText?.({ tabId, text: CACHE_AVAILABLE_BADGE_TEXT });
|
|
999
|
+
await chrome.action?.setTitle?.({ tabId, title: "Tab Control - page cache available" });
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
log("set cache indicator failed", { tabId, error });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async function applyPageCacheStatus(tabId, expectedUrl, available) {
|
|
1005
|
+
try {
|
|
1006
|
+
const tab = await chrome.tabs.get(tabId);
|
|
1007
|
+
if (activePageCacheUrl(tab) !== expectedUrl) {
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (!isEligibleActivePageCacheTab(tab) || !available) {
|
|
1011
|
+
await clearCacheAvailableIndicator(tabId);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
await setCacheAvailableIndicator(tabId, expectedUrl);
|
|
1015
|
+
} catch {
|
|
1016
|
+
await clearCacheAvailableIndicator(tabId);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function handlePageCacheStatusMessage(message) {
|
|
1020
|
+
if (!message || typeof message !== "object" || message.action) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
const requestId = typeof message.id === "string" ? message.id : "";
|
|
1024
|
+
const pending = activePageCache.statusRequests.get(requestId);
|
|
1025
|
+
if (!pending) {
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
activePageCache.statusRequests.delete(requestId);
|
|
1029
|
+
const data = message.data && typeof message.data === "object" ? message.data : {};
|
|
1030
|
+
const tabId = typeof data.tabId === "number" ? data.tabId : pending.tabId;
|
|
1031
|
+
const url = typeof data.url === "string" ? data.url : pending.url;
|
|
1032
|
+
const available = message.ok === true && data.available === true && tabId === pending.tabId && url === pending.url;
|
|
1033
|
+
void applyPageCacheStatus(pending.tabId, pending.url, available);
|
|
1034
|
+
return true;
|
|
1035
|
+
}
|
|
1036
|
+
function requestPageCacheStatus(tab, reason) {
|
|
1037
|
+
const url = activePageCacheUrl(tab);
|
|
1038
|
+
if (typeof tab.id !== "number" || !isEligibleActivePageCacheTab(tab)) {
|
|
1039
|
+
if (typeof tab.id === "number") {
|
|
1040
|
+
void clearCacheAvailableIndicator(tab.id);
|
|
1041
|
+
}
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const port = state.port;
|
|
1045
|
+
if (!port) {
|
|
1046
|
+
connectNative();
|
|
1047
|
+
void clearCacheAvailableIndicator(tab.id);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const id = nextActivePageCacheStatusId();
|
|
1051
|
+
trackPageCacheStatusRequest(id, tab.id, url);
|
|
1052
|
+
port.postMessage({
|
|
1053
|
+
id,
|
|
1054
|
+
action: "page-cache-status",
|
|
1055
|
+
ok: true,
|
|
1056
|
+
data: {
|
|
1057
|
+
reason,
|
|
1058
|
+
requestedAt: Date.now(),
|
|
1059
|
+
tab: {
|
|
1060
|
+
tabId: tab.id,
|
|
1061
|
+
url,
|
|
1062
|
+
incognito: tab.incognito || false,
|
|
1063
|
+
discarded: tab.discarded || false,
|
|
1064
|
+
status: tab.status || null
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
async function requestPageCacheStatusForTab(tabId, reason) {
|
|
1070
|
+
try {
|
|
1071
|
+
requestPageCacheStatus(await chrome.tabs.get(tabId), reason);
|
|
1072
|
+
} catch {
|
|
1073
|
+
await clearCacheAvailableIndicator(tabId);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function refreshActivePageCacheIndicator(reason) {
|
|
1077
|
+
try {
|
|
1078
|
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
1079
|
+
if (tab) {
|
|
1080
|
+
requestPageCacheStatus(tab, reason);
|
|
1081
|
+
}
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
log("active page cache indicator refresh failed", { reason, error });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
function urlMismatchPageHtmlResponse(expectedUrl) {
|
|
1087
|
+
return {
|
|
1088
|
+
status: "URL_MISMATCH",
|
|
1089
|
+
html: "",
|
|
1090
|
+
sourceHtmlChars: 0,
|
|
1091
|
+
sourceTextChars: 0,
|
|
1092
|
+
documentReadyState: null,
|
|
1093
|
+
truncatedHtml: false,
|
|
1094
|
+
error: `Tab URL changed before page HTML extraction completed: expected ${expectedUrl}`
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
function isEligibleActivePageCacheTab(tab) {
|
|
1098
|
+
const url = activePageCacheUrl(tab);
|
|
1099
|
+
return typeof tab.id === "number" && Boolean(url) && tab.incognito !== true && !browserState.incognitoTabIds.has(tab.id) && (typeof tab.windowId !== "number" || !browserState.incognitoWindowIds.has(tab.windowId)) && tab.discarded !== true && tab.status !== "loading" && isScriptableUrl(url);
|
|
1100
|
+
}
|
|
1101
|
+
function clearPendingActivePageCacheCapture() {
|
|
1102
|
+
if (activePageCache.timer) {
|
|
1103
|
+
clearTimeout(activePageCache.timer);
|
|
1104
|
+
activePageCache.timer = null;
|
|
1105
|
+
}
|
|
1106
|
+
activePageCache.pending = null;
|
|
1107
|
+
}
|
|
1108
|
+
function clearPendingQuiescentActivePageCacheCapture() {
|
|
1109
|
+
if (activePageCache.quiescentTimer) {
|
|
1110
|
+
clearTimeout(activePageCache.quiescentTimer);
|
|
1111
|
+
activePageCache.quiescentTimer = null;
|
|
1112
|
+
}
|
|
1113
|
+
activePageCache.quiescentPending = null;
|
|
1114
|
+
}
|
|
1115
|
+
function isQuiescentCaptureCoolingDown(key) {
|
|
1116
|
+
return activePageCache.lastQuiescentCapturedKey === key && Date.now() - activePageCache.lastQuiescentCapturedAt < ACTIVE_PAGE_CACHE_QUIESCENT_COOLDOWN_MS;
|
|
1117
|
+
}
|
|
1118
|
+
function scheduleQuiescentActivePageCacheCapture(tab, reason, key) {
|
|
1119
|
+
if (isQuiescentCaptureCoolingDown(key)) {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
clearPendingQuiescentActivePageCacheCapture();
|
|
1123
|
+
activePageCache.quiescentPending = {
|
|
1124
|
+
tabId: tab.id,
|
|
1125
|
+
key,
|
|
1126
|
+
reason: `${reason}:quiescent`,
|
|
1127
|
+
attempts: 0
|
|
1128
|
+
};
|
|
1129
|
+
activePageCache.quiescentTimer = setTimeout(() => {
|
|
1130
|
+
activePageCache.quiescentTimer = null;
|
|
1131
|
+
const pending = activePageCache.quiescentPending;
|
|
1132
|
+
activePageCache.quiescentPending = null;
|
|
1133
|
+
if (pending) {
|
|
1134
|
+
void captureQuiescentActivePageCache(pending);
|
|
1135
|
+
}
|
|
1136
|
+
}, ACTIVE_PAGE_CACHE_QUIESCENT_DELAY_MS);
|
|
1137
|
+
}
|
|
1138
|
+
function scheduleActivePageCacheCapture(tab, reason) {
|
|
1139
|
+
if (!isEligibleActivePageCacheTab(tab)) {
|
|
1140
|
+
if (typeof tab.id === "number") {
|
|
1141
|
+
void clearCacheAvailableIndicator(tab.id);
|
|
1142
|
+
}
|
|
1143
|
+
clearPendingActivePageCacheCapture();
|
|
1144
|
+
clearPendingQuiescentActivePageCacheCapture();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
requestPageCacheStatus(tab, reason);
|
|
1148
|
+
const url = activePageCacheUrl(tab);
|
|
1149
|
+
const key = activePageCacheKey(tab, url);
|
|
1150
|
+
scheduleQuiescentActivePageCacheCapture(tab, reason, key);
|
|
1151
|
+
if (activePageCache.inFlightKeys.has(key) || activePageCache.lastCapturedKey === key) {
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
clearPendingActivePageCacheCapture();
|
|
1155
|
+
activePageCache.pending = { tab, reason, key };
|
|
1156
|
+
activePageCache.timer = setTimeout(() => {
|
|
1157
|
+
activePageCache.timer = null;
|
|
1158
|
+
const pending = activePageCache.pending;
|
|
1159
|
+
activePageCache.pending = null;
|
|
1160
|
+
if (pending) {
|
|
1161
|
+
void captureActivePageCache(pending.tab, pending.reason, pending.key);
|
|
1162
|
+
}
|
|
1163
|
+
}, ACTIVE_PAGE_CACHE_DEBOUNCE_MS);
|
|
1164
|
+
}
|
|
1165
|
+
async function scheduleActivePageCacheCaptureForTab(tabId, reason) {
|
|
1166
|
+
try {
|
|
1167
|
+
scheduleActivePageCacheCapture(await chrome.tabs.get(tabId), reason);
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
log("active page cache tab lookup failed", { tabId, reason, error });
|
|
1170
|
+
await clearCacheAvailableIndicator(tabId);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
async function scheduleActivePageCacheCaptureForWindow(windowId, reason) {
|
|
1174
|
+
if (windowId === chrome.windows.WINDOW_ID_NONE) {
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
const [tab] = await chrome.tabs.query({ active: true, windowId });
|
|
1179
|
+
if (tab) {
|
|
1180
|
+
scheduleActivePageCacheCapture(tab, reason);
|
|
1181
|
+
}
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
log("active page cache window lookup failed", { windowId, reason, error });
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
async function currentActivePageCacheTab(tabId, key) {
|
|
1187
|
+
try {
|
|
1188
|
+
const tab = await chrome.tabs.get(tabId);
|
|
1189
|
+
const url = activePageCacheUrl(tab);
|
|
1190
|
+
if (!tab.active || !isEligibleActivePageCacheTab(tab) || activePageCacheKey(tab, url) !== key) {
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
return tab;
|
|
1194
|
+
} catch {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
async function rescheduleQuiescentActivePageCacheCapture(pending) {
|
|
1199
|
+
if (pending.attempts >= 2 || isQuiescentCaptureCoolingDown(pending.key)) {
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
activePageCache.quiescentPending = { ...pending, attempts: pending.attempts + 1 };
|
|
1203
|
+
activePageCache.quiescentTimer = setTimeout(() => {
|
|
1204
|
+
activePageCache.quiescentTimer = null;
|
|
1205
|
+
const retry = activePageCache.quiescentPending;
|
|
1206
|
+
activePageCache.quiescentPending = null;
|
|
1207
|
+
if (retry) {
|
|
1208
|
+
void captureQuiescentActivePageCache(retry);
|
|
1209
|
+
}
|
|
1210
|
+
}, ACTIVE_PAGE_CACHE_QUIESCENT_RETRY_MS);
|
|
1211
|
+
}
|
|
1212
|
+
async function captureQuiescentActivePageCache(pending) {
|
|
1213
|
+
if (isQuiescentCaptureCoolingDown(pending.key)) {
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
if (activePageCache.inFlightKeys.has(pending.key)) {
|
|
1217
|
+
await rescheduleQuiescentActivePageCacheCapture(pending);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
const tab = await currentActivePageCacheTab(pending.tabId, pending.key);
|
|
1221
|
+
if (!tab) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
const probe = await content.probePageQuiescence(
|
|
1225
|
+
tab.id,
|
|
1226
|
+
ACTIVE_PAGE_CACHE_QUIESCENT_TIMEOUT_MS,
|
|
1227
|
+
ACTIVE_PAGE_CACHE_QUIESCENT_SAMPLE_MS
|
|
1228
|
+
);
|
|
1229
|
+
if (!probe.quiet) {
|
|
1230
|
+
await rescheduleQuiescentActivePageCacheCapture(pending);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const captured = await captureActivePageCache(tab, pending.reason, pending.key);
|
|
1234
|
+
if (captured) {
|
|
1235
|
+
activePageCache.lastQuiescentCapturedKey = pending.key;
|
|
1236
|
+
activePageCache.lastQuiescentCapturedAt = Date.now();
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
async function captureActivePageCache(tab, reason, key) {
|
|
1240
|
+
if (!state.port) {
|
|
1241
|
+
connectNative();
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
1244
|
+
if (!isEligibleActivePageCacheTab(tab) || activePageCache.inFlightKeys.has(key)) {
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
activePageCache.inFlightKeys.add(key);
|
|
1248
|
+
try {
|
|
1249
|
+
const captureTab = await currentActivePageCacheTab(tab.id, key);
|
|
1250
|
+
if (!captureTab) {
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
const extraction = await content.extractPageHtml(captureTab.id, ACTIVE_PAGE_CACHE_TIMEOUT_MS, ACTIVE_PAGE_CACHE_MAX_HTML_CHARS);
|
|
1254
|
+
if (extraction.status !== "READ" || typeof extraction.html !== "string" || extraction.html.length === 0) {
|
|
1255
|
+
void requestPageCacheStatusForTab(captureTab.id, `${reason}:capture-failed`);
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
const verifiedTab = await currentActivePageCacheTab(captureTab.id, key);
|
|
1259
|
+
if (!verifiedTab) {
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
const port = state.port;
|
|
1263
|
+
if (!port) {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
const id = nextActivePageCacheId();
|
|
1267
|
+
trackPageCacheStatusRequest(id, verifiedTab.id, activePageCacheUrl(verifiedTab));
|
|
1268
|
+
port.postMessage({
|
|
1269
|
+
id,
|
|
1270
|
+
action: "page-cache-capture",
|
|
1271
|
+
ok: true,
|
|
1272
|
+
data: {
|
|
1273
|
+
reason,
|
|
1274
|
+
capturedAt: Date.now(),
|
|
1275
|
+
tab: {
|
|
1276
|
+
tabId: verifiedTab.id,
|
|
1277
|
+
windowId: verifiedTab.windowId,
|
|
1278
|
+
index: verifiedTab.index,
|
|
1279
|
+
url: activePageCacheUrl(verifiedTab),
|
|
1280
|
+
title: verifiedTab.title,
|
|
1281
|
+
groupId: verifiedTab.groupId,
|
|
1282
|
+
favIconUrl: verifiedTab.favIconUrl || null,
|
|
1283
|
+
status: verifiedTab.status || null,
|
|
1284
|
+
pinned: verifiedTab.pinned || false,
|
|
1285
|
+
active: verifiedTab.active || false,
|
|
1286
|
+
incognito: verifiedTab.incognito || false,
|
|
1287
|
+
discarded: verifiedTab.discarded || false,
|
|
1288
|
+
lastAccessedAt: verifiedTab.lastAccessed || null
|
|
1289
|
+
},
|
|
1290
|
+
extraction
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
activePageCache.lastCapturedKey = key;
|
|
1294
|
+
return true;
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
log("active page cache capture failed", { tabId: tab.id, reason, error });
|
|
1297
|
+
void requestPageCacheStatusForTab(tab.id, `${reason}:capture-error`);
|
|
1298
|
+
return false;
|
|
1299
|
+
} finally {
|
|
1300
|
+
activePageCache.inFlightKeys.delete(key);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
811
1303
|
function normalizeEventPayload(kind, payload) {
|
|
812
1304
|
const event = {
|
|
813
1305
|
kind,
|
|
@@ -924,6 +1416,12 @@
|
|
|
924
1416
|
incognito: tab.incognito,
|
|
925
1417
|
changeInfo
|
|
926
1418
|
});
|
|
1419
|
+
if ("url" in changeInfo || "status" in changeInfo || "discarded" in changeInfo) {
|
|
1420
|
+
void clearCacheAvailableIndicator(tabId);
|
|
1421
|
+
}
|
|
1422
|
+
if (tab.active && ("url" in changeInfo || "status" in changeInfo || "discarded" in changeInfo)) {
|
|
1423
|
+
scheduleActivePageCacheCapture(tab, "tabs.onUpdated");
|
|
1424
|
+
}
|
|
927
1425
|
});
|
|
928
1426
|
chrome.tabs?.onMoved?.addListener((tabId, moveInfo) => {
|
|
929
1427
|
enqueueBrowserStateEvent("tabs.onMoved", {
|
|
@@ -953,12 +1451,19 @@
|
|
|
953
1451
|
windowId: removeInfo.windowId,
|
|
954
1452
|
isWindowClosing: removeInfo.isWindowClosing
|
|
955
1453
|
});
|
|
1454
|
+
activePageCache.statusRequests.forEach((pending, requestId) => {
|
|
1455
|
+
if (pending.tabId === tabId) {
|
|
1456
|
+
activePageCache.statusRequests.delete(requestId);
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
void clearCacheAvailableIndicator(tabId);
|
|
956
1460
|
});
|
|
957
1461
|
chrome.tabs?.onActivated?.addListener((activeInfo) => {
|
|
958
1462
|
enqueueBrowserStateEvent("tabs.onActivated", {
|
|
959
1463
|
tabId: activeInfo.tabId,
|
|
960
1464
|
windowId: activeInfo.windowId
|
|
961
1465
|
});
|
|
1466
|
+
void scheduleActivePageCacheCaptureForTab(activeInfo.tabId, "tabs.onActivated");
|
|
962
1467
|
});
|
|
963
1468
|
chrome.tabGroups?.onCreated?.addListener((group) => {
|
|
964
1469
|
enqueueBrowserStateEvent("tabGroups.onCreated", {
|
|
@@ -1009,6 +1514,7 @@
|
|
|
1009
1514
|
});
|
|
1010
1515
|
chrome.windows?.onFocusChanged?.addListener((windowId) => {
|
|
1011
1516
|
enqueueBrowserStateEvent("windows.onFocusChanged", { windowId });
|
|
1517
|
+
void scheduleActivePageCacheCaptureForWindow(windowId, "windows.onFocusChanged");
|
|
1012
1518
|
});
|
|
1013
1519
|
}
|
|
1014
1520
|
connectNative();
|
|
@@ -1141,9 +1647,17 @@
|
|
|
1141
1647
|
}
|
|
1142
1648
|
case "p:page-html": {
|
|
1143
1649
|
const targetTabId = requireFiniteId(params.tabId, "tabId");
|
|
1144
|
-
const
|
|
1650
|
+
const expectedUrl = typeof params.expectedUrl === "string" ? params.expectedUrl : "";
|
|
1651
|
+
const maxHtmlChars = typeof params.maxHtmlChars === "number" ? Math.max(1, Math.min(params.maxHtmlChars, MAX_PAGE_HTML_CHARS)) : 5e5;
|
|
1145
1652
|
const timeoutMs = typeof params.timeoutMs === "number" ? Math.max(1, params.timeoutMs) : 15e3;
|
|
1146
|
-
|
|
1653
|
+
if (expectedUrl && !await tabUrlMatches(targetTabId, expectedUrl)) {
|
|
1654
|
+
return urlMismatchPageHtmlResponse(expectedUrl);
|
|
1655
|
+
}
|
|
1656
|
+
const result = await content.extractPageHtml(targetTabId, timeoutMs, maxHtmlChars);
|
|
1657
|
+
if (expectedUrl && !await tabUrlMatches(targetTabId, expectedUrl)) {
|
|
1658
|
+
return urlMismatchPageHtmlResponse(expectedUrl);
|
|
1659
|
+
}
|
|
1660
|
+
return result;
|
|
1147
1661
|
}
|
|
1148
1662
|
default:
|
|
1149
1663
|
throw new Error(`Unknown action: ${action}`);
|
|
@@ -7,6 +7,7 @@ exports.executeWithTimeoutDetailed = executeWithTimeoutDetailed;
|
|
|
7
7
|
exports.extractPageMeta = extractPageMeta;
|
|
8
8
|
exports.extractPageMarkdown = extractPageMarkdown;
|
|
9
9
|
exports.extractPageHtml = extractPageHtml;
|
|
10
|
+
exports.probePageQuiescence = probePageQuiescence;
|
|
10
11
|
exports.extractSelectorSignal = extractSelectorSignal;
|
|
11
12
|
function delay(ms) {
|
|
12
13
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -153,6 +154,120 @@ async function extractPageHtml(tabId, timeoutMs, maxHtmlChars) {
|
|
|
153
154
|
error: null,
|
|
154
155
|
};
|
|
155
156
|
}
|
|
157
|
+
async function probePageQuiescence(tabId, timeoutMs, sampleWindowMs) {
|
|
158
|
+
const result = await executeWithTimeoutDetailed(tabId, timeoutMs, async (rawWindowMs) => {
|
|
159
|
+
const startedAt = Date.now();
|
|
160
|
+
const windowMs = Math.max(50, Math.min(Number(rawWindowMs) || 350, 1_500));
|
|
161
|
+
const htmlCap = 250_000;
|
|
162
|
+
const sample = () => {
|
|
163
|
+
const root = document.documentElement;
|
|
164
|
+
const bodyText = document.body?.innerText || root?.textContent || "";
|
|
165
|
+
const html = root?.outerHTML || "";
|
|
166
|
+
const resources = typeof performance !== "undefined" && typeof performance.getEntriesByType === "function"
|
|
167
|
+
? performance.getEntriesByType("resource").length
|
|
168
|
+
: null;
|
|
169
|
+
return {
|
|
170
|
+
textChars: bodyText.length,
|
|
171
|
+
htmlChars: Math.min(html.length, htmlCap),
|
|
172
|
+
domElements: document.getElementsByTagName("*").length,
|
|
173
|
+
resourceCount: resources,
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
const waitForIdleWindow = () => new Promise((resolve) => {
|
|
177
|
+
const win = window;
|
|
178
|
+
const done = () => setTimeout(resolve, windowMs);
|
|
179
|
+
if (typeof win.requestIdleCallback === "function") {
|
|
180
|
+
let resolved = false;
|
|
181
|
+
const finish = () => {
|
|
182
|
+
if (resolved) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
resolved = true;
|
|
186
|
+
done();
|
|
187
|
+
};
|
|
188
|
+
const fallback = setTimeout(finish, windowMs + 100);
|
|
189
|
+
win.requestIdleCallback(() => {
|
|
190
|
+
clearTimeout(fallback);
|
|
191
|
+
finish();
|
|
192
|
+
}, { timeout: windowMs });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
setTimeout(resolve, windowMs);
|
|
196
|
+
});
|
|
197
|
+
try {
|
|
198
|
+
const readyState = document.readyState;
|
|
199
|
+
if (readyState !== "interactive" && readyState !== "complete") {
|
|
200
|
+
return {
|
|
201
|
+
quiet: false,
|
|
202
|
+
reason: "not-ready",
|
|
203
|
+
documentReadyState: readyState,
|
|
204
|
+
before: null,
|
|
205
|
+
after: null,
|
|
206
|
+
elapsedMs: Date.now() - startedAt,
|
|
207
|
+
error: null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const before = sample();
|
|
211
|
+
await waitForIdleWindow();
|
|
212
|
+
const after = sample();
|
|
213
|
+
const stable = before.textChars === after.textChars
|
|
214
|
+
&& before.htmlChars === after.htmlChars
|
|
215
|
+
&& before.domElements === after.domElements
|
|
216
|
+
&& before.resourceCount === after.resourceCount;
|
|
217
|
+
return {
|
|
218
|
+
quiet: stable,
|
|
219
|
+
reason: stable ? "stable" : "changed",
|
|
220
|
+
documentReadyState: document.readyState,
|
|
221
|
+
before,
|
|
222
|
+
after,
|
|
223
|
+
elapsedMs: Date.now() - startedAt,
|
|
224
|
+
error: null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
return {
|
|
229
|
+
quiet: false,
|
|
230
|
+
reason: "probe-error",
|
|
231
|
+
documentReadyState: typeof document !== "undefined" ? document.readyState : null,
|
|
232
|
+
before: null,
|
|
233
|
+
after: null,
|
|
234
|
+
elapsedMs: Date.now() - startedAt,
|
|
235
|
+
error: err instanceof Error ? err.message : String(err),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}, [sampleWindowMs]);
|
|
239
|
+
if (result.kind === "timeout") {
|
|
240
|
+
return {
|
|
241
|
+
quiet: false,
|
|
242
|
+
reason: "timed-out",
|
|
243
|
+
documentReadyState: null,
|
|
244
|
+
before: null,
|
|
245
|
+
after: null,
|
|
246
|
+
elapsedMs: timeoutMs,
|
|
247
|
+
error: `Timed out after ${timeoutMs}ms`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (result.kind === "error") {
|
|
251
|
+
return {
|
|
252
|
+
quiet: false,
|
|
253
|
+
reason: "injection-error",
|
|
254
|
+
documentReadyState: null,
|
|
255
|
+
before: null,
|
|
256
|
+
after: null,
|
|
257
|
+
elapsedMs: 0,
|
|
258
|
+
error: result.message,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return result.value ?? {
|
|
262
|
+
quiet: false,
|
|
263
|
+
reason: "no-result",
|
|
264
|
+
documentReadyState: null,
|
|
265
|
+
before: null,
|
|
266
|
+
after: null,
|
|
267
|
+
elapsedMs: 0,
|
|
268
|
+
error: "Content script returned no quiescence probe payload",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
156
271
|
async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLength) {
|
|
157
272
|
if (!specs.length) {
|
|
158
273
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabctl",
|
|
3
|
-
"version": "0.6.0-rc.
|
|
3
|
+
"version": "0.6.0-rc.9",
|
|
4
4
|
"description": "CLI tool to manage and analyze browser tabs",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"typescript": "^5.4.5"
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
56
|
-
"tabctl-win32-x64": "0.6.0-rc.
|
|
56
|
+
"tabctl-win32-x64": "0.6.0-rc.9"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"normalize-url": "^8.1.1"
|