@aryanduntley/pwa-debug 0.1.8 → 1.0.0

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.
@@ -1936,26 +1936,47 @@ var PwaDebugPageWorld = (function (exports) {
1936
1936
  ]);
1937
1937
  return actionOk('swipe', { defaultPrevented, detail: { direction: opts.direction, distance, steps } });
1938
1938
  };
1939
- /** One touchstart/touchend pair at a point; returns defaultPrevented. */
1939
+ /**
1940
+ * One tap at a point, in the order a phone browser produces it: pointer and
1941
+ * touch phases, then the compatibility mouse events and `click` (which is what
1942
+ * onClick handlers listen for). Synthetic touch events never make the browser
1943
+ * emit that click itself, so we do. A page that calls preventDefault() on
1944
+ * touchend is suppressing the click on purpose, so it is skipped then.
1945
+ */
1940
1946
  const tapOnce = (el, c) => {
1941
- dispatchAll(el, [makeTouchEvent('touchstart', [makeTouch(el, c.x, c.y)])]);
1942
- return dispatchAll(el, [makeTouchEvent('touchend', [], [makeTouch(el, c.x, c.y)])]).defaultPrevented;
1947
+ const at = { clientX: c.x, clientY: c.y };
1948
+ const touch = { ...at, pointerType: 'touch' };
1949
+ dispatchAll(el, [makePointerEvent('pointerdown', touch), makeTouchEvent('touchstart', [makeTouch(el, c.x, c.y)])]);
1950
+ dispatchAll(el, [makePointerEvent('pointerup', touch)]);
1951
+ const end = dispatchAll(el, [makeTouchEvent('touchend', [], [makeTouch(el, c.x, c.y)])]);
1952
+ if (end.defaultPrevented)
1953
+ return { defaultPrevented: true, clicked: false };
1954
+ const clicked = dispatchAll(el, [
1955
+ makeMouseEvent('mousedown', at),
1956
+ makeMouseEvent('mouseup', at),
1957
+ makeMouseEvent('click', at),
1958
+ ]);
1959
+ return { defaultPrevented: clicked.defaultPrevented, clicked: true };
1943
1960
  };
1944
- /** Tap an element (single touchstart/touchend at its center). */
1961
+ /** Tap an element at its center: touch phases plus the click a real tap yields. */
1945
1962
  const tapElement = (el) => {
1946
1963
  if (!isElement$1(el))
1947
1964
  return actionFail('tap', 'target is not an element');
1948
- const defaultPrevented = tapOnce(el, centerOf(el));
1949
- return actionOk('tap', { defaultPrevented });
1965
+ el.focus?.();
1966
+ const r = tapOnce(el, centerOf(el));
1967
+ return actionOk('tap', { defaultPrevented: r.defaultPrevented, detail: { clicked: r.clicked } });
1950
1968
  };
1951
- /** Double-tap an element (two touchstart/touchend pairs). */
1969
+ /** Double-tap an element: two taps (each with its click) then dblclick. */
1952
1970
  const doubleTapElement = (el) => {
1953
1971
  if (!isElement$1(el))
1954
1972
  return actionFail('doubleTap', 'target is not an element');
1973
+ el.focus?.();
1955
1974
  const c = centerOf(el);
1956
- tapOnce(el, c);
1957
- const defaultPrevented = tapOnce(el, c);
1958
- return actionOk('doubleTap', { defaultPrevented });
1975
+ const first = tapOnce(el, c);
1976
+ const second = tapOnce(el, c);
1977
+ const clicked = first.clicked && second.clicked;
1978
+ const dbl = clicked ? dispatchAll(el, [makeMouseEvent('dblclick', { clientX: c.x, clientY: c.y })]).defaultPrevented : false;
1979
+ return actionOk('doubleTap', { defaultPrevented: second.defaultPrevented || dbl, detail: { clicked } });
1959
1980
  };
1960
1981
  /**
1961
1982
  * Long-press (async): touchstart, hold for `duration` ms so the page's
@@ -12472,6 +12493,11 @@ var PwaDebugPageWorld = (function (exports) {
12472
12493
  return shim;
12473
12494
  };
12474
12495
 
12496
+ /**
12497
+ * Window flag set by the extension page-world entry. The CDP entry reads it so
12498
+ * it never installs a second capture set into a page the extension owns.
12499
+ */
12500
+ const EXTENSION_PAGE_WORLD_GUARD = '__pwaDebugPageWorldInstalled';
12475
12501
  const probeGlobals = () => {
12476
12502
  const w = window;
12477
12503
  return {
@@ -12523,39 +12549,23 @@ var PwaDebugPageWorld = (function (exports) {
12523
12549
  svelte: early.svelte || dom.svelte,
12524
12550
  redux: early.redux,
12525
12551
  });
12526
- const installBridgeListener = () => {
12527
- window.addEventListener('message', (event) => {
12528
- if (!isInboundCsToPage(event))
12529
- return;
12530
- dispatchPageRequest(event.data).then((response) => {
12531
- window.postMessage(response, window.location.origin);
12532
- }, (err) => {
12533
- console.warn('[pwa-debug/page] dispatchPageRequest rejected (should not happen):', err.message);
12534
- });
12535
- });
12536
- };
12537
- const installCaptures = (frame) => {
12538
- const emit = (event) => {
12539
- window.postMessage(encodeEvent(event), window.location.origin);
12540
- };
12541
- const kinds = {
12542
- console: typeof console !== 'undefined',
12543
- fetch: typeof globalThis.fetch === 'function',
12544
- xhr: typeof globalThis.XMLHttpRequest === 'function',
12545
- websocket: typeof globalThis.WebSocket === 'function',
12546
- dom_mutation: typeof MutationObserver !== 'undefined' &&
12547
- typeof document !== 'undefined',
12548
- lifecycle: typeof window !== 'undefined' &&
12549
- typeof document !== 'undefined' &&
12550
- typeof history?.pushState === 'function',
12551
- library_popup: typeof MutationObserver !== 'undefined' &&
12552
- typeof document !== 'undefined' &&
12553
- typeof Element !== 'undefined',
12554
- page_error: typeof window !== 'undefined' &&
12555
- typeof window.addEventListener === 'function',
12556
- sw_state: typeof navigator !== 'undefined' &&
12557
- navigator.serviceWorker !== undefined,
12558
- };
12552
+ const detectKinds = () => ({
12553
+ console: typeof console !== 'undefined',
12554
+ fetch: typeof globalThis.fetch === 'function',
12555
+ xhr: typeof globalThis.XMLHttpRequest === 'function',
12556
+ websocket: typeof globalThis.WebSocket === 'function',
12557
+ dom_mutation: typeof MutationObserver !== 'undefined' && typeof document !== 'undefined',
12558
+ lifecycle: typeof window !== 'undefined' &&
12559
+ typeof document !== 'undefined' &&
12560
+ typeof history?.pushState === 'function',
12561
+ library_popup: typeof MutationObserver !== 'undefined' &&
12562
+ typeof document !== 'undefined' &&
12563
+ typeof Element !== 'undefined',
12564
+ page_error: typeof window !== 'undefined' && typeof window.addEventListener === 'function',
12565
+ sw_state: typeof navigator !== 'undefined' && navigator.serviceWorker !== undefined,
12566
+ });
12567
+ const installCaptures = (emit, frame) => {
12568
+ const kinds = detectKinds();
12559
12569
  const disposers = [
12560
12570
  installConsoleCapture(emit, frame),
12561
12571
  installFetchCapture(emit, frame),
@@ -12574,7 +12584,61 @@ var PwaDebugPageWorld = (function (exports) {
12574
12584
  };
12575
12585
  return { dispose, kinds };
12576
12586
  };
12577
- const INSTALL_GUARD = '__pwaDebugPageWorldInstalled';
12587
+ const NOOP = () => undefined;
12588
+ /**
12589
+ * Start the page world. Transport-specific concerns (idempotency guard, how
12590
+ * events leave, how requests arrive) belong to the entry point that calls this.
12591
+ */
12592
+ const bootPageWorld = (opts) => {
12593
+ // Redux store capture is PASSIVE — read-only react-redux fiber-context
12594
+ // discovery (see stores/redux/discover); we do not impersonate
12595
+ // __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ (that broke RTK apps).
12596
+ //
12597
+ // Zustand's devtools middleware calls __REDUX_DEVTOOLS_EXTENSION__.connect(...).
12598
+ // With no real devtools present the shim installs a benign callable carrier
12599
+ // that provides `.connect` (and no-ops if the real Redux DevTools owns it).
12600
+ const zustandShim = installZustandDevtoolsShim(window);
12601
+ setZustandShim(zustandShim);
12602
+ opts.installRequestChannel();
12603
+ const frame = computeFrameMeta();
12604
+ const installed = opts.captures === false ? null : installCaptures(opts.emit, frame);
12605
+ console.log('[pwa-debug/page] captures', installed ? 'installed' : 'skipped', {
12606
+ frame,
12607
+ kinds: installed?.kinds ?? null,
12608
+ });
12609
+ const early = probeGlobals();
12610
+ console.log('[pwa-debug/page] world=MAIN, hooks(early)=', early);
12611
+ const reportLate = () => {
12612
+ console.log('[pwa-debug/page] hooks(post-load)=', merge(early, probeDom()));
12613
+ };
12614
+ if (document.readyState === 'loading') {
12615
+ document.addEventListener('DOMContentLoaded', reportLate, { once: true });
12616
+ }
12617
+ else {
12618
+ setTimeout(reportLate, 0);
12619
+ }
12620
+ return Object.freeze({
12621
+ kinds: installed?.kinds ?? null,
12622
+ dispose: installed?.dispose ?? NOOP,
12623
+ });
12624
+ };
12625
+
12626
+ // Extension transport: requests arrive from the content script as postMessage
12627
+ // envelopes, and captured events leave the same way.
12628
+ const installBridgeListener = () => {
12629
+ window.addEventListener('message', (event) => {
12630
+ if (!isInboundCsToPage(event))
12631
+ return;
12632
+ dispatchPageRequest(event.data).then((response) => {
12633
+ window.postMessage(response, window.location.origin);
12634
+ }, (err) => {
12635
+ console.warn('[pwa-debug/page] dispatchPageRequest rejected (should not happen):', err.message);
12636
+ });
12637
+ });
12638
+ };
12639
+ const emit = (event) => {
12640
+ window.postMessage(encodeEvent(event), window.location.origin);
12641
+ };
12578
12642
  const bootstrap = () => {
12579
12643
  // Idempotency guard. page-world.js can run more than once in the same MAIN
12580
12644
  // world: the manifest injects it at document_start, and the SW self-heal
@@ -12587,40 +12651,13 @@ var PwaDebugPageWorld = (function (exports) {
12587
12651
  // on `window` and persists across the reload, so the re-injection no-ops here
12588
12652
  // and the (orphaned-but-functional) original listener stays the sole handler.
12589
12653
  const w = window;
12590
- if (w[INSTALL_GUARD])
12654
+ if (w[EXTENSION_PAGE_WORLD_GUARD])
12591
12655
  return;
12592
- Object.defineProperty(window, INSTALL_GUARD, {
12656
+ Object.defineProperty(window, EXTENSION_PAGE_WORLD_GUARD, {
12593
12657
  value: true,
12594
12658
  configurable: true,
12595
12659
  });
12596
- // Redux store capture is PASSIVE now — read-only react-redux fiber-context
12597
- // discovery (see stores/redux/discover). We no longer impersonate
12598
- // __REDUX_DEVTOOLS_EXTENSION_COMPOSE__, which used to break RTK apps by
12599
- // sitting in their store-creation path.
12600
- //
12601
- // Install the Zustand devtools-connect shim: zustand's devtools middleware
12602
- // calls __REDUX_DEVTOOLS_EXTENSION__.connect(...). With no real devtools and
12603
- // no Redux stub present, the shim installs a benign callable carrier that
12604
- // also provides `.connect` (and no-ops if the real Redux DevTools owns the
12605
- // hook).
12606
- const zustandShim = installZustandDevtoolsShim(window);
12607
- setZustandShim(zustandShim);
12608
- installBridgeListener();
12609
- const frame = computeFrameMeta();
12610
- const { kinds } = installCaptures(frame);
12611
- console.log('[pwa-debug/page] captures installed', { frame, kinds });
12612
- const early = probeGlobals();
12613
- console.log('[pwa-debug/page] world=MAIN, hooks(early)=', early);
12614
- const reportLate = () => {
12615
- const merged = merge(early, probeDom());
12616
- console.log('[pwa-debug/page] hooks(post-load)=', merged);
12617
- };
12618
- if (document.readyState === 'loading') {
12619
- document.addEventListener('DOMContentLoaded', reportLate, { once: true });
12620
- }
12621
- else {
12622
- setTimeout(reportLate, 0);
12623
- }
12660
+ bootPageWorld({ emit, installRequestChannel: installBridgeListener });
12624
12661
  };
12625
12662
  bootstrap();
12626
12663
 
@@ -176,110 +176,6 @@ const dispatchToTabClassified = async (tabId, req, opts = {}) => {
176
176
  return { ok: true, response };
177
177
  };
178
178
 
179
- // SW-side navigation primitives — wraps chrome.tabs URL navigation so the request
180
- // router stays a thin orchestrator. SW-handled (NOT page-world): chrome.tabs is
181
- // only reachable in the extension service worker, and driving the tab to a new URL
182
- // is a browser action, not an in-page read. This is the pwa-debug counterpart to
183
- // chrome-devtools-mcp's navigate_page/new_page — it works through the loaded
184
- // extension on the user's real profile, with no CDP attach required.
185
- const DEFAULT_LOAD_TIMEOUT_MS = 10_000;
186
- // A bare javascript: URL would run script in the target page context on navigate;
187
- // reject it. Everything else (http(s), file:, about:, data:, chrome:, localhost
188
- // shorthand) is allowed — this is a debugging driver, not a sandbox.
189
- const JAVASCRIPT_SCHEME = /^javascript:/i;
190
- const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
191
- /**
192
- * Normalize a caller-supplied URL: trim, reject empty / javascript:, and assume
193
- * https:// when no scheme is given (so `example.com` works). Returns null when the
194
- * input is unusable so the caller can surface a clear error.
195
- */
196
- const normalizeUrl = (raw) => {
197
- const trimmed = raw.trim();
198
- if (trimmed.length === 0)
199
- return null;
200
- if (JAVASCRIPT_SCHEME.test(trimmed))
201
- return null;
202
- return HAS_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`;
203
- };
204
- /** Active tab id in the last-focused window, or undefined when there is none. */
205
- const activeTabId = async () => {
206
- const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
207
- return tabs[0]?.id;
208
- };
209
- /**
210
- * Resolve once the tab reports document 'complete', or 'timeout' after timeoutMs.
211
- * The onUpdated listener is added BEFORE the caller triggers the load so the
212
- * 'complete' event can't be missed; it is always removed and the timer cleared.
213
- */
214
- const waitForComplete = (tabId, timeoutMs) => new Promise((resolve) => {
215
- let settled = false;
216
- const settle = (outcome) => {
217
- if (settled)
218
- return;
219
- settled = true;
220
- chrome.tabs.onUpdated.removeListener(onUpdated);
221
- clearTimeout(timer);
222
- resolve(outcome);
223
- };
224
- const onUpdated = (id, info) => {
225
- if (id === tabId && info.status === 'complete')
226
- settle('complete');
227
- };
228
- const timer = setTimeout(() => settle('timeout'), timeoutMs);
229
- chrome.tabs.onUpdated.addListener(onUpdated);
230
- });
231
- /**
232
- * Navigate the active (or given) tab to url and wait for it to finish loading.
233
- * Throws on an invalid url or when no tab can be resolved. The listener is armed
234
- * before chrome.tabs.update so the fresh load's 'complete' is observed.
235
- */
236
- const navigateTab = async (input) => {
237
- const url = normalizeUrl(input.url);
238
- if (url === null) {
239
- throw new Error(`navigate: invalid or unsupported url: ${JSON.stringify(input.url)}`);
240
- }
241
- const tabId = input.tabId ?? (await activeTabId());
242
- if (tabId === undefined) {
243
- throw new Error('navigate: no active tab (open a tab or pass tab_id)');
244
- }
245
- const pending = waitForComplete(tabId, input.timeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);
246
- const tab = await chrome.tabs.update(tabId, { url });
247
- const outcome = await pending;
248
- return {
249
- tabId,
250
- url,
251
- ...(tab?.windowId !== undefined ? { windowId: tab.windowId } : {}),
252
- status: outcome === 'complete' ? 'complete' : 'loading',
253
- };
254
- };
255
- /**
256
- * Open a new tab at url and wait for it to finish loading. active defaults to
257
- * Chrome's behavior (foreground). Throws on an invalid url or when Chrome returns
258
- * no tab id.
259
- */
260
- const openNewTab = async (input) => {
261
- const url = normalizeUrl(input.url);
262
- if (url === null) {
263
- throw new Error(`new_tab: invalid or unsupported url: ${JSON.stringify(input.url)}`);
264
- }
265
- const tab = await chrome.tabs.create({
266
- url,
267
- ...(input.active !== undefined ? { active: input.active } : {}),
268
- });
269
- const tabId = tab.id;
270
- if (tabId === undefined) {
271
- throw new Error('new_tab: chrome did not return a tab id');
272
- }
273
- const outcome = await waitForComplete(tabId, input.timeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);
274
- return {
275
- tabId,
276
- url,
277
- ...(tab.windowId !== undefined ? { windowId: tab.windowId } : {}),
278
- status: outcome === 'complete' ? 'complete' : 'loading',
279
- created: true,
280
- };
281
- };
282
-
283
179
  const compilePatternList = (sources, fieldPathPrefix) => {
284
180
  if (sources === undefined || sources.length === 0) {
285
181
  return { ok: true, value: [] };
@@ -665,6 +561,206 @@ const ACTION_TOOL_SPECS = Object.freeze([
665
561
  { tool: 'pdl_pinch', action: 'pinch', params: [N('scale', true, 'target scale: >1 zoom in, <1 zoom out'), N('steps', false, 'touchmove steps (default 10)')], summary: 'Pinch-zoom on the located element with two touches.' },
666
562
  ]);
667
563
 
564
+ // A bare javascript: URL would run script in the target page context on navigate;
565
+ // reject it. Everything else (http(s), file:, about:, data:, chrome:, localhost
566
+ // shorthand) is allowed — this is a debugging driver, not a sandbox.
567
+ const JAVASCRIPT_SCHEME = /^javascript:/i;
568
+ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
569
+ /**
570
+ * Normalize a caller-supplied navigation URL: trim, reject empty / javascript:,
571
+ * and assume https:// when no scheme is given (so `example.com` works). Returns
572
+ * null when the input is unusable so the caller can surface a clear error.
573
+ * Shared by the extension (chrome.tabs) and the host (CDP Page.navigate) so both
574
+ * navigation paths accept exactly the same URLs.
575
+ */
576
+ const normalizeUrl = (raw) => {
577
+ const trimmed = raw.trim();
578
+ if (trimmed.length === 0)
579
+ return null;
580
+ if (JAVASCRIPT_SCHEME.test(trimmed))
581
+ return null;
582
+ return HAS_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`;
583
+ };
584
+
585
+ const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g;
586
+ const globToRegex = (pattern) => {
587
+ const escaped = pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*');
588
+ return new RegExp(`^${escaped}$`);
589
+ };
590
+ const matchesAnyGlob = (value, patterns) => patterns.some((p) => globToRegex(p).test(value));
591
+ /**
592
+ * Raw captured-event kinds (6) -> M7 capture categories (4).
593
+ * The union side reflects the captured_event.ts kind discriminants.
594
+ */
595
+ const KIND_MAP = Object.freeze({
596
+ console: 'console',
597
+ fetch: 'network',
598
+ xhr: 'network',
599
+ websocket: 'network',
600
+ dom_mutation: 'dom_mutations',
601
+ lifecycle: 'lifecycle',
602
+ // App service-worker lifecycle: its own capture category + host buffer.
603
+ sw_state: 'sw_state',
604
+ store_change: 'store_change',
605
+ replay: 'replay',
606
+ library_popup: 'library_popup',
607
+ });
608
+ const eventKindToCaptureKind = (rawKind) => KIND_MAP[rawKind] ?? null;
609
+ const isKindEnabled = (rawKind, enabledKinds) => {
610
+ const ck = eventKindToCaptureKind(rawKind);
611
+ if (ck === null)
612
+ return true; // unknown raw kinds default-allow (future-compat)
613
+ return enabledKinds.includes(ck);
614
+ };
615
+ const isUrlAllowed = (url, allowlist, blocklist) => {
616
+ if (matchesAnyGlob(url, blocklist))
617
+ return false; // blocklist wins
618
+ if (allowlist.length === 0)
619
+ return false; // explicit opt-in model
620
+ return matchesAnyGlob(url, allowlist);
621
+ };
622
+ /**
623
+ * Pick the most-specific readControls entry whose glob pattern matches `url`.
624
+ * Specificity = longest pattern string; ties broken by lexicographic order of
625
+ * the pattern so the choice is deterministic. Returns undefined when no
626
+ * pattern matches (caller treats as no restriction).
627
+ */
628
+ const pickMostSpecificReadControl = (url, controls) => {
629
+ let winnerPattern;
630
+ let winnerValue;
631
+ for (const [pattern, value] of Object.entries(controls)) {
632
+ if (!matchesAnyGlob(url, [pattern]))
633
+ continue;
634
+ if (winnerPattern === undefined) {
635
+ winnerPattern = pattern;
636
+ winnerValue = value;
637
+ continue;
638
+ }
639
+ if (pattern.length > winnerPattern.length ||
640
+ (pattern.length === winnerPattern.length && pattern < winnerPattern)) {
641
+ winnerPattern = pattern;
642
+ winnerValue = value;
643
+ }
644
+ }
645
+ return winnerValue;
646
+ };
647
+ /**
648
+ * True iff the resolved readControls entry permits the given CaptureKind.
649
+ * Missing flag = allowed (no restriction); explicit `false` denies. Undefined
650
+ * control (no matching readControls entry) = allowed.
651
+ */
652
+ const isKindAllowedByReadControls = (kind, control) => control?.[kind] !== false;
653
+ /**
654
+ * True iff the per-kind capture.filters predicate (if configured) accepts the
655
+ * event. No filter for the resolved kind => allow. compileSourceFilter ok:false
656
+ * (malformed regex despite the schema validator) => fail-open (allow) so a
657
+ * single bad filter cannot suppress all events of a kind.
658
+ */
659
+ const passesCaptureFilter = (event, captureKind, filters) => {
660
+ const spec = filters[captureKind];
661
+ if (spec === undefined)
662
+ return true;
663
+ const compiled = compileSourceFilter(spec);
664
+ if (!compiled.ok)
665
+ return true;
666
+ return compiled.predicate(event);
667
+ };
668
+ const shouldCaptureEvent = (event, settings) => {
669
+ if (!isKindEnabled(event.kind, settings['capture.enabledKinds'])) {
670
+ return false;
671
+ }
672
+ if (!isUrlAllowed(event.frameUrl, settings['sites.allowlist'], settings['sites.blocklist'])) {
673
+ return false;
674
+ }
675
+ const captureKind = eventKindToCaptureKind(event.kind);
676
+ if (captureKind === null)
677
+ return true; // unknown raw kinds default-allow
678
+ const control = pickMostSpecificReadControl(event.frameUrl, settings['sites.readControls']);
679
+ if (!isKindAllowedByReadControls(captureKind, control))
680
+ return false;
681
+ return passesCaptureFilter(event, captureKind, settings['capture.filters']);
682
+ };
683
+
684
+ const DEFAULT_LOAD_TIMEOUT_MS = 10_000;
685
+ /** Active tab id in the last-focused window, or undefined when there is none. */
686
+ const activeTabId = async () => {
687
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
688
+ return tabs[0]?.id;
689
+ };
690
+ /**
691
+ * Resolve once the tab reports document 'complete', or 'timeout' after timeoutMs.
692
+ * The onUpdated listener is added BEFORE the caller triggers the load so the
693
+ * 'complete' event can't be missed; it is always removed and the timer cleared.
694
+ */
695
+ const waitForComplete = (tabId, timeoutMs) => new Promise((resolve) => {
696
+ let settled = false;
697
+ const settle = (outcome) => {
698
+ if (settled)
699
+ return;
700
+ settled = true;
701
+ chrome.tabs.onUpdated.removeListener(onUpdated);
702
+ clearTimeout(timer);
703
+ resolve(outcome);
704
+ };
705
+ const onUpdated = (id, info) => {
706
+ if (id === tabId && info.status === 'complete')
707
+ settle('complete');
708
+ };
709
+ const timer = setTimeout(() => settle('timeout'), timeoutMs);
710
+ chrome.tabs.onUpdated.addListener(onUpdated);
711
+ });
712
+ /**
713
+ * Navigate the active (or given) tab to url and wait for it to finish loading.
714
+ * Throws on an invalid url or when no tab can be resolved. The listener is armed
715
+ * before chrome.tabs.update so the fresh load's 'complete' is observed.
716
+ */
717
+ const navigateTab = async (input) => {
718
+ const url = normalizeUrl(input.url);
719
+ if (url === null) {
720
+ throw new Error(`navigate: invalid or unsupported url: ${JSON.stringify(input.url)}`);
721
+ }
722
+ const tabId = input.tabId ?? (await activeTabId());
723
+ if (tabId === undefined) {
724
+ throw new Error('navigate: no active tab (open a tab or pass tab_id)');
725
+ }
726
+ const pending = waitForComplete(tabId, input.timeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);
727
+ const tab = await chrome.tabs.update(tabId, { url });
728
+ const outcome = await pending;
729
+ return {
730
+ tabId,
731
+ url,
732
+ ...(tab?.windowId !== undefined ? { windowId: tab.windowId } : {}),
733
+ status: outcome === 'complete' ? 'complete' : 'loading',
734
+ };
735
+ };
736
+ /**
737
+ * Open a new tab at url and wait for it to finish loading. active defaults to
738
+ * Chrome's behavior (foreground). Throws on an invalid url or when Chrome returns
739
+ * no tab id.
740
+ */
741
+ const openNewTab = async (input) => {
742
+ const url = normalizeUrl(input.url);
743
+ if (url === null) {
744
+ throw new Error(`new_tab: invalid or unsupported url: ${JSON.stringify(input.url)}`);
745
+ }
746
+ const tab = await chrome.tabs.create({
747
+ url,
748
+ ...(input.active !== undefined ? { active: input.active } : {}),
749
+ });
750
+ const tabId = tab.id;
751
+ if (tabId === undefined) {
752
+ throw new Error('new_tab: chrome did not return a tab id');
753
+ }
754
+ const outcome = await waitForComplete(tabId, input.timeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);
755
+ return {
756
+ tabId,
757
+ url,
758
+ ...(tab.windowId !== undefined ? { windowId: tab.windowId } : {}),
759
+ status: outcome === 'complete' ? 'complete' : 'loading',
760
+ created: true,
761
+ };
762
+ };
763
+
668
764
  const isSwRequestEnvelope = (m) => {
669
765
  if (m === null || typeof m !== 'object')
670
766
  return false;
@@ -2175,105 +2271,6 @@ const createSettingsCache = () => {
2175
2271
  return { getSetting, getAll, applySnapshot, applyChange };
2176
2272
  };
2177
2273
 
2178
- const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g;
2179
- const globToRegex = (pattern) => {
2180
- const escaped = pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*');
2181
- return new RegExp(`^${escaped}$`);
2182
- };
2183
- const matchesAnyGlob = (value, patterns) => patterns.some((p) => globToRegex(p).test(value));
2184
- /**
2185
- * Raw captured-event kinds (6) -> M7 capture categories (4).
2186
- * The union side reflects the captured_event.ts kind discriminants.
2187
- */
2188
- const KIND_MAP = Object.freeze({
2189
- console: 'console',
2190
- fetch: 'network',
2191
- xhr: 'network',
2192
- websocket: 'network',
2193
- dom_mutation: 'dom_mutations',
2194
- lifecycle: 'lifecycle',
2195
- // App service-worker lifecycle: its own capture category + host buffer.
2196
- sw_state: 'sw_state',
2197
- store_change: 'store_change',
2198
- replay: 'replay',
2199
- library_popup: 'library_popup',
2200
- });
2201
- const eventKindToCaptureKind = (rawKind) => KIND_MAP[rawKind] ?? null;
2202
- const isKindEnabled = (rawKind, enabledKinds) => {
2203
- const ck = eventKindToCaptureKind(rawKind);
2204
- if (ck === null)
2205
- return true; // unknown raw kinds default-allow (future-compat)
2206
- return enabledKinds.includes(ck);
2207
- };
2208
- const isUrlAllowed = (url, allowlist, blocklist) => {
2209
- if (matchesAnyGlob(url, blocklist))
2210
- return false; // blocklist wins
2211
- if (allowlist.length === 0)
2212
- return false; // explicit opt-in model
2213
- return matchesAnyGlob(url, allowlist);
2214
- };
2215
- /**
2216
- * Pick the most-specific readControls entry whose glob pattern matches `url`.
2217
- * Specificity = longest pattern string; ties broken by lexicographic order of
2218
- * the pattern so the choice is deterministic. Returns undefined when no
2219
- * pattern matches (caller treats as no restriction).
2220
- */
2221
- const pickMostSpecificReadControl = (url, controls) => {
2222
- let winnerPattern;
2223
- let winnerValue;
2224
- for (const [pattern, value] of Object.entries(controls)) {
2225
- if (!matchesAnyGlob(url, [pattern]))
2226
- continue;
2227
- if (winnerPattern === undefined) {
2228
- winnerPattern = pattern;
2229
- winnerValue = value;
2230
- continue;
2231
- }
2232
- if (pattern.length > winnerPattern.length ||
2233
- (pattern.length === winnerPattern.length && pattern < winnerPattern)) {
2234
- winnerPattern = pattern;
2235
- winnerValue = value;
2236
- }
2237
- }
2238
- return winnerValue;
2239
- };
2240
- /**
2241
- * True iff the resolved readControls entry permits the given CaptureKind.
2242
- * Missing flag = allowed (no restriction); explicit `false` denies. Undefined
2243
- * control (no matching readControls entry) = allowed.
2244
- */
2245
- const isKindAllowedByReadControls = (kind, control) => control?.[kind] !== false;
2246
- /**
2247
- * True iff the per-kind capture.filters predicate (if configured) accepts the
2248
- * event. No filter for the resolved kind => allow. compileSourceFilter ok:false
2249
- * (malformed regex despite the schema validator) => fail-open (allow) so a
2250
- * single bad filter cannot suppress all events of a kind.
2251
- */
2252
- const passesCaptureFilter = (event, captureKind, filters) => {
2253
- const spec = filters[captureKind];
2254
- if (spec === undefined)
2255
- return true;
2256
- const compiled = compileSourceFilter(spec);
2257
- if (!compiled.ok)
2258
- return true;
2259
- return compiled.predicate(event);
2260
- };
2261
- const shouldCaptureEvent = (event, settings) => {
2262
- if (!isKindEnabled(event.kind, settings['capture.enabledKinds'])) {
2263
- return false;
2264
- }
2265
- if (!isUrlAllowed(event.frameUrl, settings['sites.allowlist'], settings['sites.blocklist'])) {
2266
- return false;
2267
- }
2268
- const captureKind = eventKindToCaptureKind(event.kind);
2269
- if (captureKind === null)
2270
- return true; // unknown raw kinds default-allow
2271
- const control = pickMostSpecificReadControl(event.frameUrl, settings['sites.readControls']);
2272
- if (!isKindAllowedByReadControls(captureKind, control))
2273
- return false;
2274
- return passesCaptureFilter(event, captureKind, settings['capture.filters']);
2275
- };
2276
-
2277
2274
  const isSettingsEventMessage = (msg) => {
2278
2275
  if (msg === null || typeof msg !== 'object')
2279
2276
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryanduntley/pwa-debug",
3
- "version": "0.1.8",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,8 +63,8 @@
63
63
  "@types/winreg": "^1.2.36",
64
64
  "rollup": "^4.27.4",
65
65
  "tslib": "^2.8.1",
66
- "@pwa-debug/extension": "0.1.8",
67
- "@pwa-debug/shared": "0.1.8"
66
+ "@pwa-debug/extension": "1.0.0",
67
+ "@pwa-debug/shared": "1.0.0"
68
68
  },
69
69
  "scripts": {
70
70
  "typecheck": "tsc --noEmit",