@aryanduntley/pwa-debug 0.2.0 → 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.
- package/README.md +48 -1
- package/dist/main.js +9158 -7096
- package/extension/page-world-cdp.js +12746 -0
- package/extension/page-world.js +111 -74
- package/extension/service-worker.js +200 -203
- package/package.json +3 -3
package/extension/page-world.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
1942
|
-
|
|
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
|
|
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
|
-
|
|
1949
|
-
|
|
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
|
|
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
|
|
1958
|
-
|
|
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
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
|
|
12535
|
-
|
|
12536
|
-
|
|
12537
|
-
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
12541
|
-
|
|
12542
|
-
|
|
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
|
|
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[
|
|
12654
|
+
if (w[EXTENSION_PAGE_WORLD_GUARD])
|
|
12591
12655
|
return;
|
|
12592
|
-
Object.defineProperty(window,
|
|
12656
|
+
Object.defineProperty(window, EXTENSION_PAGE_WORLD_GUARD, {
|
|
12593
12657
|
value: true,
|
|
12594
12658
|
configurable: true,
|
|
12595
12659
|
});
|
|
12596
|
-
|
|
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.
|
|
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.
|
|
67
|
-
"@pwa-debug/shared": "0.
|
|
66
|
+
"@pwa-debug/extension": "1.0.0",
|
|
67
|
+
"@pwa-debug/shared": "1.0.0"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"typecheck": "tsc --noEmit",
|