@aryanduntley/pwa-debug 0.1.2
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/LICENSE +21 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/main.js +15850 -0
- package/dist/main.js.map +1 -0
- package/extension/content-script.js +267 -0
- package/extension/content-script.js.map +1 -0
- package/extension/manifest.json +30 -0
- package/extension/page-world.js +12632 -0
- package/extension/page-world.js.map +1 -0
- package/extension/service-worker.js +2247 -0
- package/extension/service-worker.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,2247 @@
|
|
|
1
|
+
const probeTabScripting = async (tabId) => {
|
|
2
|
+
try {
|
|
3
|
+
await chrome.scripting.executeScript({
|
|
4
|
+
target: { tabId },
|
|
5
|
+
world: 'ISOLATED',
|
|
6
|
+
func: () => '__pwa_debug_probe__',
|
|
7
|
+
});
|
|
8
|
+
return 'scripts_run';
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return 'scripts_blocked';
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const RESTRICTED_PROTOCOLS = [
|
|
15
|
+
'chrome:',
|
|
16
|
+
'chrome-extension:',
|
|
17
|
+
'about:',
|
|
18
|
+
'devtools:',
|
|
19
|
+
'edge:',
|
|
20
|
+
'brave:',
|
|
21
|
+
'view-source:',
|
|
22
|
+
'file:',
|
|
23
|
+
];
|
|
24
|
+
const RESTRICTED_HOST_SUFFIXES = [
|
|
25
|
+
'chromewebstore.google.com',
|
|
26
|
+
'chrome.google.com',
|
|
27
|
+
];
|
|
28
|
+
const classifyRestrictedUrl = (url) => {
|
|
29
|
+
if (!url)
|
|
30
|
+
return null;
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(url);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (RESTRICTED_PROTOCOLS.includes(parsed.protocol))
|
|
39
|
+
return 'restricted_url';
|
|
40
|
+
if (RESTRICTED_HOST_SUFFIXES.includes(parsed.hostname)) {
|
|
41
|
+
return 'restricted_url';
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
const classifyDispatchFailure = async (input) => {
|
|
46
|
+
const restricted = classifyRestrictedUrl(input.url);
|
|
47
|
+
if (restricted !== null) {
|
|
48
|
+
return { code: 'restricted_url', message: input.lastErrorMessage };
|
|
49
|
+
}
|
|
50
|
+
const probe = await probeTabScripting(input.tabId);
|
|
51
|
+
if (probe === 'scripts_blocked') {
|
|
52
|
+
return { code: 'page_blocks_scripts', message: input.lastErrorMessage };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
code: 'cs_not_attached_refresh_tab',
|
|
56
|
+
message: input.lastErrorMessage,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
const CS_BUNDLE_PATH = 'content-script.js';
|
|
60
|
+
const PAGE_WORLD_BUNDLE_PATH = 'page-world.js';
|
|
61
|
+
const selfHealCsAttachment = async (tabId) => {
|
|
62
|
+
try {
|
|
63
|
+
await chrome.scripting.executeScript({
|
|
64
|
+
target: { tabId },
|
|
65
|
+
world: 'ISOLATED',
|
|
66
|
+
files: [CS_BUNDLE_PATH],
|
|
67
|
+
});
|
|
68
|
+
await chrome.scripting.executeScript({
|
|
69
|
+
target: { tabId },
|
|
70
|
+
world: 'MAIN',
|
|
71
|
+
files: [PAGE_WORLD_BUNDLE_PATH],
|
|
72
|
+
});
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return { ok: false, reason: err.message };
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const DEFAULT_TIMEOUT_MS = 4500;
|
|
81
|
+
const SELF_HEAL_SETTLE_MS = 100;
|
|
82
|
+
const dispatchToTab = async (tabId, req, opts = {}) => {
|
|
83
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
84
|
+
let timeoutHandle;
|
|
85
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
86
|
+
timeoutHandle = setTimeout(() => {
|
|
87
|
+
reject(new Error(`sw-tab-dispatch timeout after ${timeoutMs}ms (tabId=${tabId})`));
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
const response = await Promise.race([
|
|
92
|
+
chrome.tabs.sendMessage(tabId, req),
|
|
93
|
+
timeoutPromise,
|
|
94
|
+
]);
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (timeoutHandle !== undefined)
|
|
99
|
+
clearTimeout(timeoutHandle);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const dispatchToActiveTab = async (req, opts = {}) => {
|
|
103
|
+
const tabs = await chrome.tabs.query({
|
|
104
|
+
active: true,
|
|
105
|
+
lastFocusedWindow: true,
|
|
106
|
+
});
|
|
107
|
+
const tabId = tabs[0]?.id;
|
|
108
|
+
if (tabId === undefined) {
|
|
109
|
+
throw new Error('no active tab');
|
|
110
|
+
}
|
|
111
|
+
return dispatchToTab(tabId, req, opts);
|
|
112
|
+
};
|
|
113
|
+
const getTabUrl = async (tabId) => {
|
|
114
|
+
try {
|
|
115
|
+
const tab = await chrome.tabs.get(tabId);
|
|
116
|
+
return tab?.url;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const dispatchToTabClassified = async (tabId, req, opts = {}) => {
|
|
123
|
+
let response;
|
|
124
|
+
try {
|
|
125
|
+
response = await dispatchToTab(tabId, req, opts);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
const lastErrorMessage = err.message;
|
|
129
|
+
const url = await getTabUrl(tabId);
|
|
130
|
+
const failure = await classifyDispatchFailure({
|
|
131
|
+
tabId,
|
|
132
|
+
url,
|
|
133
|
+
lastErrorMessage,
|
|
134
|
+
});
|
|
135
|
+
if (failure.code !== 'cs_not_attached_refresh_tab') {
|
|
136
|
+
return { ok: false, code: failure.code, message: failure.message };
|
|
137
|
+
}
|
|
138
|
+
const heal = await selfHealCsAttachment(tabId);
|
|
139
|
+
if (!heal.ok) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
code: 'cs_inject_failed',
|
|
143
|
+
message: heal.reason,
|
|
144
|
+
selfHealed: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, SELF_HEAL_SETTLE_MS));
|
|
148
|
+
try {
|
|
149
|
+
const retryResponse = await dispatchToTab(tabId, req, opts);
|
|
150
|
+
if (retryResponse.error) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
code: 'page_world_blocked',
|
|
154
|
+
message: retryResponse.error.message,
|
|
155
|
+
selfHealed: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, response: retryResponse, selfHealed: true };
|
|
159
|
+
}
|
|
160
|
+
catch (retryErr) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
code: 'cs_not_attached_refresh_tab',
|
|
164
|
+
message: retryErr.message,
|
|
165
|
+
selfHealed: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (response.error) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
code: 'page_world_blocked',
|
|
173
|
+
message: response.error.message,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, response };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const compilePatternList = (sources, fieldPathPrefix) => {
|
|
180
|
+
if (sources === undefined || sources.length === 0) {
|
|
181
|
+
return { ok: true, value: [] };
|
|
182
|
+
}
|
|
183
|
+
const out = [];
|
|
184
|
+
let i = 0;
|
|
185
|
+
for (const src of sources) {
|
|
186
|
+
try {
|
|
187
|
+
out.push(new RegExp(src));
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: {
|
|
193
|
+
kind: 'pattern_invalid',
|
|
194
|
+
fieldPath: `${fieldPathPrefix}[${i}]`,
|
|
195
|
+
error: e instanceof Error ? e.message : String(e),
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
i++;
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, value: out };
|
|
202
|
+
};
|
|
203
|
+
const eventTextForPattern = (event) => {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.stringify(event) ?? '';
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return '';
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const compileSourceFilter = (spec) => {
|
|
212
|
+
const includeResult = compilePatternList(spec?.pattern?.include, 'pattern.include');
|
|
213
|
+
if (!includeResult.ok)
|
|
214
|
+
return { ok: false, error: includeResult.error };
|
|
215
|
+
const excludeResult = compilePatternList(spec?.pattern?.exclude, 'pattern.exclude');
|
|
216
|
+
if (!excludeResult.ok)
|
|
217
|
+
return { ok: false, error: excludeResult.error };
|
|
218
|
+
const include = includeResult.value;
|
|
219
|
+
const exclude = excludeResult.value;
|
|
220
|
+
const levelSet = spec?.level !== undefined && spec.level.length > 0
|
|
221
|
+
? new Set(spec.level)
|
|
222
|
+
: null;
|
|
223
|
+
const predicate = (event) => {
|
|
224
|
+
if (levelSet !== null) {
|
|
225
|
+
const lvl = event.level;
|
|
226
|
+
if (lvl === undefined || !levelSet.has(lvl))
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (include.length === 0 && exclude.length === 0)
|
|
230
|
+
return true;
|
|
231
|
+
const text = eventTextForPattern(event);
|
|
232
|
+
for (const re of exclude) {
|
|
233
|
+
if (re.test(text))
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (include.length > 0) {
|
|
237
|
+
let any = false;
|
|
238
|
+
for (const re of include) {
|
|
239
|
+
if (re.test(text)) {
|
|
240
|
+
any = true;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!any)
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
};
|
|
249
|
+
return { ok: true, predicate };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Cross-package settings vocabulary — the single source of truth for every
|
|
254
|
+
* user-tunable setting in pwa-debug.
|
|
255
|
+
*
|
|
256
|
+
* Plug-ability invariant (M7): adding a new setting is exactly ONE line in
|
|
257
|
+
* {@link SettingTypeMap} + ONE entry in {@link SETTINGS_SCHEMA}. The host
|
|
258
|
+
* settings store, the settings.* MCP tools, and the extension settings cache
|
|
259
|
+
* all iterate {@link settingKeys} / {@link getSettingEntry} — no key is ever
|
|
260
|
+
* hardcoded — so a new key needs zero changes to any consumer's shape.
|
|
261
|
+
*
|
|
262
|
+
* Lives in @pwa-debug/shared so the host store and the (T3) extension cache
|
|
263
|
+
* enforce identical key/value shapes at compile time via getSetting<K>.
|
|
264
|
+
*/
|
|
265
|
+
/** Runtime tuple of every {@link CaptureKind}, for validation and introspection. */
|
|
266
|
+
const CAPTURE_KINDS = [
|
|
267
|
+
'console',
|
|
268
|
+
'network',
|
|
269
|
+
'dom_mutations',
|
|
270
|
+
'lifecycle',
|
|
271
|
+
'store_change',
|
|
272
|
+
'replay',
|
|
273
|
+
'library_popup',
|
|
274
|
+
'page_error',
|
|
275
|
+
'sw_state',
|
|
276
|
+
];
|
|
277
|
+
// --- internal primitive guards (not exported; not part of the public surface) ---
|
|
278
|
+
const isNonNegInt = (v) => typeof v === 'number' &&
|
|
279
|
+
Number.isFinite(v) &&
|
|
280
|
+
Number.isInteger(v) &&
|
|
281
|
+
v >= 0;
|
|
282
|
+
const isBoolean = (v) => typeof v === 'boolean';
|
|
283
|
+
/** A valid TCP port for remote debugging: integer in [1, 65535]. */
|
|
284
|
+
const isPort = (v) => typeof v === 'number' &&
|
|
285
|
+
Number.isInteger(v) &&
|
|
286
|
+
v >= 1 &&
|
|
287
|
+
v <= 65535;
|
|
288
|
+
const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
289
|
+
const isCaptureKindSubset = (v) => Array.isArray(v) &&
|
|
290
|
+
new Set(v).size === v.length &&
|
|
291
|
+
v.every((x) => CAPTURE_KINDS.includes(x));
|
|
292
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
293
|
+
const isReadControlValue = (v) => {
|
|
294
|
+
if (!isPlainObject(v))
|
|
295
|
+
return false;
|
|
296
|
+
const allowed = CAPTURE_KINDS;
|
|
297
|
+
for (const [k, flag] of Object.entries(v)) {
|
|
298
|
+
if (!allowed.includes(k))
|
|
299
|
+
return false;
|
|
300
|
+
if (typeof flag !== 'boolean')
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
return true;
|
|
304
|
+
};
|
|
305
|
+
const isReadControlsRecord = (v) => {
|
|
306
|
+
if (!isPlainObject(v))
|
|
307
|
+
return false;
|
|
308
|
+
for (const value of Object.values(v)) {
|
|
309
|
+
if (!isReadControlValue(value))
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
};
|
|
314
|
+
const CONSOLE_LEVELS = [
|
|
315
|
+
'log',
|
|
316
|
+
'info',
|
|
317
|
+
'warn',
|
|
318
|
+
'error',
|
|
319
|
+
'debug',
|
|
320
|
+
'trace',
|
|
321
|
+
];
|
|
322
|
+
const isFilterPattern = (v) => {
|
|
323
|
+
if (!isPlainObject(v))
|
|
324
|
+
return false;
|
|
325
|
+
for (const [k, val] of Object.entries(v)) {
|
|
326
|
+
if (k !== 'include' && k !== 'exclude')
|
|
327
|
+
return false;
|
|
328
|
+
if (val === undefined)
|
|
329
|
+
continue;
|
|
330
|
+
if (!Array.isArray(val))
|
|
331
|
+
return false;
|
|
332
|
+
if (!val.every((x) => typeof x === 'string'))
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
return true;
|
|
336
|
+
};
|
|
337
|
+
const FILTER_SPEC_SOURCE_KEYS = ['level', 'pattern'];
|
|
338
|
+
const isSourceFilterSpec = (v) => {
|
|
339
|
+
if (!isPlainObject(v))
|
|
340
|
+
return false;
|
|
341
|
+
for (const [k, val] of Object.entries(v)) {
|
|
342
|
+
if (!FILTER_SPEC_SOURCE_KEYS.includes(k))
|
|
343
|
+
return false;
|
|
344
|
+
if (val === undefined)
|
|
345
|
+
continue;
|
|
346
|
+
if (k === 'level') {
|
|
347
|
+
if (!Array.isArray(val))
|
|
348
|
+
return false;
|
|
349
|
+
if (!val.every((x) => typeof x === 'string' && CONSOLE_LEVELS.includes(x)))
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
else if (k === 'pattern') {
|
|
353
|
+
if (!isFilterPattern(val))
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return true;
|
|
358
|
+
};
|
|
359
|
+
const isCaptureFiltersRecord = (v) => {
|
|
360
|
+
if (!isPlainObject(v))
|
|
361
|
+
return false;
|
|
362
|
+
const allowed = CAPTURE_KINDS;
|
|
363
|
+
for (const [k, val] of Object.entries(v)) {
|
|
364
|
+
if (!allowed.includes(k))
|
|
365
|
+
return false;
|
|
366
|
+
if (val === undefined)
|
|
367
|
+
continue;
|
|
368
|
+
if (!isSourceFilterSpec(val))
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* The schema as data. Frozen. THIS is the single const instance; every
|
|
375
|
+
* consumer reaches it through {@link settingKeys} / {@link getSettingEntry}.
|
|
376
|
+
*/
|
|
377
|
+
const SETTINGS_SCHEMA = Object.freeze({
|
|
378
|
+
'capture.memoryCutoffPerKind': {
|
|
379
|
+
key: 'capture.memoryCutoffPerKind',
|
|
380
|
+
type: 'number',
|
|
381
|
+
default: 5000,
|
|
382
|
+
scope: 'host',
|
|
383
|
+
description: 'Max events retained in memory per capture kind before eviction (overflow goes to disk when capture.diskSpill.enabled).',
|
|
384
|
+
validate: isNonNegInt,
|
|
385
|
+
},
|
|
386
|
+
'capture.diskSpill.enabled': {
|
|
387
|
+
key: 'capture.diskSpill.enabled',
|
|
388
|
+
type: 'boolean',
|
|
389
|
+
default: false,
|
|
390
|
+
scope: 'host',
|
|
391
|
+
description: 'When true, events evicted from the in-memory ring buffer are written to on-disk jsonl archives instead of dropped.',
|
|
392
|
+
validate: isBoolean,
|
|
393
|
+
},
|
|
394
|
+
'capture.diskSpill.archiveLongevityDays': {
|
|
395
|
+
key: 'capture.diskSpill.archiveLongevityDays',
|
|
396
|
+
type: 'number',
|
|
397
|
+
default: 7,
|
|
398
|
+
scope: 'host',
|
|
399
|
+
description: 'Age in days after which a disk archive file is pruned on the next pruner tick.',
|
|
400
|
+
validate: isNonNegInt,
|
|
401
|
+
},
|
|
402
|
+
'capture.diskSpill.maxBytes': {
|
|
403
|
+
key: 'capture.diskSpill.maxBytes',
|
|
404
|
+
type: 'number',
|
|
405
|
+
default: 100_000_000,
|
|
406
|
+
scope: 'host',
|
|
407
|
+
description: 'Total disk-archive byte cap; oldest archive files are evicted first when exceeded.',
|
|
408
|
+
validate: isNonNegInt,
|
|
409
|
+
},
|
|
410
|
+
'sites.allowlist': {
|
|
411
|
+
key: 'sites.allowlist',
|
|
412
|
+
type: 'string[]',
|
|
413
|
+
default: ['*'],
|
|
414
|
+
scope: 'both',
|
|
415
|
+
description: 'Glob patterns of origins/URLs the capture pipeline is permitted to record. Default ["*"] = all sites.',
|
|
416
|
+
validate: isStringArray,
|
|
417
|
+
},
|
|
418
|
+
'sites.blocklist': {
|
|
419
|
+
key: 'sites.blocklist',
|
|
420
|
+
type: 'string[]',
|
|
421
|
+
default: [],
|
|
422
|
+
scope: 'both',
|
|
423
|
+
description: 'Glob patterns of origins/URLs never captured; takes precedence over sites.allowlist.',
|
|
424
|
+
validate: isStringArray,
|
|
425
|
+
},
|
|
426
|
+
'capture.enabledKinds': {
|
|
427
|
+
key: 'capture.enabledKinds',
|
|
428
|
+
type: 'enum[]',
|
|
429
|
+
default: [
|
|
430
|
+
'console',
|
|
431
|
+
'network',
|
|
432
|
+
'dom_mutations',
|
|
433
|
+
'lifecycle',
|
|
434
|
+
'store_change',
|
|
435
|
+
'replay',
|
|
436
|
+
'library_popup',
|
|
437
|
+
'sw_state',
|
|
438
|
+
],
|
|
439
|
+
scope: 'both',
|
|
440
|
+
description: 'Subset of capture kinds actively recorded. Empty = capture nothing.',
|
|
441
|
+
validate: isCaptureKindSubset,
|
|
442
|
+
enumValues: CAPTURE_KINDS,
|
|
443
|
+
},
|
|
444
|
+
'sites.readControls': {
|
|
445
|
+
key: 'sites.readControls',
|
|
446
|
+
type: 'record',
|
|
447
|
+
default: Object.freeze({}),
|
|
448
|
+
scope: 'both',
|
|
449
|
+
description: 'Per-site, per-kind read-permission overrides. Keys are glob patterns (same matcher as sites.allowlist); values are objects of CaptureKind→boolean flags. Missing flag = allowed; false denies that kind for matching URLs. Most-specific (longest) pattern wins per URL; ties broken lexicographically. Only DENIES events otherwise allowed by sites.allowlist + capture.enabledKinds — cannot re-enable what allowlist already rejected.',
|
|
450
|
+
validate: isReadControlsRecord,
|
|
451
|
+
},
|
|
452
|
+
'capture.filters': {
|
|
453
|
+
key: 'capture.filters',
|
|
454
|
+
type: 'record',
|
|
455
|
+
default: Object.freeze({}),
|
|
456
|
+
scope: 'both',
|
|
457
|
+
description: 'Per-kind source-side capture filters. Keys are CaptureKinds; values are wire FilterSpecs (level + pattern only — cursors and limit are seq-based, meaningful only on the host). When set, the capture chokepoint applies the compiled predicate BEFORE the event reaches the host buffer; rejected events are dropped at the source. Validation tightens the wire shape to source-applicable fields only.',
|
|
458
|
+
validate: isCaptureFiltersRecord,
|
|
459
|
+
},
|
|
460
|
+
'capture.stores.allowDispatch': {
|
|
461
|
+
key: 'capture.stores.allowDispatch',
|
|
462
|
+
type: 'boolean',
|
|
463
|
+
default: false,
|
|
464
|
+
scope: 'both',
|
|
465
|
+
description: 'When true, redux_dispatch (and forthcoming store-system dispatch tools) may write to the page-world store; when false (default), the dispatch tool rejects with an actionable next_steps[] hint. Gates the only write surface in the store-introspection family — reads (redux_get_state, redux_subscribe, redux_tail) are unaffected.',
|
|
466
|
+
validate: isBoolean,
|
|
467
|
+
},
|
|
468
|
+
'capture.sourceMap.enabled': {
|
|
469
|
+
key: 'capture.sourceMap.enabled',
|
|
470
|
+
type: 'boolean',
|
|
471
|
+
default: true,
|
|
472
|
+
scope: 'both',
|
|
473
|
+
description: 'When true (default), source_map_resolve fetches and resolves source maps to translate generated stack frames into original-source coordinates. When false, the tool returns errorResponse with a hint. M13 ships query-time resolution only; capture-time auto-annotation is deferred to M13.5.',
|
|
474
|
+
validate: isBoolean,
|
|
475
|
+
},
|
|
476
|
+
'launch.defaultPort': {
|
|
477
|
+
key: 'launch.defaultPort',
|
|
478
|
+
type: 'number',
|
|
479
|
+
default: 9222,
|
|
480
|
+
scope: 'host',
|
|
481
|
+
description: 'Default remote-debugging port used by pdl_launch_browser when no explicit `port` arg is given. 9222 is the chrome-devtools-mcp convention. Change it if 9222 is already in use on your machine.',
|
|
482
|
+
validate: isPort,
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
/**
|
|
486
|
+
* All setting keys in stable schema-declaration order — the canonical
|
|
487
|
+
* iteration order for defaults-merge and settings.list_schema.
|
|
488
|
+
*/
|
|
489
|
+
const settingKeys = () => Object.keys(SETTINGS_SCHEMA);
|
|
490
|
+
/**
|
|
491
|
+
* Typed accessor for a single schema entry — the one DRY lookup point so a
|
|
492
|
+
* future key change is a single schema edit, never a consumer change.
|
|
493
|
+
*/
|
|
494
|
+
const getSettingEntry = (key) => SETTINGS_SCHEMA[key];
|
|
495
|
+
/**
|
|
496
|
+
* Central pure type-guard: validate an unknown value against a key's schema
|
|
497
|
+
* validator. Single validation path shared by the host_settings store and the
|
|
498
|
+
* settings.set MCP tool. Narrows `value` to SettingTypeMap[K] on true.
|
|
499
|
+
*/
|
|
500
|
+
const validateSettingValue = (key, value) => getSettingEntry(key).validate(value);
|
|
501
|
+
/**
|
|
502
|
+
* Factory producing a fresh fully-materialized {@link SettingsRecord} of every
|
|
503
|
+
* key's default. Array and plain-object defaults are cloned so the result
|
|
504
|
+
* never aliases the frozen SETTINGS_SCHEMA. The base the host_settings store
|
|
505
|
+
* merges over.
|
|
506
|
+
*/
|
|
507
|
+
const defaultSettings = () => Object.fromEntries(settingKeys().map((k) => {
|
|
508
|
+
const d = getSettingEntry(k).default;
|
|
509
|
+
if (Array.isArray(d))
|
|
510
|
+
return [k, [...d]];
|
|
511
|
+
if (isPlainObject(d))
|
|
512
|
+
return [k, { ...d }];
|
|
513
|
+
return [k, d];
|
|
514
|
+
}));
|
|
515
|
+
|
|
516
|
+
// Single source of truth for the Path 7 pdl_* interaction action tools.
|
|
517
|
+
//
|
|
518
|
+
// Both the host (builds a ToolDef + Zod schema per entry) and the extension
|
|
519
|
+
// (SW request routing + page-world dispatch) import this table, so tool names,
|
|
520
|
+
// their dom_actions action kind, and their parameters stay in sync across
|
|
521
|
+
// packages. Adding a tool = one entry here + (for new param shapes) the typed
|
|
522
|
+
// param model below; the three generic layers need no per-tool code.
|
|
523
|
+
const S = (key, required = false, description) => ({
|
|
524
|
+
key,
|
|
525
|
+
type: 'string',
|
|
526
|
+
...(required ? { required } : {}),
|
|
527
|
+
...(description !== undefined ? { description } : {}),
|
|
528
|
+
});
|
|
529
|
+
const N = (key, required = false, description) => ({
|
|
530
|
+
key,
|
|
531
|
+
type: 'number',
|
|
532
|
+
...(required ? { required } : {}),
|
|
533
|
+
...(description !== undefined ? { description } : {}),
|
|
534
|
+
});
|
|
535
|
+
const B = (key, description) => ({
|
|
536
|
+
key,
|
|
537
|
+
type: 'boolean',
|
|
538
|
+
...(description !== undefined ? { description } : {}),
|
|
539
|
+
});
|
|
540
|
+
const ACTION_TOOL_SPECS = Object.freeze([
|
|
541
|
+
// --- discrete (pointer/keyboard) ---
|
|
542
|
+
{ tool: 'pdl_click', action: 'click', params: [], summary: 'Click an element (full pointer/mouse event chain so React/Vue delegated onClick fires).' },
|
|
543
|
+
{ tool: 'pdl_dblclick', action: 'dblclick', params: [], summary: 'Double-click an element.' },
|
|
544
|
+
{ tool: 'pdl_fill', action: 'fill', params: [S('value', true, 'text to set')], summary: "Set an input/textarea/select value via the native setter + input/change (works with React controlled inputs)." },
|
|
545
|
+
{ tool: 'pdl_submit', action: 'submit', params: [], summary: 'Submit the form owning the located element (requestSubmit).' },
|
|
546
|
+
{ tool: 'pdl_hover', action: 'hover', params: [], summary: 'Hover an element (pointer/mouse over/enter/move).' },
|
|
547
|
+
{ tool: 'pdl_focus', action: 'focus', params: [], summary: 'Focus an element.' },
|
|
548
|
+
{ tool: 'pdl_blur', action: 'blur', params: [], summary: 'Blur an element.' },
|
|
549
|
+
{ tool: 'pdl_check', action: 'check', params: [], summary: 'Check a checkbox/radio (idempotent; native click path so onChange fires).' },
|
|
550
|
+
{ tool: 'pdl_uncheck', action: 'uncheck', params: [], summary: 'Uncheck a checkbox (idempotent).' },
|
|
551
|
+
{ tool: 'pdl_select_option', action: 'selectOption', params: [S('value', false, 'option value'), S('label', false, 'visible option label')], summary: 'Select a <select> option by value or visible label (one required).' },
|
|
552
|
+
{ tool: 'pdl_key_press', action: 'keyPress', params: [S('key', true, "a character or named key e.g. 'Enter','Tab','ArrowDown'")], summary: 'Press a single key on an element.' },
|
|
553
|
+
{ tool: 'pdl_type_sequence', action: 'typeSequence', params: [S('value', true, 'string to type char-by-char')], summary: 'Type a string into an editable element char-by-char.' },
|
|
554
|
+
// --- gestures (pointer/touch) ---
|
|
555
|
+
{ tool: 'pdl_drag', action: 'drag', params: [N('toX', false, 'destination viewport X'), N('toY', false, 'destination viewport Y'), S('targetSelector', false, 'CSS selector for the drop target (alternative to toX/toY)'), N('steps', false, 'pointermove steps (default 10)'), B('html5', 'also fire the native HTML5 drag/drop sequence with a DataTransfer')], summary: 'Drag the located element to a point (toX/toY) or onto targetSelector; pointer drag + optional HTML5 DnD.' },
|
|
556
|
+
{ tool: 'pdl_scroll', action: 'scroll', params: [N('deltaX', false, 'horizontal scroll delta'), N('deltaY', false, 'vertical scroll delta'), B('intoView', 'scrollIntoView (centered) instead of by-delta')], summary: 'Scroll the located element by delta (dispatches wheel + scrollBy) or scrollIntoView.' },
|
|
557
|
+
{ tool: 'pdl_swipe', action: 'swipe', params: [{ key: 'direction', type: 'enum', required: true, enum: ['up', 'down', 'left', 'right'], description: 'swipe direction' }, N('distance', false, 'px distance (default 100)'), N('steps', false, 'touchmove steps (default 10)')], summary: 'Swipe a touch across the located element in a direction.' },
|
|
558
|
+
{ tool: 'pdl_tap', action: 'tap', params: [], summary: 'Tap (touchstart/touchend) the located element.' },
|
|
559
|
+
{ tool: 'pdl_double_tap', action: 'doubleTap', params: [], summary: 'Double-tap the located element.' },
|
|
560
|
+
{ tool: 'pdl_long_press', action: 'longPress', params: [N('duration', false, 'hold ms before release (default 500)')], summary: 'Long-press the located element (holds, then releases + contextmenu).' },
|
|
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.' },
|
|
562
|
+
]);
|
|
563
|
+
|
|
564
|
+
const isSwRequestEnvelope = (m) => {
|
|
565
|
+
if (m === null || typeof m !== 'object')
|
|
566
|
+
return false;
|
|
567
|
+
const r = m;
|
|
568
|
+
return (r['type'] === 'request' &&
|
|
569
|
+
typeof r['requestId'] === 'string' &&
|
|
570
|
+
typeof r['tool'] === 'string');
|
|
571
|
+
};
|
|
572
|
+
const fetchPageWorld = async (tabId) => {
|
|
573
|
+
const result = await dispatchToTabClassified(tabId, { tool: 'session_ping' });
|
|
574
|
+
if (result.ok) {
|
|
575
|
+
const payload = result.response.payload;
|
|
576
|
+
return {
|
|
577
|
+
pageWorld: payload ?? null,
|
|
578
|
+
...(result.selfHealed ? { pageWorldSelfHealed: true } : {}),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
pageWorld: null,
|
|
583
|
+
pageWorldError: result.code,
|
|
584
|
+
pageWorldErrorMessage: result.message,
|
|
585
|
+
...(result.selfHealed ? { pageWorldSelfHealed: true } : {}),
|
|
586
|
+
};
|
|
587
|
+
};
|
|
588
|
+
const handleSessionPing = async () => {
|
|
589
|
+
const tabs = await chrome.tabs.query({
|
|
590
|
+
active: true,
|
|
591
|
+
lastFocusedWindow: true,
|
|
592
|
+
});
|
|
593
|
+
const attachedTabId = tabs[0]?.id ?? null;
|
|
594
|
+
const extensionVersion = chrome.runtime.getManifest().version;
|
|
595
|
+
const pageWorldResult = attachedTabId !== null
|
|
596
|
+
? await fetchPageWorld(attachedTabId)
|
|
597
|
+
: {
|
|
598
|
+
pageWorld: null,
|
|
599
|
+
pageWorldError: 'no_active_tab',
|
|
600
|
+
pageWorldErrorMessage: 'no active tab',
|
|
601
|
+
};
|
|
602
|
+
const result = {
|
|
603
|
+
extensionVersion,
|
|
604
|
+
attachedTabId,
|
|
605
|
+
pageWorld: pageWorldResult.pageWorld,
|
|
606
|
+
...(pageWorldResult.pageWorldError !== undefined
|
|
607
|
+
? { pageWorldError: pageWorldResult.pageWorldError }
|
|
608
|
+
: {}),
|
|
609
|
+
...(pageWorldResult.pageWorldErrorMessage !== undefined
|
|
610
|
+
? { pageWorldErrorMessage: pageWorldResult.pageWorldErrorMessage }
|
|
611
|
+
: {}),
|
|
612
|
+
...(pageWorldResult.pageWorldSelfHealed
|
|
613
|
+
? { pageWorldSelfHealed: true }
|
|
614
|
+
: {}),
|
|
615
|
+
};
|
|
616
|
+
return result;
|
|
617
|
+
};
|
|
618
|
+
const sanitizeRecentFilter = (raw) => {
|
|
619
|
+
if (raw === null || typeof raw !== 'object')
|
|
620
|
+
return {};
|
|
621
|
+
const r = raw;
|
|
622
|
+
const kinds = Array.isArray(r['kinds'])
|
|
623
|
+
? r['kinds'].filter((k) => typeof k === 'string')
|
|
624
|
+
: undefined;
|
|
625
|
+
const sinceMs = typeof r['sinceMs'] === 'number' ? r['sinceMs'] : undefined;
|
|
626
|
+
const limit = typeof r['limit'] === 'number' ? r['limit'] : undefined;
|
|
627
|
+
return {
|
|
628
|
+
...(kinds !== undefined ? { kinds } : {}),
|
|
629
|
+
...(sinceMs !== undefined ? { sinceMs } : {}),
|
|
630
|
+
...(limit !== undefined ? { limit } : {}),
|
|
631
|
+
};
|
|
632
|
+
};
|
|
633
|
+
const handleRecentEvents = async (env, ctx) => {
|
|
634
|
+
const filter = sanitizeRecentFilter(env.payload);
|
|
635
|
+
const result = ctx.sink.getRecent(filter);
|
|
636
|
+
return result;
|
|
637
|
+
};
|
|
638
|
+
const sanitizeEvaluateInput = (raw) => {
|
|
639
|
+
if (raw === null || typeof raw !== 'object')
|
|
640
|
+
return null;
|
|
641
|
+
const r = raw;
|
|
642
|
+
const expression = r['expression'];
|
|
643
|
+
if (typeof expression !== 'string' || expression.length === 0)
|
|
644
|
+
return null;
|
|
645
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
646
|
+
? r['tab_id']
|
|
647
|
+
: undefined;
|
|
648
|
+
return {
|
|
649
|
+
tabId,
|
|
650
|
+
payload: {
|
|
651
|
+
expression,
|
|
652
|
+
...(typeof r['timeout_ms'] === 'number' && r['timeout_ms'] > 0
|
|
653
|
+
? { timeout_ms: r['timeout_ms'] }
|
|
654
|
+
: {}),
|
|
655
|
+
...(typeof r['await_promise'] === 'boolean'
|
|
656
|
+
? { await_promise: r['await_promise'] }
|
|
657
|
+
: {}),
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
};
|
|
661
|
+
const handleEvaluate = async (env) => {
|
|
662
|
+
const sanitized = sanitizeEvaluateInput(env.payload);
|
|
663
|
+
if (sanitized === null) {
|
|
664
|
+
throw new Error('evaluate: payload must be { expression: non-empty string, tab_id?, timeout_ms?, await_promise? }');
|
|
665
|
+
}
|
|
666
|
+
const csReq = { tool: 'evaluate', payload: sanitized.payload };
|
|
667
|
+
const response = sanitized.tabId !== undefined
|
|
668
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
669
|
+
: await dispatchToActiveTab(csReq);
|
|
670
|
+
if (response.error) {
|
|
671
|
+
throw new Error(response.error.message);
|
|
672
|
+
}
|
|
673
|
+
return response.payload;
|
|
674
|
+
};
|
|
675
|
+
const sanitizeReactTreeInput = (raw) => {
|
|
676
|
+
if (raw === undefined || raw === null) {
|
|
677
|
+
return { tabId: undefined, payload: {} };
|
|
678
|
+
}
|
|
679
|
+
if (typeof raw !== 'object')
|
|
680
|
+
return null;
|
|
681
|
+
const r = raw;
|
|
682
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
683
|
+
? r['tab_id']
|
|
684
|
+
: undefined;
|
|
685
|
+
const payload = {};
|
|
686
|
+
if (typeof r['root_index'] === 'number' &&
|
|
687
|
+
Number.isInteger(r['root_index']) &&
|
|
688
|
+
r['root_index'] >= 0) {
|
|
689
|
+
payload['root_index'] = r['root_index'];
|
|
690
|
+
}
|
|
691
|
+
if (typeof r['depth_limit'] === 'number' &&
|
|
692
|
+
Number.isInteger(r['depth_limit']) &&
|
|
693
|
+
r['depth_limit'] > 0) {
|
|
694
|
+
payload['depth_limit'] = r['depth_limit'];
|
|
695
|
+
}
|
|
696
|
+
if (typeof r['max_nodes'] === 'number' &&
|
|
697
|
+
Number.isInteger(r['max_nodes']) &&
|
|
698
|
+
r['max_nodes'] > 0) {
|
|
699
|
+
payload['max_nodes'] = r['max_nodes'];
|
|
700
|
+
}
|
|
701
|
+
return { tabId, payload };
|
|
702
|
+
};
|
|
703
|
+
const handleReactTree = async (env) => {
|
|
704
|
+
const sanitized = sanitizeReactTreeInput(env.payload);
|
|
705
|
+
if (sanitized === null) {
|
|
706
|
+
throw new Error('react_tree: payload must be an object with optional { tab_id?, root_index?, depth_limit?, max_nodes? }');
|
|
707
|
+
}
|
|
708
|
+
const csReq = { tool: 'react_tree', payload: sanitized.payload };
|
|
709
|
+
const response = sanitized.tabId !== undefined
|
|
710
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
711
|
+
: await dispatchToActiveTab(csReq);
|
|
712
|
+
if (response.error) {
|
|
713
|
+
throw new Error(response.error.message);
|
|
714
|
+
}
|
|
715
|
+
return response.payload;
|
|
716
|
+
};
|
|
717
|
+
const sanitizeReactGetStateInput = (raw) => {
|
|
718
|
+
if (raw === null || typeof raw !== 'object')
|
|
719
|
+
return null;
|
|
720
|
+
const r = raw;
|
|
721
|
+
const stableId = r['stable_id'];
|
|
722
|
+
if (typeof stableId !== 'string' || stableId.length === 0)
|
|
723
|
+
return null;
|
|
724
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
725
|
+
? r['tab_id']
|
|
726
|
+
: undefined;
|
|
727
|
+
const payload = { stable_id: stableId };
|
|
728
|
+
if (typeof r['root_index'] === 'number' &&
|
|
729
|
+
Number.isInteger(r['root_index']) &&
|
|
730
|
+
r['root_index'] >= 0) {
|
|
731
|
+
payload['root_index'] = r['root_index'];
|
|
732
|
+
}
|
|
733
|
+
if (typeof r['include_props'] === 'boolean')
|
|
734
|
+
payload['include_props'] = r['include_props'];
|
|
735
|
+
if (typeof r['include_hooks'] === 'boolean')
|
|
736
|
+
payload['include_hooks'] = r['include_hooks'];
|
|
737
|
+
return { tabId, payload };
|
|
738
|
+
};
|
|
739
|
+
const handleReactGetState = async (env) => {
|
|
740
|
+
const sanitized = sanitizeReactGetStateInput(env.payload);
|
|
741
|
+
if (sanitized === null) {
|
|
742
|
+
throw new Error('react_get_state: payload must be { stable_id: non-empty string, tab_id?, root_index?, include_props?, include_hooks? }');
|
|
743
|
+
}
|
|
744
|
+
const csReq = { tool: 'react_get_state', payload: sanitized.payload };
|
|
745
|
+
const response = sanitized.tabId !== undefined
|
|
746
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
747
|
+
: await dispatchToActiveTab(csReq);
|
|
748
|
+
if (response.error) {
|
|
749
|
+
throw new Error(response.error.message);
|
|
750
|
+
}
|
|
751
|
+
return response.payload;
|
|
752
|
+
};
|
|
753
|
+
const sanitizeReactFindByTextInput = (raw) => {
|
|
754
|
+
if (raw === null || typeof raw !== 'object')
|
|
755
|
+
return null;
|
|
756
|
+
const r = raw;
|
|
757
|
+
const pattern = r['pattern'];
|
|
758
|
+
if (typeof pattern !== 'string' || pattern.length === 0)
|
|
759
|
+
return null;
|
|
760
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
761
|
+
? r['tab_id']
|
|
762
|
+
: undefined;
|
|
763
|
+
const payload = { pattern };
|
|
764
|
+
if (typeof r['exact'] === 'boolean')
|
|
765
|
+
payload['exact'] = r['exact'];
|
|
766
|
+
if (typeof r['root_index'] === 'number' &&
|
|
767
|
+
Number.isInteger(r['root_index']) &&
|
|
768
|
+
r['root_index'] >= 0) {
|
|
769
|
+
payload['root_index'] = r['root_index'];
|
|
770
|
+
}
|
|
771
|
+
if (typeof r['max_matches'] === 'number' &&
|
|
772
|
+
Number.isInteger(r['max_matches']) &&
|
|
773
|
+
r['max_matches'] > 0) {
|
|
774
|
+
payload['max_matches'] = r['max_matches'];
|
|
775
|
+
}
|
|
776
|
+
return { tabId, payload };
|
|
777
|
+
};
|
|
778
|
+
const handleReactFindByText = async (env) => {
|
|
779
|
+
const sanitized = sanitizeReactFindByTextInput(env.payload);
|
|
780
|
+
if (sanitized === null) {
|
|
781
|
+
throw new Error('react_find_by_text: payload must be { pattern: non-empty string, tab_id?, exact?, root_index?, max_matches? }');
|
|
782
|
+
}
|
|
783
|
+
const csReq = { tool: 'react_find_by_text', payload: sanitized.payload };
|
|
784
|
+
const response = sanitized.tabId !== undefined
|
|
785
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
786
|
+
: await dispatchToActiveTab(csReq);
|
|
787
|
+
if (response.error) {
|
|
788
|
+
throw new Error(response.error.message);
|
|
789
|
+
}
|
|
790
|
+
return response.payload;
|
|
791
|
+
};
|
|
792
|
+
const sanitizeReactFindByRoleInput = (raw) => {
|
|
793
|
+
if (raw === null || typeof raw !== 'object')
|
|
794
|
+
return null;
|
|
795
|
+
const r = raw;
|
|
796
|
+
const role = r['role'];
|
|
797
|
+
if (typeof role !== 'string' || role.length === 0)
|
|
798
|
+
return null;
|
|
799
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
800
|
+
? r['tab_id']
|
|
801
|
+
: undefined;
|
|
802
|
+
const payload = { role };
|
|
803
|
+
if (typeof r['name'] === 'string' && r['name'].length > 0) {
|
|
804
|
+
payload['name'] = r['name'];
|
|
805
|
+
}
|
|
806
|
+
if (typeof r['root_index'] === 'number' &&
|
|
807
|
+
Number.isInteger(r['root_index']) &&
|
|
808
|
+
r['root_index'] >= 0) {
|
|
809
|
+
payload['root_index'] = r['root_index'];
|
|
810
|
+
}
|
|
811
|
+
if (typeof r['max_matches'] === 'number' &&
|
|
812
|
+
Number.isInteger(r['max_matches']) &&
|
|
813
|
+
r['max_matches'] > 0) {
|
|
814
|
+
payload['max_matches'] = r['max_matches'];
|
|
815
|
+
}
|
|
816
|
+
return { tabId, payload };
|
|
817
|
+
};
|
|
818
|
+
const handleReactFindByRole = async (env) => {
|
|
819
|
+
const sanitized = sanitizeReactFindByRoleInput(env.payload);
|
|
820
|
+
if (sanitized === null) {
|
|
821
|
+
throw new Error('react_find_by_role: payload must be { role: non-empty string, tab_id?, name?, root_index?, max_matches? }');
|
|
822
|
+
}
|
|
823
|
+
const csReq = { tool: 'react_find_by_role', payload: sanitized.payload };
|
|
824
|
+
const response = sanitized.tabId !== undefined
|
|
825
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
826
|
+
: await dispatchToActiveTab(csReq);
|
|
827
|
+
if (response.error) {
|
|
828
|
+
throw new Error(response.error.message);
|
|
829
|
+
}
|
|
830
|
+
return response.payload;
|
|
831
|
+
};
|
|
832
|
+
const sanitizeVueTreeInput = (raw) => {
|
|
833
|
+
if (raw === undefined || raw === null) {
|
|
834
|
+
return { tabId: undefined, payload: {} };
|
|
835
|
+
}
|
|
836
|
+
if (typeof raw !== 'object')
|
|
837
|
+
return null;
|
|
838
|
+
const r = raw;
|
|
839
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
840
|
+
? r['tab_id']
|
|
841
|
+
: undefined;
|
|
842
|
+
const payload = {};
|
|
843
|
+
if (typeof r['root_index'] === 'number' &&
|
|
844
|
+
Number.isInteger(r['root_index']) &&
|
|
845
|
+
r['root_index'] >= 0) {
|
|
846
|
+
payload['root_index'] = r['root_index'];
|
|
847
|
+
}
|
|
848
|
+
if (typeof r['depth_limit'] === 'number' &&
|
|
849
|
+
Number.isInteger(r['depth_limit']) &&
|
|
850
|
+
r['depth_limit'] > 0) {
|
|
851
|
+
payload['depth_limit'] = r['depth_limit'];
|
|
852
|
+
}
|
|
853
|
+
if (typeof r['max_nodes'] === 'number' &&
|
|
854
|
+
Number.isInteger(r['max_nodes']) &&
|
|
855
|
+
r['max_nodes'] > 0) {
|
|
856
|
+
payload['max_nodes'] = r['max_nodes'];
|
|
857
|
+
}
|
|
858
|
+
return { tabId, payload };
|
|
859
|
+
};
|
|
860
|
+
const handleVueTree = async (env) => {
|
|
861
|
+
const sanitized = sanitizeVueTreeInput(env.payload);
|
|
862
|
+
if (sanitized === null) {
|
|
863
|
+
throw new Error('vue_tree: payload must be an object with optional { tab_id?, root_index?, depth_limit?, max_nodes? }');
|
|
864
|
+
}
|
|
865
|
+
const csReq = { tool: 'vue_tree', payload: sanitized.payload };
|
|
866
|
+
const response = sanitized.tabId !== undefined
|
|
867
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
868
|
+
: await dispatchToActiveTab(csReq);
|
|
869
|
+
if (response.error) {
|
|
870
|
+
throw new Error(response.error.message);
|
|
871
|
+
}
|
|
872
|
+
return response.payload;
|
|
873
|
+
};
|
|
874
|
+
const sanitizeVueGetStateInput = (raw) => {
|
|
875
|
+
if (raw === null || typeof raw !== 'object')
|
|
876
|
+
return null;
|
|
877
|
+
const r = raw;
|
|
878
|
+
const stableId = r['stable_id'];
|
|
879
|
+
if (typeof stableId !== 'string' || stableId.length === 0)
|
|
880
|
+
return null;
|
|
881
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
882
|
+
? r['tab_id']
|
|
883
|
+
: undefined;
|
|
884
|
+
const payload = { stable_id: stableId };
|
|
885
|
+
if (typeof r['include_props'] === 'boolean')
|
|
886
|
+
payload['include_props'] = r['include_props'];
|
|
887
|
+
if (typeof r['include_state'] === 'boolean')
|
|
888
|
+
payload['include_state'] = r['include_state'];
|
|
889
|
+
return { tabId, payload };
|
|
890
|
+
};
|
|
891
|
+
const handleVueGetState = async (env) => {
|
|
892
|
+
const sanitized = sanitizeVueGetStateInput(env.payload);
|
|
893
|
+
if (sanitized === null) {
|
|
894
|
+
throw new Error('vue_get_state: payload must be { stable_id: non-empty string, tab_id?, include_props?, include_state? }');
|
|
895
|
+
}
|
|
896
|
+
const csReq = { tool: 'vue_get_state', payload: sanitized.payload };
|
|
897
|
+
const response = sanitized.tabId !== undefined
|
|
898
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
899
|
+
: await dispatchToActiveTab(csReq);
|
|
900
|
+
if (response.error) {
|
|
901
|
+
throw new Error(response.error.message);
|
|
902
|
+
}
|
|
903
|
+
return response.payload;
|
|
904
|
+
};
|
|
905
|
+
const sanitizeVueFindByTextInput = (raw) => {
|
|
906
|
+
if (raw === null || typeof raw !== 'object')
|
|
907
|
+
return null;
|
|
908
|
+
const r = raw;
|
|
909
|
+
const pattern = r['pattern'];
|
|
910
|
+
if (typeof pattern !== 'string' || pattern.length === 0)
|
|
911
|
+
return null;
|
|
912
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
913
|
+
? r['tab_id']
|
|
914
|
+
: undefined;
|
|
915
|
+
const payload = { pattern };
|
|
916
|
+
if (typeof r['exact'] === 'boolean')
|
|
917
|
+
payload['exact'] = r['exact'];
|
|
918
|
+
if (typeof r['root_index'] === 'number' &&
|
|
919
|
+
Number.isInteger(r['root_index']) &&
|
|
920
|
+
r['root_index'] >= 0) {
|
|
921
|
+
payload['root_index'] = r['root_index'];
|
|
922
|
+
}
|
|
923
|
+
if (typeof r['max_matches'] === 'number' &&
|
|
924
|
+
Number.isInteger(r['max_matches']) &&
|
|
925
|
+
r['max_matches'] > 0) {
|
|
926
|
+
payload['max_matches'] = r['max_matches'];
|
|
927
|
+
}
|
|
928
|
+
return { tabId, payload };
|
|
929
|
+
};
|
|
930
|
+
const handleVueFindByText = async (env) => {
|
|
931
|
+
const sanitized = sanitizeVueFindByTextInput(env.payload);
|
|
932
|
+
if (sanitized === null) {
|
|
933
|
+
throw new Error('vue_find_by_text: payload must be { pattern: non-empty string, tab_id?, exact?, root_index?, max_matches? }');
|
|
934
|
+
}
|
|
935
|
+
const csReq = { tool: 'vue_find_by_text', payload: sanitized.payload };
|
|
936
|
+
const response = sanitized.tabId !== undefined
|
|
937
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
938
|
+
: await dispatchToActiveTab(csReq);
|
|
939
|
+
if (response.error) {
|
|
940
|
+
throw new Error(response.error.message);
|
|
941
|
+
}
|
|
942
|
+
return response.payload;
|
|
943
|
+
};
|
|
944
|
+
const sanitizeVueFindByRoleInput = (raw) => {
|
|
945
|
+
if (raw === null || typeof raw !== 'object')
|
|
946
|
+
return null;
|
|
947
|
+
const r = raw;
|
|
948
|
+
const role = r['role'];
|
|
949
|
+
if (typeof role !== 'string' || role.length === 0)
|
|
950
|
+
return null;
|
|
951
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
952
|
+
? r['tab_id']
|
|
953
|
+
: undefined;
|
|
954
|
+
const payload = { role };
|
|
955
|
+
if (typeof r['name'] === 'string' && r['name'].length > 0) {
|
|
956
|
+
payload['name'] = r['name'];
|
|
957
|
+
}
|
|
958
|
+
if (typeof r['root_index'] === 'number' &&
|
|
959
|
+
Number.isInteger(r['root_index']) &&
|
|
960
|
+
r['root_index'] >= 0) {
|
|
961
|
+
payload['root_index'] = r['root_index'];
|
|
962
|
+
}
|
|
963
|
+
if (typeof r['max_matches'] === 'number' &&
|
|
964
|
+
Number.isInteger(r['max_matches']) &&
|
|
965
|
+
r['max_matches'] > 0) {
|
|
966
|
+
payload['max_matches'] = r['max_matches'];
|
|
967
|
+
}
|
|
968
|
+
return { tabId, payload };
|
|
969
|
+
};
|
|
970
|
+
const handleVueFindByRole = async (env) => {
|
|
971
|
+
const sanitized = sanitizeVueFindByRoleInput(env.payload);
|
|
972
|
+
if (sanitized === null) {
|
|
973
|
+
throw new Error('vue_find_by_role: payload must be { role: non-empty string, tab_id?, name?, root_index?, max_matches? }');
|
|
974
|
+
}
|
|
975
|
+
const csReq = { tool: 'vue_find_by_role', payload: sanitized.payload };
|
|
976
|
+
const response = sanitized.tabId !== undefined
|
|
977
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
978
|
+
: await dispatchToActiveTab(csReq);
|
|
979
|
+
if (response.error) {
|
|
980
|
+
throw new Error(response.error.message);
|
|
981
|
+
}
|
|
982
|
+
return response.payload;
|
|
983
|
+
};
|
|
984
|
+
// ── Svelte introspection (Path 5 M42) ───────────────────────────────────────
|
|
985
|
+
const handleSvelteComponents = async (env) => {
|
|
986
|
+
const raw = env.payload;
|
|
987
|
+
const tabId = raw !== null &&
|
|
988
|
+
typeof raw === 'object' &&
|
|
989
|
+
typeof raw['tab_id'] === 'number' &&
|
|
990
|
+
Number.isFinite(raw['tab_id'])
|
|
991
|
+
? raw['tab_id']
|
|
992
|
+
: undefined;
|
|
993
|
+
const csReq = { tool: 'svelte_components', payload: {} };
|
|
994
|
+
const response = tabId !== undefined
|
|
995
|
+
? await dispatchToTab(tabId, csReq)
|
|
996
|
+
: await dispatchToActiveTab(csReq);
|
|
997
|
+
if (response.error) {
|
|
998
|
+
throw new Error(response.error.message);
|
|
999
|
+
}
|
|
1000
|
+
return response.payload;
|
|
1001
|
+
};
|
|
1002
|
+
// sw_status (PWA Runtime Diagnostics): forward to the active (or given) tab's
|
|
1003
|
+
// page-world, which reads navigator.serviceWorker. No params beyond tab_id —
|
|
1004
|
+
// same shape as svelte_components.
|
|
1005
|
+
const handleSwStatus = async (env) => {
|
|
1006
|
+
const raw = env.payload;
|
|
1007
|
+
const tabId = raw !== null &&
|
|
1008
|
+
typeof raw === 'object' &&
|
|
1009
|
+
typeof raw['tab_id'] === 'number' &&
|
|
1010
|
+
Number.isFinite(raw['tab_id'])
|
|
1011
|
+
? raw['tab_id']
|
|
1012
|
+
: undefined;
|
|
1013
|
+
const csReq = { tool: 'sw_status', payload: {} };
|
|
1014
|
+
const response = tabId !== undefined
|
|
1015
|
+
? await dispatchToTab(tabId, csReq)
|
|
1016
|
+
: await dispatchToActiveTab(csReq);
|
|
1017
|
+
if (response.error) {
|
|
1018
|
+
throw new Error(response.error.message);
|
|
1019
|
+
}
|
|
1020
|
+
return response.payload;
|
|
1021
|
+
};
|
|
1022
|
+
// cache_* (PWA Runtime Diagnostics): forward to the active/given tab's page-world.
|
|
1023
|
+
const readTabId = (raw) => raw !== null &&
|
|
1024
|
+
typeof raw === 'object' &&
|
|
1025
|
+
typeof raw['tab_id'] === 'number' &&
|
|
1026
|
+
Number.isFinite(raw['tab_id'])
|
|
1027
|
+
? raw['tab_id']
|
|
1028
|
+
: undefined;
|
|
1029
|
+
const handleCacheList = async (env) => {
|
|
1030
|
+
const tabId = readTabId(env.payload);
|
|
1031
|
+
const csReq = { tool: 'cache_list', payload: {} };
|
|
1032
|
+
const response = tabId !== undefined
|
|
1033
|
+
? await dispatchToTab(tabId, csReq)
|
|
1034
|
+
: await dispatchToActiveTab(csReq);
|
|
1035
|
+
if (response.error)
|
|
1036
|
+
throw new Error(response.error.message);
|
|
1037
|
+
return response.payload;
|
|
1038
|
+
};
|
|
1039
|
+
const handlePwaStatus = async (env) => {
|
|
1040
|
+
const tabId = readTabId(env.payload);
|
|
1041
|
+
const csReq = { tool: 'pwa_status', payload: {} };
|
|
1042
|
+
const response = tabId !== undefined
|
|
1043
|
+
? await dispatchToTab(tabId, csReq)
|
|
1044
|
+
: await dispatchToActiveTab(csReq);
|
|
1045
|
+
if (response.error)
|
|
1046
|
+
throw new Error(response.error.message);
|
|
1047
|
+
return response.payload;
|
|
1048
|
+
};
|
|
1049
|
+
const handlePwaInstallability = async (env) => {
|
|
1050
|
+
const tabId = readTabId(env.payload);
|
|
1051
|
+
const csReq = { tool: 'pwa_installability', payload: {} };
|
|
1052
|
+
const response = tabId !== undefined
|
|
1053
|
+
? await dispatchToTab(tabId, csReq)
|
|
1054
|
+
: await dispatchToActiveTab(csReq);
|
|
1055
|
+
if (response.error)
|
|
1056
|
+
throw new Error(response.error.message);
|
|
1057
|
+
return response.payload;
|
|
1058
|
+
};
|
|
1059
|
+
// storage_get (PWA Runtime Diagnostics T2): forward area + limit to the page-world.
|
|
1060
|
+
const handleStorageGet = async (env) => {
|
|
1061
|
+
const r = env.payload !== null && typeof env.payload === 'object'
|
|
1062
|
+
? env.payload
|
|
1063
|
+
: {};
|
|
1064
|
+
const payload = {};
|
|
1065
|
+
if (r['area'] === 'session' || r['area'] === 'local')
|
|
1066
|
+
payload['area'] = r['area'];
|
|
1067
|
+
if (typeof r['limit'] === 'number' &&
|
|
1068
|
+
Number.isInteger(r['limit']) &&
|
|
1069
|
+
r['limit'] > 0) {
|
|
1070
|
+
payload['limit'] = r['limit'];
|
|
1071
|
+
}
|
|
1072
|
+
const tabId = readTabId(env.payload);
|
|
1073
|
+
const csReq = { tool: 'storage_get', payload };
|
|
1074
|
+
const response = tabId !== undefined
|
|
1075
|
+
? await dispatchToTab(tabId, csReq)
|
|
1076
|
+
: await dispatchToActiveTab(csReq);
|
|
1077
|
+
if (response.error)
|
|
1078
|
+
throw new Error(response.error.message);
|
|
1079
|
+
return response.payload;
|
|
1080
|
+
};
|
|
1081
|
+
// idb_list (PWA Runtime Diagnostics T2): forward to the active/given tab's
|
|
1082
|
+
// page-world, which reads indexedDB. No params beyond tab_id.
|
|
1083
|
+
const handleIdbList = async (env) => {
|
|
1084
|
+
const tabId = readTabId(env.payload);
|
|
1085
|
+
const csReq = { tool: 'idb_list', payload: {} };
|
|
1086
|
+
const response = tabId !== undefined
|
|
1087
|
+
? await dispatchToTab(tabId, csReq)
|
|
1088
|
+
: await dispatchToActiveTab(csReq);
|
|
1089
|
+
if (response.error)
|
|
1090
|
+
throw new Error(response.error.message);
|
|
1091
|
+
return response.payload;
|
|
1092
|
+
};
|
|
1093
|
+
// idb_query (PWA Runtime Diagnostics T2): forward db + store + limit to the
|
|
1094
|
+
// page-world over a read-only IndexedDB transaction.
|
|
1095
|
+
const handleIdbQuery = async (env) => {
|
|
1096
|
+
const r = env.payload !== null && typeof env.payload === 'object'
|
|
1097
|
+
? env.payload
|
|
1098
|
+
: {};
|
|
1099
|
+
const db = r['db'];
|
|
1100
|
+
if (typeof db !== 'string' || db.length === 0) {
|
|
1101
|
+
throw new Error('idb_query: payload must include { db: non-empty string }');
|
|
1102
|
+
}
|
|
1103
|
+
const store = r['store'];
|
|
1104
|
+
if (typeof store !== 'string' || store.length === 0) {
|
|
1105
|
+
throw new Error('idb_query: payload must include { store: non-empty string }');
|
|
1106
|
+
}
|
|
1107
|
+
const payload = { db, store };
|
|
1108
|
+
if (typeof r['limit'] === 'number' &&
|
|
1109
|
+
Number.isInteger(r['limit']) &&
|
|
1110
|
+
r['limit'] > 0) {
|
|
1111
|
+
payload['limit'] = r['limit'];
|
|
1112
|
+
}
|
|
1113
|
+
const tabId = readTabId(env.payload);
|
|
1114
|
+
const csReq = { tool: 'idb_query', payload };
|
|
1115
|
+
const response = tabId !== undefined
|
|
1116
|
+
? await dispatchToTab(tabId, csReq)
|
|
1117
|
+
: await dispatchToActiveTab(csReq);
|
|
1118
|
+
if (response.error)
|
|
1119
|
+
throw new Error(response.error.message);
|
|
1120
|
+
return response.payload;
|
|
1121
|
+
};
|
|
1122
|
+
// pwa_update_gather (PWA Runtime Diagnostics T3): forward to the active/given
|
|
1123
|
+
// tab's page-world, which gathers the SW snapshot + cache entries. Optional
|
|
1124
|
+
// per_cache_limit beyond tab_id.
|
|
1125
|
+
const handlePwaUpdateGather = async (env) => {
|
|
1126
|
+
const r = env.payload !== null && typeof env.payload === 'object'
|
|
1127
|
+
? env.payload
|
|
1128
|
+
: {};
|
|
1129
|
+
const payload = {};
|
|
1130
|
+
if (typeof r['per_cache_limit'] === 'number' &&
|
|
1131
|
+
Number.isInteger(r['per_cache_limit']) &&
|
|
1132
|
+
r['per_cache_limit'] > 0) {
|
|
1133
|
+
payload['per_cache_limit'] = r['per_cache_limit'];
|
|
1134
|
+
}
|
|
1135
|
+
const tabId = readTabId(env.payload);
|
|
1136
|
+
const csReq = { tool: 'pwa_update_gather', payload };
|
|
1137
|
+
const response = tabId !== undefined
|
|
1138
|
+
? await dispatchToTab(tabId, csReq)
|
|
1139
|
+
: await dispatchToActiveTab(csReq);
|
|
1140
|
+
if (response.error)
|
|
1141
|
+
throw new Error(response.error.message);
|
|
1142
|
+
return response.payload;
|
|
1143
|
+
};
|
|
1144
|
+
// pwa_snapshot (PWA Runtime Diagnostics T3): forward to the active/given tab's
|
|
1145
|
+
// page-world, which composes the runtime-state blob. No params beyond tab_id.
|
|
1146
|
+
const handlePwaSnapshotGather = async (env) => {
|
|
1147
|
+
const tabId = readTabId(env.payload);
|
|
1148
|
+
const csReq = { tool: 'pwa_snapshot_gather', payload: {} };
|
|
1149
|
+
const response = tabId !== undefined
|
|
1150
|
+
? await dispatchToTab(tabId, csReq)
|
|
1151
|
+
: await dispatchToActiveTab(csReq);
|
|
1152
|
+
if (response.error)
|
|
1153
|
+
throw new Error(response.error.message);
|
|
1154
|
+
return response.payload;
|
|
1155
|
+
};
|
|
1156
|
+
const handleCacheInspect = async (env) => {
|
|
1157
|
+
const r = env.payload !== null && typeof env.payload === 'object'
|
|
1158
|
+
? env.payload
|
|
1159
|
+
: {};
|
|
1160
|
+
const name = r['cache_name'];
|
|
1161
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
1162
|
+
throw new Error('cache_inspect: payload must include { cache_name: non-empty string }');
|
|
1163
|
+
}
|
|
1164
|
+
const payload = { cache_name: name };
|
|
1165
|
+
if (typeof r['limit'] === 'number' && Number.isInteger(r['limit']) && r['limit'] > 0) {
|
|
1166
|
+
payload['limit'] = r['limit'];
|
|
1167
|
+
}
|
|
1168
|
+
const tabId = readTabId(env.payload);
|
|
1169
|
+
const csReq = { tool: 'cache_inspect', payload };
|
|
1170
|
+
const response = tabId !== undefined
|
|
1171
|
+
? await dispatchToTab(tabId, csReq)
|
|
1172
|
+
: await dispatchToActiveTab(csReq);
|
|
1173
|
+
if (response.error)
|
|
1174
|
+
throw new Error(response.error.message);
|
|
1175
|
+
return response.payload;
|
|
1176
|
+
};
|
|
1177
|
+
const handleCacheMatch = async (env) => {
|
|
1178
|
+
const r = env.payload !== null && typeof env.payload === 'object'
|
|
1179
|
+
? env.payload
|
|
1180
|
+
: {};
|
|
1181
|
+
const url = r['url'];
|
|
1182
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
1183
|
+
throw new Error('cache_match: payload must include { url: non-empty string }');
|
|
1184
|
+
}
|
|
1185
|
+
const tabId = readTabId(env.payload);
|
|
1186
|
+
const csReq = { tool: 'cache_match', payload: { url } };
|
|
1187
|
+
const response = tabId !== undefined
|
|
1188
|
+
? await dispatchToTab(tabId, csReq)
|
|
1189
|
+
: await dispatchToActiveTab(csReq);
|
|
1190
|
+
if (response.error)
|
|
1191
|
+
throw new Error(response.error.message);
|
|
1192
|
+
return response.payload;
|
|
1193
|
+
};
|
|
1194
|
+
const sanitizeSvelteFindByTextInput = (raw) => {
|
|
1195
|
+
if (raw === null || typeof raw !== 'object')
|
|
1196
|
+
return null;
|
|
1197
|
+
const r = raw;
|
|
1198
|
+
const pattern = r['pattern'];
|
|
1199
|
+
if (typeof pattern !== 'string' || pattern.length === 0)
|
|
1200
|
+
return null;
|
|
1201
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1202
|
+
? r['tab_id']
|
|
1203
|
+
: undefined;
|
|
1204
|
+
const payload = { pattern };
|
|
1205
|
+
if (typeof r['exact'] === 'boolean')
|
|
1206
|
+
payload['exact'] = r['exact'];
|
|
1207
|
+
if (typeof r['max_matches'] === 'number' &&
|
|
1208
|
+
Number.isInteger(r['max_matches']) &&
|
|
1209
|
+
r['max_matches'] > 0) {
|
|
1210
|
+
payload['max_matches'] = r['max_matches'];
|
|
1211
|
+
}
|
|
1212
|
+
return { tabId, payload };
|
|
1213
|
+
};
|
|
1214
|
+
const handleSvelteFindByText = async (env) => {
|
|
1215
|
+
const sanitized = sanitizeSvelteFindByTextInput(env.payload);
|
|
1216
|
+
if (sanitized === null) {
|
|
1217
|
+
throw new Error('svelte_find_by_text: payload must be { pattern: non-empty string, tab_id?, exact?, max_matches? }');
|
|
1218
|
+
}
|
|
1219
|
+
const csReq = { tool: 'svelte_find_by_text', payload: sanitized.payload };
|
|
1220
|
+
const response = sanitized.tabId !== undefined
|
|
1221
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1222
|
+
: await dispatchToActiveTab(csReq);
|
|
1223
|
+
if (response.error) {
|
|
1224
|
+
throw new Error(response.error.message);
|
|
1225
|
+
}
|
|
1226
|
+
return response.payload;
|
|
1227
|
+
};
|
|
1228
|
+
const sanitizeSvelteFindByRoleInput = (raw) => {
|
|
1229
|
+
if (raw === null || typeof raw !== 'object')
|
|
1230
|
+
return null;
|
|
1231
|
+
const r = raw;
|
|
1232
|
+
const role = r['role'];
|
|
1233
|
+
if (typeof role !== 'string' || role.length === 0)
|
|
1234
|
+
return null;
|
|
1235
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1236
|
+
? r['tab_id']
|
|
1237
|
+
: undefined;
|
|
1238
|
+
const payload = { role };
|
|
1239
|
+
if (typeof r['name'] === 'string' && r['name'].length > 0) {
|
|
1240
|
+
payload['name'] = r['name'];
|
|
1241
|
+
}
|
|
1242
|
+
if (typeof r['max_matches'] === 'number' &&
|
|
1243
|
+
Number.isInteger(r['max_matches']) &&
|
|
1244
|
+
r['max_matches'] > 0) {
|
|
1245
|
+
payload['max_matches'] = r['max_matches'];
|
|
1246
|
+
}
|
|
1247
|
+
return { tabId, payload };
|
|
1248
|
+
};
|
|
1249
|
+
const handleSvelteFindByRole = async (env) => {
|
|
1250
|
+
const sanitized = sanitizeSvelteFindByRoleInput(env.payload);
|
|
1251
|
+
if (sanitized === null) {
|
|
1252
|
+
throw new Error('svelte_find_by_role: payload must be { role: non-empty string, tab_id?, name?, max_matches? }');
|
|
1253
|
+
}
|
|
1254
|
+
const csReq = { tool: 'svelte_find_by_role', payload: sanitized.payload };
|
|
1255
|
+
const response = sanitized.tabId !== undefined
|
|
1256
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1257
|
+
: await dispatchToActiveTab(csReq);
|
|
1258
|
+
if (response.error) {
|
|
1259
|
+
throw new Error(response.error.message);
|
|
1260
|
+
}
|
|
1261
|
+
return response.payload;
|
|
1262
|
+
};
|
|
1263
|
+
// ── Solid introspection (Path 5 M43) ────────────────────────────────────────
|
|
1264
|
+
const handleSolidDetect = async (env) => {
|
|
1265
|
+
const raw = env.payload;
|
|
1266
|
+
const tabId = raw !== null &&
|
|
1267
|
+
typeof raw === 'object' &&
|
|
1268
|
+
typeof raw['tab_id'] === 'number' &&
|
|
1269
|
+
Number.isFinite(raw['tab_id'])
|
|
1270
|
+
? raw['tab_id']
|
|
1271
|
+
: undefined;
|
|
1272
|
+
const csReq = { tool: 'solid_detect', payload: {} };
|
|
1273
|
+
const response = tabId !== undefined
|
|
1274
|
+
? await dispatchToTab(tabId, csReq)
|
|
1275
|
+
: await dispatchToActiveTab(csReq);
|
|
1276
|
+
if (response.error) {
|
|
1277
|
+
throw new Error(response.error.message);
|
|
1278
|
+
}
|
|
1279
|
+
return response.payload;
|
|
1280
|
+
};
|
|
1281
|
+
const sanitizeSolidFindByTextInput = (raw) => {
|
|
1282
|
+
if (raw === null || typeof raw !== 'object')
|
|
1283
|
+
return null;
|
|
1284
|
+
const r = raw;
|
|
1285
|
+
const pattern = r['pattern'];
|
|
1286
|
+
if (typeof pattern !== 'string' || pattern.length === 0)
|
|
1287
|
+
return null;
|
|
1288
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1289
|
+
? r['tab_id']
|
|
1290
|
+
: undefined;
|
|
1291
|
+
const payload = { pattern };
|
|
1292
|
+
if (typeof r['exact'] === 'boolean')
|
|
1293
|
+
payload['exact'] = r['exact'];
|
|
1294
|
+
if (typeof r['max_matches'] === 'number' &&
|
|
1295
|
+
Number.isInteger(r['max_matches']) &&
|
|
1296
|
+
r['max_matches'] > 0) {
|
|
1297
|
+
payload['max_matches'] = r['max_matches'];
|
|
1298
|
+
}
|
|
1299
|
+
return { tabId, payload };
|
|
1300
|
+
};
|
|
1301
|
+
const handleSolidFindByText = async (env) => {
|
|
1302
|
+
const sanitized = sanitizeSolidFindByTextInput(env.payload);
|
|
1303
|
+
if (sanitized === null) {
|
|
1304
|
+
throw new Error('solid_find_by_text: payload must be { pattern: non-empty string, tab_id?, exact?, max_matches? }');
|
|
1305
|
+
}
|
|
1306
|
+
const csReq = { tool: 'solid_find_by_text', payload: sanitized.payload };
|
|
1307
|
+
const response = sanitized.tabId !== undefined
|
|
1308
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1309
|
+
: await dispatchToActiveTab(csReq);
|
|
1310
|
+
if (response.error) {
|
|
1311
|
+
throw new Error(response.error.message);
|
|
1312
|
+
}
|
|
1313
|
+
return response.payload;
|
|
1314
|
+
};
|
|
1315
|
+
const sanitizeSolidFindByRoleInput = (raw) => {
|
|
1316
|
+
if (raw === null || typeof raw !== 'object')
|
|
1317
|
+
return null;
|
|
1318
|
+
const r = raw;
|
|
1319
|
+
const role = r['role'];
|
|
1320
|
+
if (typeof role !== 'string' || role.length === 0)
|
|
1321
|
+
return null;
|
|
1322
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1323
|
+
? r['tab_id']
|
|
1324
|
+
: undefined;
|
|
1325
|
+
const payload = { role };
|
|
1326
|
+
if (typeof r['name'] === 'string' && r['name'].length > 0) {
|
|
1327
|
+
payload['name'] = r['name'];
|
|
1328
|
+
}
|
|
1329
|
+
if (typeof r['max_matches'] === 'number' &&
|
|
1330
|
+
Number.isInteger(r['max_matches']) &&
|
|
1331
|
+
r['max_matches'] > 0) {
|
|
1332
|
+
payload['max_matches'] = r['max_matches'];
|
|
1333
|
+
}
|
|
1334
|
+
return { tabId, payload };
|
|
1335
|
+
};
|
|
1336
|
+
const handleSolidFindByRole = async (env) => {
|
|
1337
|
+
const sanitized = sanitizeSolidFindByRoleInput(env.payload);
|
|
1338
|
+
if (sanitized === null) {
|
|
1339
|
+
throw new Error('solid_find_by_role: payload must be { role: non-empty string, tab_id?, name?, max_matches? }');
|
|
1340
|
+
}
|
|
1341
|
+
const csReq = { tool: 'solid_find_by_role', payload: sanitized.payload };
|
|
1342
|
+
const response = sanitized.tabId !== undefined
|
|
1343
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1344
|
+
: await dispatchToActiveTab(csReq);
|
|
1345
|
+
if (response.error) {
|
|
1346
|
+
throw new Error(response.error.message);
|
|
1347
|
+
}
|
|
1348
|
+
return response.payload;
|
|
1349
|
+
};
|
|
1350
|
+
const sanitizeReduxGetStateInput = (raw) => {
|
|
1351
|
+
if (raw === undefined || raw === null) {
|
|
1352
|
+
return { tabId: undefined, payload: {} };
|
|
1353
|
+
}
|
|
1354
|
+
if (typeof raw !== 'object')
|
|
1355
|
+
return null;
|
|
1356
|
+
const r = raw;
|
|
1357
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1358
|
+
? r['tab_id']
|
|
1359
|
+
: undefined;
|
|
1360
|
+
const payload = {};
|
|
1361
|
+
if (typeof r['path'] === 'string' && r['path'].length > 0) {
|
|
1362
|
+
payload['path'] = r['path'];
|
|
1363
|
+
}
|
|
1364
|
+
return { tabId, payload };
|
|
1365
|
+
};
|
|
1366
|
+
const handleReduxGetState = async (env) => {
|
|
1367
|
+
const sanitized = sanitizeReduxGetStateInput(env.payload);
|
|
1368
|
+
if (sanitized === null) {
|
|
1369
|
+
throw new Error('redux_get_state: payload must be an object with optional { tab_id?, path? }');
|
|
1370
|
+
}
|
|
1371
|
+
const csReq = { tool: 'redux_get_state', payload: sanitized.payload };
|
|
1372
|
+
const response = sanitized.tabId !== undefined
|
|
1373
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1374
|
+
: await dispatchToActiveTab(csReq);
|
|
1375
|
+
if (response.error) {
|
|
1376
|
+
throw new Error(response.error.message);
|
|
1377
|
+
}
|
|
1378
|
+
return response.payload;
|
|
1379
|
+
};
|
|
1380
|
+
const sanitizeReduxSubscribeInput = (raw) => {
|
|
1381
|
+
if (raw === null || typeof raw !== 'object')
|
|
1382
|
+
return null;
|
|
1383
|
+
const r = raw;
|
|
1384
|
+
const action = r['action'];
|
|
1385
|
+
if (action !== 'start' && action !== 'stop')
|
|
1386
|
+
return null;
|
|
1387
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1388
|
+
? r['tab_id']
|
|
1389
|
+
: undefined;
|
|
1390
|
+
const payload = { action };
|
|
1391
|
+
if (typeof r['path'] === 'string' && r['path'].length > 0) {
|
|
1392
|
+
payload['path'] = r['path'];
|
|
1393
|
+
}
|
|
1394
|
+
return { tabId, payload };
|
|
1395
|
+
};
|
|
1396
|
+
const handleReduxSubscribe = async (env) => {
|
|
1397
|
+
const sanitized = sanitizeReduxSubscribeInput(env.payload);
|
|
1398
|
+
if (sanitized === null) {
|
|
1399
|
+
throw new Error("redux_subscribe: payload must be { action: 'start' | 'stop', tab_id?, path? }");
|
|
1400
|
+
}
|
|
1401
|
+
const csReq = { tool: 'redux_subscribe', payload: sanitized.payload };
|
|
1402
|
+
const response = sanitized.tabId !== undefined
|
|
1403
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1404
|
+
: await dispatchToActiveTab(csReq);
|
|
1405
|
+
if (response.error) {
|
|
1406
|
+
throw new Error(response.error.message);
|
|
1407
|
+
}
|
|
1408
|
+
return response.payload;
|
|
1409
|
+
};
|
|
1410
|
+
const sanitizeReduxDispatchInput = (raw) => {
|
|
1411
|
+
if (raw === null || typeof raw !== 'object')
|
|
1412
|
+
return null;
|
|
1413
|
+
const r = raw;
|
|
1414
|
+
const action = r['action'];
|
|
1415
|
+
if (action === null || typeof action !== 'object')
|
|
1416
|
+
return null;
|
|
1417
|
+
const a = action;
|
|
1418
|
+
if (typeof a['type'] !== 'string' || a['type'].length === 0) {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1422
|
+
? r['tab_id']
|
|
1423
|
+
: undefined;
|
|
1424
|
+
return { tabId, payload: { action } };
|
|
1425
|
+
};
|
|
1426
|
+
const handleReduxDispatch = async (env) => {
|
|
1427
|
+
const sanitized = sanitizeReduxDispatchInput(env.payload);
|
|
1428
|
+
if (sanitized === null) {
|
|
1429
|
+
throw new Error('redux_dispatch: payload must be { action: { type: non-empty string; payload? }, tab_id? }');
|
|
1430
|
+
}
|
|
1431
|
+
const csReq = { tool: 'redux_dispatch', payload: sanitized.payload };
|
|
1432
|
+
const response = sanitized.tabId !== undefined
|
|
1433
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1434
|
+
: await dispatchToActiveTab(csReq);
|
|
1435
|
+
if (response.error) {
|
|
1436
|
+
throw new Error(response.error.message);
|
|
1437
|
+
}
|
|
1438
|
+
return response.payload;
|
|
1439
|
+
};
|
|
1440
|
+
// ── Unified store_* family (Path 4 M2) ──────────────────────────────────────
|
|
1441
|
+
// Framework-aware variants of the redux_* sanitizers: they additionally pass
|
|
1442
|
+
// an optional `framework` selector through to the page-world store handlers
|
|
1443
|
+
// (which auto-detect when it is absent) and forward to the store_* page keys.
|
|
1444
|
+
// The redux_* handlers above are kept untouched as deprecated aliases.
|
|
1445
|
+
const extractFramework = (r) => typeof r['framework'] === 'string' && r['framework'].length > 0
|
|
1446
|
+
? r['framework']
|
|
1447
|
+
: undefined;
|
|
1448
|
+
const routedTabId = (r) => typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1449
|
+
? r['tab_id']
|
|
1450
|
+
: undefined;
|
|
1451
|
+
const sanitizeStoreGetStateInput = (raw) => {
|
|
1452
|
+
if (raw === undefined || raw === null) {
|
|
1453
|
+
return { tabId: undefined, payload: {} };
|
|
1454
|
+
}
|
|
1455
|
+
if (typeof raw !== 'object')
|
|
1456
|
+
return null;
|
|
1457
|
+
const r = raw;
|
|
1458
|
+
const payload = {};
|
|
1459
|
+
if (typeof r['path'] === 'string' && r['path'].length > 0) {
|
|
1460
|
+
payload['path'] = r['path'];
|
|
1461
|
+
}
|
|
1462
|
+
const framework = extractFramework(r);
|
|
1463
|
+
if (framework !== undefined)
|
|
1464
|
+
payload['framework'] = framework;
|
|
1465
|
+
return { tabId: routedTabId(r), payload };
|
|
1466
|
+
};
|
|
1467
|
+
const handleStoreGetState = async (env) => {
|
|
1468
|
+
const sanitized = sanitizeStoreGetStateInput(env.payload);
|
|
1469
|
+
if (sanitized === null) {
|
|
1470
|
+
throw new Error('store_get_state: payload must be an object with optional { tab_id?, path?, framework? }');
|
|
1471
|
+
}
|
|
1472
|
+
const csReq = { tool: 'store_get_state', payload: sanitized.payload };
|
|
1473
|
+
const response = sanitized.tabId !== undefined
|
|
1474
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1475
|
+
: await dispatchToActiveTab(csReq);
|
|
1476
|
+
if (response.error) {
|
|
1477
|
+
throw new Error(response.error.message);
|
|
1478
|
+
}
|
|
1479
|
+
return response.payload;
|
|
1480
|
+
};
|
|
1481
|
+
const sanitizeStoreSubscribeInput = (raw) => {
|
|
1482
|
+
if (raw === null || typeof raw !== 'object')
|
|
1483
|
+
return null;
|
|
1484
|
+
const r = raw;
|
|
1485
|
+
const action = r['action'];
|
|
1486
|
+
if (action !== 'start' && action !== 'stop')
|
|
1487
|
+
return null;
|
|
1488
|
+
const payload = { action };
|
|
1489
|
+
if (typeof r['path'] === 'string' && r['path'].length > 0) {
|
|
1490
|
+
payload['path'] = r['path'];
|
|
1491
|
+
}
|
|
1492
|
+
const framework = extractFramework(r);
|
|
1493
|
+
if (framework !== undefined)
|
|
1494
|
+
payload['framework'] = framework;
|
|
1495
|
+
return { tabId: routedTabId(r), payload };
|
|
1496
|
+
};
|
|
1497
|
+
const handleStoreSubscribe = async (env) => {
|
|
1498
|
+
const sanitized = sanitizeStoreSubscribeInput(env.payload);
|
|
1499
|
+
if (sanitized === null) {
|
|
1500
|
+
throw new Error("store_subscribe: payload must be { action: 'start' | 'stop', tab_id?, path?, framework? }");
|
|
1501
|
+
}
|
|
1502
|
+
const csReq = { tool: 'store_subscribe', payload: sanitized.payload };
|
|
1503
|
+
const response = sanitized.tabId !== undefined
|
|
1504
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1505
|
+
: await dispatchToActiveTab(csReq);
|
|
1506
|
+
if (response.error) {
|
|
1507
|
+
throw new Error(response.error.message);
|
|
1508
|
+
}
|
|
1509
|
+
return response.payload;
|
|
1510
|
+
};
|
|
1511
|
+
const sanitizeStoreDispatchInput = (raw) => {
|
|
1512
|
+
if (raw === null || typeof raw !== 'object')
|
|
1513
|
+
return null;
|
|
1514
|
+
const r = raw;
|
|
1515
|
+
const action = r['action'];
|
|
1516
|
+
if (action === null || typeof action !== 'object')
|
|
1517
|
+
return null;
|
|
1518
|
+
const a = action;
|
|
1519
|
+
if (typeof a['type'] !== 'string' || a['type'].length === 0) {
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
const payload = { action };
|
|
1523
|
+
const framework = extractFramework(r);
|
|
1524
|
+
if (framework !== undefined)
|
|
1525
|
+
payload['framework'] = framework;
|
|
1526
|
+
return { tabId: routedTabId(r), payload };
|
|
1527
|
+
};
|
|
1528
|
+
const handleStoreDispatch = async (env) => {
|
|
1529
|
+
const sanitized = sanitizeStoreDispatchInput(env.payload);
|
|
1530
|
+
if (sanitized === null) {
|
|
1531
|
+
throw new Error('store_dispatch: payload must be { action: { type: non-empty string; payload? }, tab_id?, framework? }');
|
|
1532
|
+
}
|
|
1533
|
+
const csReq = { tool: 'store_dispatch', payload: sanitized.payload };
|
|
1534
|
+
const response = sanitized.tabId !== undefined
|
|
1535
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1536
|
+
: await dispatchToActiveTab(csReq);
|
|
1537
|
+
if (response.error) {
|
|
1538
|
+
throw new Error(response.error.message);
|
|
1539
|
+
}
|
|
1540
|
+
return response.payload;
|
|
1541
|
+
};
|
|
1542
|
+
const sanitizeSourceMapResolveInput = (raw) => {
|
|
1543
|
+
if (raw === null || typeof raw !== 'object')
|
|
1544
|
+
return null;
|
|
1545
|
+
const r = raw;
|
|
1546
|
+
const scriptUrl = r['script_url'];
|
|
1547
|
+
if (typeof scriptUrl !== 'string' || scriptUrl.length === 0)
|
|
1548
|
+
return null;
|
|
1549
|
+
const line = r['line'];
|
|
1550
|
+
if (typeof line !== 'number' || !Number.isInteger(line) || line < 1)
|
|
1551
|
+
return null;
|
|
1552
|
+
const column = r['column'];
|
|
1553
|
+
if (typeof column !== 'number' || !Number.isInteger(column) || column < 0) {
|
|
1554
|
+
return null;
|
|
1555
|
+
}
|
|
1556
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1557
|
+
? r['tab_id']
|
|
1558
|
+
: undefined;
|
|
1559
|
+
return {
|
|
1560
|
+
tabId,
|
|
1561
|
+
payload: { script_url: scriptUrl, line, column },
|
|
1562
|
+
};
|
|
1563
|
+
};
|
|
1564
|
+
const handleSourceMapResolve = async (env) => {
|
|
1565
|
+
const sanitized = sanitizeSourceMapResolveInput(env.payload);
|
|
1566
|
+
if (sanitized === null) {
|
|
1567
|
+
throw new Error('source_map_resolve: payload must be { script_url, line: int>=1, column: int>=0, tab_id? }');
|
|
1568
|
+
}
|
|
1569
|
+
const csReq = { tool: 'source_map_resolve', payload: sanitized.payload };
|
|
1570
|
+
const response = sanitized.tabId !== undefined
|
|
1571
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1572
|
+
: await dispatchToActiveTab(csReq);
|
|
1573
|
+
if (response.error) {
|
|
1574
|
+
throw new Error(response.error.message);
|
|
1575
|
+
}
|
|
1576
|
+
return response.payload;
|
|
1577
|
+
};
|
|
1578
|
+
const sanitizeSessionRecordInput = (raw) => {
|
|
1579
|
+
if (raw === null || typeof raw !== 'object')
|
|
1580
|
+
return null;
|
|
1581
|
+
const r = raw;
|
|
1582
|
+
const action = r['action'];
|
|
1583
|
+
if (action !== 'start' && action !== 'stop')
|
|
1584
|
+
return null;
|
|
1585
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1586
|
+
? r['tab_id']
|
|
1587
|
+
: undefined;
|
|
1588
|
+
const payload = { action };
|
|
1589
|
+
if (typeof r['session_id'] === 'string' && r['session_id'].length > 0) {
|
|
1590
|
+
payload['session_id'] = r['session_id'];
|
|
1591
|
+
}
|
|
1592
|
+
if (typeof r['duration_cap_ms'] === 'number' &&
|
|
1593
|
+
Number.isInteger(r['duration_cap_ms']) &&
|
|
1594
|
+
r['duration_cap_ms'] > 0) {
|
|
1595
|
+
payload['duration_cap_ms'] = r['duration_cap_ms'];
|
|
1596
|
+
}
|
|
1597
|
+
return { tabId, payload };
|
|
1598
|
+
};
|
|
1599
|
+
const handleSessionRecord = async (env) => {
|
|
1600
|
+
const sanitized = sanitizeSessionRecordInput(env.payload);
|
|
1601
|
+
if (sanitized === null) {
|
|
1602
|
+
throw new Error("session_record: payload must be { action: 'start' | 'stop', tab_id?, session_id?, duration_cap_ms? }");
|
|
1603
|
+
}
|
|
1604
|
+
const csReq = { tool: 'session_record', payload: sanitized.payload };
|
|
1605
|
+
const response = sanitized.tabId !== undefined
|
|
1606
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1607
|
+
: await dispatchToActiveTab(csReq);
|
|
1608
|
+
if (response.error) {
|
|
1609
|
+
throw new Error(response.error.message);
|
|
1610
|
+
}
|
|
1611
|
+
return response.payload;
|
|
1612
|
+
};
|
|
1613
|
+
// --- Path 7 interaction action tools (pdl_*) ---------------------------------
|
|
1614
|
+
// One generic handler per ACTION_TOOL_SPECS entry: extract tab_id for routing,
|
|
1615
|
+
// forward the locator + params payload to the page-world unchanged.
|
|
1616
|
+
const sanitizeActionInput = (raw) => {
|
|
1617
|
+
if (raw === null || typeof raw !== 'object')
|
|
1618
|
+
return null;
|
|
1619
|
+
const r = raw;
|
|
1620
|
+
const tabId = typeof r['tab_id'] === 'number' && Number.isFinite(r['tab_id'])
|
|
1621
|
+
? r['tab_id']
|
|
1622
|
+
: undefined;
|
|
1623
|
+
const payload = {};
|
|
1624
|
+
for (const [k, v] of Object.entries(r)) {
|
|
1625
|
+
if (k !== 'tab_id' && k !== 'extension_id' && v !== undefined)
|
|
1626
|
+
payload[k] = v;
|
|
1627
|
+
}
|
|
1628
|
+
return tabId !== undefined ? { tabId, payload } : { payload };
|
|
1629
|
+
};
|
|
1630
|
+
const makeActionRequestHandler = (tool) => async (env) => {
|
|
1631
|
+
const sanitized = sanitizeActionInput(env.payload);
|
|
1632
|
+
if (sanitized === null) {
|
|
1633
|
+
throw new Error(`${tool}: payload must be an object carrying a locator`);
|
|
1634
|
+
}
|
|
1635
|
+
const csReq = { tool, payload: sanitized.payload };
|
|
1636
|
+
const response = sanitized.tabId !== undefined
|
|
1637
|
+
? await dispatchToTab(sanitized.tabId, csReq)
|
|
1638
|
+
: await dispatchToActiveTab(csReq);
|
|
1639
|
+
if (response.error)
|
|
1640
|
+
throw new Error(response.error.message);
|
|
1641
|
+
return response.payload;
|
|
1642
|
+
};
|
|
1643
|
+
const actionRequestHandlers = Object.freeze(Object.fromEntries(ACTION_TOOL_SPECS.map((s) => [s.tool, makeActionRequestHandler(s.tool)])));
|
|
1644
|
+
const HANDLERS = Object.freeze({
|
|
1645
|
+
...actionRequestHandlers,
|
|
1646
|
+
session_ping: handleSessionPing,
|
|
1647
|
+
recent_events: handleRecentEvents,
|
|
1648
|
+
evaluate: handleEvaluate,
|
|
1649
|
+
react_tree: handleReactTree,
|
|
1650
|
+
react_get_state: handleReactGetState,
|
|
1651
|
+
react_find_by_text: handleReactFindByText,
|
|
1652
|
+
react_find_by_role: handleReactFindByRole,
|
|
1653
|
+
vue_tree: handleVueTree,
|
|
1654
|
+
vue_get_state: handleVueGetState,
|
|
1655
|
+
vue_find_by_text: handleVueFindByText,
|
|
1656
|
+
vue_find_by_role: handleVueFindByRole,
|
|
1657
|
+
svelte_components: handleSvelteComponents,
|
|
1658
|
+
svelte_find_by_text: handleSvelteFindByText,
|
|
1659
|
+
svelte_find_by_role: handleSvelteFindByRole,
|
|
1660
|
+
solid_detect: handleSolidDetect,
|
|
1661
|
+
solid_find_by_text: handleSolidFindByText,
|
|
1662
|
+
solid_find_by_role: handleSolidFindByRole,
|
|
1663
|
+
redux_get_state: handleReduxGetState,
|
|
1664
|
+
redux_subscribe: handleReduxSubscribe,
|
|
1665
|
+
redux_dispatch: handleReduxDispatch,
|
|
1666
|
+
store_get_state: handleStoreGetState,
|
|
1667
|
+
store_subscribe: handleStoreSubscribe,
|
|
1668
|
+
store_dispatch: handleStoreDispatch,
|
|
1669
|
+
source_map_resolve: handleSourceMapResolve,
|
|
1670
|
+
session_record: handleSessionRecord,
|
|
1671
|
+
sw_status: handleSwStatus,
|
|
1672
|
+
cache_list: handleCacheList,
|
|
1673
|
+
cache_inspect: handleCacheInspect,
|
|
1674
|
+
cache_match: handleCacheMatch,
|
|
1675
|
+
pwa_status: handlePwaStatus,
|
|
1676
|
+
pwa_installability: handlePwaInstallability,
|
|
1677
|
+
storage_get: handleStorageGet,
|
|
1678
|
+
idb_list: handleIdbList,
|
|
1679
|
+
idb_query: handleIdbQuery,
|
|
1680
|
+
pwa_update_gather: handlePwaUpdateGather,
|
|
1681
|
+
pwa_snapshot_gather: handlePwaSnapshotGather,
|
|
1682
|
+
});
|
|
1683
|
+
const errorResponse = (requestId, message) => Object.freeze({
|
|
1684
|
+
type: 'response',
|
|
1685
|
+
requestId,
|
|
1686
|
+
error: Object.freeze({ message }),
|
|
1687
|
+
});
|
|
1688
|
+
const okResponse = (requestId, payload) => Object.freeze({
|
|
1689
|
+
type: 'response',
|
|
1690
|
+
requestId,
|
|
1691
|
+
payload,
|
|
1692
|
+
});
|
|
1693
|
+
const routeRequest = async (env, ctx) => {
|
|
1694
|
+
const handler = HANDLERS[env.tool];
|
|
1695
|
+
if (!handler) {
|
|
1696
|
+
return errorResponse(env.requestId, `unknown tool: ${env.tool}`);
|
|
1697
|
+
}
|
|
1698
|
+
try {
|
|
1699
|
+
const payload = await handler(env, ctx);
|
|
1700
|
+
return okResponse(env.requestId, payload);
|
|
1701
|
+
}
|
|
1702
|
+
catch (err) {
|
|
1703
|
+
return errorResponse(env.requestId, err.message);
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
const PAGE_EVENT_SW_TAG = 'pwa-debug-page-event';
|
|
1708
|
+
|
|
1709
|
+
const createBatchAccumulator = (opts) => {
|
|
1710
|
+
if (!Number.isFinite(opts.maxSize) || opts.maxSize <= 0) {
|
|
1711
|
+
throw new Error(`createBatchAccumulator: maxSize must be > 0, got ${String(opts.maxSize)}`);
|
|
1712
|
+
}
|
|
1713
|
+
if (!Number.isFinite(opts.maxMs) || opts.maxMs <= 0) {
|
|
1714
|
+
throw new Error(`createBatchAccumulator: maxMs must be > 0, got ${String(opts.maxMs)}`);
|
|
1715
|
+
}
|
|
1716
|
+
const maxSize = opts.maxSize;
|
|
1717
|
+
const maxMs = opts.maxMs;
|
|
1718
|
+
const flush = opts.flush;
|
|
1719
|
+
const pending = [];
|
|
1720
|
+
let timerHandle;
|
|
1721
|
+
const clearTimer = () => {
|
|
1722
|
+
if (timerHandle !== undefined) {
|
|
1723
|
+
clearTimeout(timerHandle);
|
|
1724
|
+
timerHandle = undefined;
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
const flushNow = () => {
|
|
1728
|
+
clearTimer();
|
|
1729
|
+
if (pending.length === 0)
|
|
1730
|
+
return;
|
|
1731
|
+
const snapshot = pending.slice();
|
|
1732
|
+
pending.length = 0;
|
|
1733
|
+
flush(snapshot);
|
|
1734
|
+
};
|
|
1735
|
+
const push = (item) => {
|
|
1736
|
+
pending.push(item);
|
|
1737
|
+
if (pending.length >= maxSize) {
|
|
1738
|
+
flushNow();
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
if (timerHandle === undefined) {
|
|
1742
|
+
timerHandle = setTimeout(flushNow, maxMs);
|
|
1743
|
+
}
|
|
1744
|
+
};
|
|
1745
|
+
const dispose = () => {
|
|
1746
|
+
clearTimer();
|
|
1747
|
+
pending.length = 0;
|
|
1748
|
+
};
|
|
1749
|
+
return Object.freeze({ push, flushNow, dispose });
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
const DEFAULT_BUFFER_SIZE = 200;
|
|
1753
|
+
const DEFAULT_LIMIT = 50;
|
|
1754
|
+
const DEFAULT_FORWARD_MAX_SIZE = 50;
|
|
1755
|
+
const DEFAULT_FORWARD_MAX_MS = 100;
|
|
1756
|
+
const createEventSink = (input = {}) => {
|
|
1757
|
+
const logger = input.logger;
|
|
1758
|
+
const shouldRecord = input.shouldRecord;
|
|
1759
|
+
const bufferSize = input.bufferSize !== undefined && input.bufferSize > 0
|
|
1760
|
+
? Math.floor(input.bufferSize)
|
|
1761
|
+
: DEFAULT_BUFFER_SIZE;
|
|
1762
|
+
const buffer = [];
|
|
1763
|
+
let writeIndex = 0;
|
|
1764
|
+
const perKind = {};
|
|
1765
|
+
let totalReceived = 0;
|
|
1766
|
+
const forwardAcc = input.forwardEvents !== undefined
|
|
1767
|
+
? createBatchAccumulator({
|
|
1768
|
+
maxSize: input.forwardMaxSize ?? DEFAULT_FORWARD_MAX_SIZE,
|
|
1769
|
+
maxMs: input.forwardMaxMs ?? DEFAULT_FORWARD_MAX_MS,
|
|
1770
|
+
flush: input.forwardEvents,
|
|
1771
|
+
})
|
|
1772
|
+
: undefined;
|
|
1773
|
+
const snapshotStats = () => Object.freeze({
|
|
1774
|
+
totalReceived,
|
|
1775
|
+
perKind: Object.freeze({ ...perKind }),
|
|
1776
|
+
bufferSize,
|
|
1777
|
+
});
|
|
1778
|
+
const handle = (event) => {
|
|
1779
|
+
if (shouldRecord !== undefined && !shouldRecord(event)) {
|
|
1780
|
+
// Gated out: no stats, no buffer, no logger, no forward.
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
perKind[event.kind] = (perKind[event.kind] ?? 0) + 1;
|
|
1784
|
+
totalReceived += 1;
|
|
1785
|
+
if (buffer.length < bufferSize) {
|
|
1786
|
+
buffer.push(event);
|
|
1787
|
+
}
|
|
1788
|
+
else {
|
|
1789
|
+
buffer[writeIndex] = event;
|
|
1790
|
+
}
|
|
1791
|
+
writeIndex = (writeIndex + 1) % bufferSize;
|
|
1792
|
+
if (logger !== undefined) {
|
|
1793
|
+
try {
|
|
1794
|
+
logger(event);
|
|
1795
|
+
}
|
|
1796
|
+
catch {
|
|
1797
|
+
// Logger failures must not interrupt event ingestion.
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
if (forwardAcc !== undefined) {
|
|
1801
|
+
forwardAcc.push(event);
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
const getStats = () => snapshotStats();
|
|
1805
|
+
const getRecent = (filter = {}) => {
|
|
1806
|
+
const ordered = [];
|
|
1807
|
+
if (buffer.length < bufferSize) {
|
|
1808
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
1809
|
+
ordered.push(buffer[i]);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
else {
|
|
1813
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
1814
|
+
ordered.push(buffer[(writeIndex + i) % bufferSize]);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
const kindsSet = filter.kinds !== undefined && filter.kinds.length > 0
|
|
1818
|
+
? new Set(filter.kinds)
|
|
1819
|
+
: undefined;
|
|
1820
|
+
const afterKinds = kindsSet === undefined
|
|
1821
|
+
? ordered
|
|
1822
|
+
: ordered.filter((e) => kindsSet.has(e.kind));
|
|
1823
|
+
const sinceMs = filter.sinceMs;
|
|
1824
|
+
const afterSince = sinceMs === undefined
|
|
1825
|
+
? afterKinds
|
|
1826
|
+
: afterKinds.filter((e) => e.ts > sinceMs);
|
|
1827
|
+
const requested = filter.limit !== undefined ? Math.floor(filter.limit) : DEFAULT_LIMIT;
|
|
1828
|
+
const cap = Math.max(0, Math.min(requested, bufferSize));
|
|
1829
|
+
const events = afterSince.length > cap
|
|
1830
|
+
? afterSince.slice(afterSince.length - cap)
|
|
1831
|
+
: afterSince;
|
|
1832
|
+
return Object.freeze({
|
|
1833
|
+
events: Object.freeze([...events]),
|
|
1834
|
+
stats: snapshotStats(),
|
|
1835
|
+
});
|
|
1836
|
+
};
|
|
1837
|
+
const flushNow = () => {
|
|
1838
|
+
forwardAcc?.flushNow();
|
|
1839
|
+
};
|
|
1840
|
+
const dispose = () => {
|
|
1841
|
+
forwardAcc?.dispose();
|
|
1842
|
+
};
|
|
1843
|
+
return Object.freeze({ handle, getStats, getRecent, flushNow, dispose });
|
|
1844
|
+
};
|
|
1845
|
+
const isPageEventSwMessage = (msg) => {
|
|
1846
|
+
if (msg === null || typeof msg !== 'object')
|
|
1847
|
+
return false;
|
|
1848
|
+
const r = msg;
|
|
1849
|
+
return r['tag'] === PAGE_EVENT_SW_TAG && 'event' in r;
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
const isEnabled = (opts, subkind) => opts?.enabled?.[subkind] !== false;
|
|
1853
|
+
const frameKeyFor = (frameId) => frameId === undefined || frameId === 0 ? 'top' : `frame-${frameId}`;
|
|
1854
|
+
const createSwLifecycleProducer = (deps) => {
|
|
1855
|
+
if (typeof chrome === 'undefined' ||
|
|
1856
|
+
!chrome.tabs ||
|
|
1857
|
+
!chrome.webNavigation) {
|
|
1858
|
+
return () => { };
|
|
1859
|
+
}
|
|
1860
|
+
const { sink, opts, getTabUrl } = deps;
|
|
1861
|
+
const now = () => Date.now();
|
|
1862
|
+
let disposed = false;
|
|
1863
|
+
const cleanups = [];
|
|
1864
|
+
const tryEmit = (event) => {
|
|
1865
|
+
if (disposed)
|
|
1866
|
+
return;
|
|
1867
|
+
try {
|
|
1868
|
+
sink.handle(event);
|
|
1869
|
+
}
|
|
1870
|
+
catch {
|
|
1871
|
+
// Capture failure must never break the SW.
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
const buildEvent = (payload, frameUrl, frameKey) => Object.freeze({
|
|
1875
|
+
kind: 'lifecycle',
|
|
1876
|
+
source: 'sw',
|
|
1877
|
+
ts: now(),
|
|
1878
|
+
frameUrl,
|
|
1879
|
+
frameKey,
|
|
1880
|
+
...payload,
|
|
1881
|
+
});
|
|
1882
|
+
if (isEnabled(opts, 'navigation_committed')) {
|
|
1883
|
+
const onCommitted = (d) => {
|
|
1884
|
+
const payload = {
|
|
1885
|
+
subkind: 'navigation_committed',
|
|
1886
|
+
tabId: d.tabId,
|
|
1887
|
+
frameId: d.frameId,
|
|
1888
|
+
url: d.url,
|
|
1889
|
+
...(d.transitionType ? { transitionType: d.transitionType } : {}),
|
|
1890
|
+
...(d.transitionQualifiers
|
|
1891
|
+
? { transitionQualifiers: d.transitionQualifiers }
|
|
1892
|
+
: {}),
|
|
1893
|
+
};
|
|
1894
|
+
tryEmit(buildEvent(payload, d.url, frameKeyFor(d.frameId)));
|
|
1895
|
+
};
|
|
1896
|
+
chrome.webNavigation.onCommitted.addListener(onCommitted);
|
|
1897
|
+
cleanups.push(() => chrome.webNavigation.onCommitted.removeListener(onCommitted));
|
|
1898
|
+
}
|
|
1899
|
+
if (isEnabled(opts, 'history_state_updated')) {
|
|
1900
|
+
const onHistory = (d) => {
|
|
1901
|
+
const payload = {
|
|
1902
|
+
subkind: 'history_state_updated',
|
|
1903
|
+
tabId: d.tabId,
|
|
1904
|
+
frameId: d.frameId,
|
|
1905
|
+
url: d.url,
|
|
1906
|
+
...(d.transitionType ? { transitionType: d.transitionType } : {}),
|
|
1907
|
+
...(d.transitionQualifiers
|
|
1908
|
+
? { transitionQualifiers: d.transitionQualifiers }
|
|
1909
|
+
: {}),
|
|
1910
|
+
};
|
|
1911
|
+
tryEmit(buildEvent(payload, d.url, frameKeyFor(d.frameId)));
|
|
1912
|
+
};
|
|
1913
|
+
chrome.webNavigation.onHistoryStateUpdated.addListener(onHistory);
|
|
1914
|
+
cleanups.push(() => chrome.webNavigation.onHistoryStateUpdated.removeListener(onHistory));
|
|
1915
|
+
}
|
|
1916
|
+
if (isEnabled(opts, 'tab_status')) {
|
|
1917
|
+
const onUpdated = (tabId, changeInfo, tab) => {
|
|
1918
|
+
if (changeInfo.status !== 'loading' && changeInfo.status !== 'complete') {
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
const payload = {
|
|
1922
|
+
subkind: 'tab_status',
|
|
1923
|
+
tabId,
|
|
1924
|
+
status: changeInfo.status,
|
|
1925
|
+
};
|
|
1926
|
+
const url = tab.url ?? getTabUrl?.(tabId) ?? '';
|
|
1927
|
+
tryEmit(buildEvent(payload, url, 'top'));
|
|
1928
|
+
};
|
|
1929
|
+
chrome.tabs.onUpdated.addListener(onUpdated);
|
|
1930
|
+
cleanups.push(() => chrome.tabs.onUpdated.removeListener(onUpdated));
|
|
1931
|
+
}
|
|
1932
|
+
if (isEnabled(opts, 'tab_removed')) {
|
|
1933
|
+
const onRemoved = (tabId, removeInfo) => {
|
|
1934
|
+
const payload = {
|
|
1935
|
+
subkind: 'tab_removed',
|
|
1936
|
+
tabId,
|
|
1937
|
+
isWindowClosing: removeInfo.isWindowClosing,
|
|
1938
|
+
};
|
|
1939
|
+
const url = getTabUrl?.(tabId) ?? '';
|
|
1940
|
+
tryEmit(buildEvent(payload, url, 'top'));
|
|
1941
|
+
};
|
|
1942
|
+
chrome.tabs.onRemoved.addListener(onRemoved);
|
|
1943
|
+
cleanups.push(() => chrome.tabs.onRemoved.removeListener(onRemoved));
|
|
1944
|
+
}
|
|
1945
|
+
return () => {
|
|
1946
|
+
if (disposed)
|
|
1947
|
+
return;
|
|
1948
|
+
disposed = true;
|
|
1949
|
+
for (const cleanup of cleanups) {
|
|
1950
|
+
try {
|
|
1951
|
+
cleanup();
|
|
1952
|
+
}
|
|
1953
|
+
catch {
|
|
1954
|
+
// Ignore cleanup failures.
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
};
|
|
1959
|
+
|
|
1960
|
+
const attachFrameId = (event, frameId) => {
|
|
1961
|
+
if (frameId === undefined)
|
|
1962
|
+
return event;
|
|
1963
|
+
return { ...event, frameId };
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* Extension-side typed settings cache — SW-side mirror of host_settings.
|
|
1968
|
+
*
|
|
1969
|
+
* The host sends:
|
|
1970
|
+
* • One IpcEventEnvelope{ tool:'settings_snapshot', payload:{values: SettingsRecord} }
|
|
1971
|
+
* on extension register/handshake.
|
|
1972
|
+
* • One IpcEventEnvelope{ tool:'settings_changed', payload: SettingChange }
|
|
1973
|
+
* on each host store change.
|
|
1974
|
+
*
|
|
1975
|
+
* The SW orchestrator routes the envelope's payload to applySnapshot or
|
|
1976
|
+
* applyChange. This module owns the cache state — nothing here knows about
|
|
1977
|
+
* chrome.*, sockets, or IPC framing; it accepts already-decoded payloads as
|
|
1978
|
+
* `unknown` and defensively validates everything at the boundary.
|
|
1979
|
+
*
|
|
1980
|
+
* Pre-snapshot, getSetting returns the schema default for every key so
|
|
1981
|
+
* consumers (capture pipeline at T4, future UI) tolerate boot ordering.
|
|
1982
|
+
*/
|
|
1983
|
+
const isKnownKey = (k) => typeof k === 'string' &&
|
|
1984
|
+
settingKeys().includes(k);
|
|
1985
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
1986
|
+
const createSettingsCache = () => {
|
|
1987
|
+
let current = defaultSettings();
|
|
1988
|
+
const getSetting = (key) => current[key];
|
|
1989
|
+
const getAll = () => current;
|
|
1990
|
+
const applySnapshot = (payload) => {
|
|
1991
|
+
if (!isRecord(payload))
|
|
1992
|
+
return { applied: 0 };
|
|
1993
|
+
const values = payload['values'];
|
|
1994
|
+
if (!isRecord(values))
|
|
1995
|
+
return { applied: 0 };
|
|
1996
|
+
const next = {
|
|
1997
|
+
...defaultSettings(),
|
|
1998
|
+
};
|
|
1999
|
+
let applied = 0;
|
|
2000
|
+
for (const k of settingKeys()) {
|
|
2001
|
+
const v = values[k];
|
|
2002
|
+
if (v !== undefined && validateSettingValue(k, v)) {
|
|
2003
|
+
next[k] = v;
|
|
2004
|
+
applied += 1;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
current = next;
|
|
2008
|
+
return { applied };
|
|
2009
|
+
};
|
|
2010
|
+
const applyChange = (payload) => {
|
|
2011
|
+
if (!isRecord(payload))
|
|
2012
|
+
return { applied: false };
|
|
2013
|
+
const key = payload['key'];
|
|
2014
|
+
if (!isKnownKey(key))
|
|
2015
|
+
return { applied: false };
|
|
2016
|
+
const value = payload['value'];
|
|
2017
|
+
if (!validateSettingValue(key, value))
|
|
2018
|
+
return { applied: false };
|
|
2019
|
+
// Re-narrowing: getSettingEntry confirms the key exists in schema (defensive).
|
|
2020
|
+
if (!getSettingEntry(key))
|
|
2021
|
+
return { applied: false };
|
|
2022
|
+
current = { ...current, [key]: value };
|
|
2023
|
+
return { applied: true };
|
|
2024
|
+
};
|
|
2025
|
+
return { getSetting, getAll, applySnapshot, applyChange };
|
|
2026
|
+
};
|
|
2027
|
+
|
|
2028
|
+
const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g;
|
|
2029
|
+
const globToRegex = (pattern) => {
|
|
2030
|
+
const escaped = pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*');
|
|
2031
|
+
return new RegExp(`^${escaped}$`);
|
|
2032
|
+
};
|
|
2033
|
+
const matchesAnyGlob = (value, patterns) => patterns.some((p) => globToRegex(p).test(value));
|
|
2034
|
+
/**
|
|
2035
|
+
* Raw captured-event kinds (6) -> M7 capture categories (4).
|
|
2036
|
+
* The union side reflects the captured_event.ts kind discriminants.
|
|
2037
|
+
*/
|
|
2038
|
+
const KIND_MAP = Object.freeze({
|
|
2039
|
+
console: 'console',
|
|
2040
|
+
fetch: 'network',
|
|
2041
|
+
xhr: 'network',
|
|
2042
|
+
websocket: 'network',
|
|
2043
|
+
dom_mutation: 'dom_mutations',
|
|
2044
|
+
lifecycle: 'lifecycle',
|
|
2045
|
+
// App service-worker lifecycle: its own capture category + host buffer.
|
|
2046
|
+
sw_state: 'sw_state',
|
|
2047
|
+
store_change: 'store_change',
|
|
2048
|
+
replay: 'replay',
|
|
2049
|
+
library_popup: 'library_popup',
|
|
2050
|
+
});
|
|
2051
|
+
const eventKindToCaptureKind = (rawKind) => KIND_MAP[rawKind] ?? null;
|
|
2052
|
+
const isKindEnabled = (rawKind, enabledKinds) => {
|
|
2053
|
+
const ck = eventKindToCaptureKind(rawKind);
|
|
2054
|
+
if (ck === null)
|
|
2055
|
+
return true; // unknown raw kinds default-allow (future-compat)
|
|
2056
|
+
return enabledKinds.includes(ck);
|
|
2057
|
+
};
|
|
2058
|
+
const isUrlAllowed = (url, allowlist, blocklist) => {
|
|
2059
|
+
if (matchesAnyGlob(url, blocklist))
|
|
2060
|
+
return false; // blocklist wins
|
|
2061
|
+
if (allowlist.length === 0)
|
|
2062
|
+
return false; // explicit opt-in model
|
|
2063
|
+
return matchesAnyGlob(url, allowlist);
|
|
2064
|
+
};
|
|
2065
|
+
/**
|
|
2066
|
+
* Pick the most-specific readControls entry whose glob pattern matches `url`.
|
|
2067
|
+
* Specificity = longest pattern string; ties broken by lexicographic order of
|
|
2068
|
+
* the pattern so the choice is deterministic. Returns undefined when no
|
|
2069
|
+
* pattern matches (caller treats as no restriction).
|
|
2070
|
+
*/
|
|
2071
|
+
const pickMostSpecificReadControl = (url, controls) => {
|
|
2072
|
+
let winnerPattern;
|
|
2073
|
+
let winnerValue;
|
|
2074
|
+
for (const [pattern, value] of Object.entries(controls)) {
|
|
2075
|
+
if (!matchesAnyGlob(url, [pattern]))
|
|
2076
|
+
continue;
|
|
2077
|
+
if (winnerPattern === undefined) {
|
|
2078
|
+
winnerPattern = pattern;
|
|
2079
|
+
winnerValue = value;
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
if (pattern.length > winnerPattern.length ||
|
|
2083
|
+
(pattern.length === winnerPattern.length && pattern < winnerPattern)) {
|
|
2084
|
+
winnerPattern = pattern;
|
|
2085
|
+
winnerValue = value;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
return winnerValue;
|
|
2089
|
+
};
|
|
2090
|
+
/**
|
|
2091
|
+
* True iff the resolved readControls entry permits the given CaptureKind.
|
|
2092
|
+
* Missing flag = allowed (no restriction); explicit `false` denies. Undefined
|
|
2093
|
+
* control (no matching readControls entry) = allowed.
|
|
2094
|
+
*/
|
|
2095
|
+
const isKindAllowedByReadControls = (kind, control) => control?.[kind] !== false;
|
|
2096
|
+
/**
|
|
2097
|
+
* True iff the per-kind capture.filters predicate (if configured) accepts the
|
|
2098
|
+
* event. No filter for the resolved kind => allow. compileSourceFilter ok:false
|
|
2099
|
+
* (malformed regex despite the schema validator) => fail-open (allow) so a
|
|
2100
|
+
* single bad filter cannot suppress all events of a kind.
|
|
2101
|
+
*/
|
|
2102
|
+
const passesCaptureFilter = (event, captureKind, filters) => {
|
|
2103
|
+
const spec = filters[captureKind];
|
|
2104
|
+
if (spec === undefined)
|
|
2105
|
+
return true;
|
|
2106
|
+
const compiled = compileSourceFilter(spec);
|
|
2107
|
+
if (!compiled.ok)
|
|
2108
|
+
return true;
|
|
2109
|
+
return compiled.predicate(event);
|
|
2110
|
+
};
|
|
2111
|
+
const shouldCaptureEvent = (event, settings) => {
|
|
2112
|
+
if (!isKindEnabled(event.kind, settings['capture.enabledKinds'])) {
|
|
2113
|
+
return false;
|
|
2114
|
+
}
|
|
2115
|
+
if (!isUrlAllowed(event.frameUrl, settings['sites.allowlist'], settings['sites.blocklist'])) {
|
|
2116
|
+
return false;
|
|
2117
|
+
}
|
|
2118
|
+
const captureKind = eventKindToCaptureKind(event.kind);
|
|
2119
|
+
if (captureKind === null)
|
|
2120
|
+
return true; // unknown raw kinds default-allow
|
|
2121
|
+
const control = pickMostSpecificReadControl(event.frameUrl, settings['sites.readControls']);
|
|
2122
|
+
if (!isKindAllowedByReadControls(captureKind, control))
|
|
2123
|
+
return false;
|
|
2124
|
+
return passesCaptureFilter(event, captureKind, settings['capture.filters']);
|
|
2125
|
+
};
|
|
2126
|
+
|
|
2127
|
+
const isSettingsEventMessage = (msg) => {
|
|
2128
|
+
if (msg === null || typeof msg !== 'object')
|
|
2129
|
+
return false;
|
|
2130
|
+
const m = msg;
|
|
2131
|
+
return (m['type'] === 'event' &&
|
|
2132
|
+
(m['tool'] === 'settings_snapshot' || m['tool'] === 'settings_changed'));
|
|
2133
|
+
};
|
|
2134
|
+
const HOST_NAME = 'com.pwa_debug.host';
|
|
2135
|
+
const CAPTURES_EVENT_TOOL = 'captures';
|
|
2136
|
+
const installEventSinkListener = (sink) => {
|
|
2137
|
+
chrome.runtime.onMessage.addListener((msg, sender) => {
|
|
2138
|
+
if (!isPageEventSwMessage(msg))
|
|
2139
|
+
return;
|
|
2140
|
+
sink.handle(attachFrameId(msg.event, sender.frameId));
|
|
2141
|
+
});
|
|
2142
|
+
};
|
|
2143
|
+
const logSetupHint = (extId, errorMessage) => {
|
|
2144
|
+
const reason = errorMessage ? `: ${errorMessage}` : '';
|
|
2145
|
+
console.warn(`[pwa-debug/sw] native host not registered for this extension${reason}\n` +
|
|
2146
|
+
`[pwa-debug/sw] To register, ask Claude (or any MCP client) to call:\n` +
|
|
2147
|
+
`[pwa-debug/sw] mcp__pwa_debug__host_register_extension { extension_id: "${extId}" }\n` +
|
|
2148
|
+
`[pwa-debug/sw] Then reload this extension at chrome://extensions and the connect will retry.`);
|
|
2149
|
+
};
|
|
2150
|
+
const connectNativeHost = (sink, portRef, settingsCache) => {
|
|
2151
|
+
const extId = chrome.runtime.id;
|
|
2152
|
+
console.log(`[pwa-debug/sw] connecting to native host: ${HOST_NAME}`);
|
|
2153
|
+
let port;
|
|
2154
|
+
try {
|
|
2155
|
+
port = chrome.runtime.connectNative(HOST_NAME);
|
|
2156
|
+
}
|
|
2157
|
+
catch (e) {
|
|
2158
|
+
logSetupHint(extId, e.message);
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
portRef.current = port;
|
|
2162
|
+
port.onMessage.addListener((msg) => {
|
|
2163
|
+
if (isSwRequestEnvelope(msg)) {
|
|
2164
|
+
routeRequest(msg, { sink }).then((response) => {
|
|
2165
|
+
try {
|
|
2166
|
+
port.postMessage(response);
|
|
2167
|
+
}
|
|
2168
|
+
catch (err) {
|
|
2169
|
+
console.warn('[pwa-debug/sw] postMessage failed:', err.message);
|
|
2170
|
+
}
|
|
2171
|
+
}, (err) => {
|
|
2172
|
+
console.warn('[pwa-debug/sw] routeRequest rejected (should not happen):', err.message);
|
|
2173
|
+
});
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
if (isSettingsEventMessage(msg)) {
|
|
2177
|
+
if (msg.tool === 'settings_snapshot') {
|
|
2178
|
+
const r = settingsCache.applySnapshot(msg.payload);
|
|
2179
|
+
console.log(`[pwa-debug/sw] settings_snapshot applied (${r.applied} keys)`);
|
|
2180
|
+
}
|
|
2181
|
+
else {
|
|
2182
|
+
const r = settingsCache.applyChange(msg.payload);
|
|
2183
|
+
if (!r.applied) {
|
|
2184
|
+
console.warn('[pwa-debug/sw] settings_changed dropped (invalid payload):', msg.payload);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
console.log('[pwa-debug/sw] from host:', msg);
|
|
2190
|
+
});
|
|
2191
|
+
port.onDisconnect.addListener(() => {
|
|
2192
|
+
portRef.current = null;
|
|
2193
|
+
const err = chrome.runtime.lastError;
|
|
2194
|
+
const msg = err?.message ?? '';
|
|
2195
|
+
if (/not found|forbidden|access/i.test(msg)) {
|
|
2196
|
+
logSetupHint(extId, msg);
|
|
2197
|
+
}
|
|
2198
|
+
else if (msg.length > 0) {
|
|
2199
|
+
console.log('[pwa-debug/sw] native port disconnected:', msg);
|
|
2200
|
+
}
|
|
2201
|
+
else {
|
|
2202
|
+
console.log('[pwa-debug/sw] native port disconnected (clean)');
|
|
2203
|
+
}
|
|
2204
|
+
});
|
|
2205
|
+
};
|
|
2206
|
+
const bootstrap = () => {
|
|
2207
|
+
chrome.runtime.onInstalled.addListener((details) => {
|
|
2208
|
+
console.log('[pwa-debug/sw] installed:', details.reason);
|
|
2209
|
+
});
|
|
2210
|
+
console.log(`[pwa-debug/sw] id=${chrome.runtime.id}`);
|
|
2211
|
+
console.log('[pwa-debug/sw] up');
|
|
2212
|
+
const portRef = { current: null };
|
|
2213
|
+
const extensionId = chrome.runtime.id;
|
|
2214
|
+
const sendEventEnvelope = (events) => {
|
|
2215
|
+
const port = portRef.current;
|
|
2216
|
+
if (port === null)
|
|
2217
|
+
return;
|
|
2218
|
+
try {
|
|
2219
|
+
port.postMessage({
|
|
2220
|
+
type: 'event',
|
|
2221
|
+
tool: CAPTURES_EVENT_TOOL,
|
|
2222
|
+
extensionId,
|
|
2223
|
+
payload: { events },
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
catch (err) {
|
|
2227
|
+
console.warn('[pwa-debug/sw] event flush postMessage failed:', err.message);
|
|
2228
|
+
}
|
|
2229
|
+
};
|
|
2230
|
+
const settingsCache = createSettingsCache();
|
|
2231
|
+
const sink = createEventSink({
|
|
2232
|
+
logger: (event) => {
|
|
2233
|
+
console.log('[pwa-debug/sw] event', event.kind, event);
|
|
2234
|
+
},
|
|
2235
|
+
forwardEvents: sendEventEnvelope,
|
|
2236
|
+
// Re-reads settings on every event so live host pushes (T3) take effect
|
|
2237
|
+
// without an extension reload (M7 acceptance).
|
|
2238
|
+
shouldRecord: (event) => shouldCaptureEvent(event, settingsCache.getAll()),
|
|
2239
|
+
});
|
|
2240
|
+
installEventSinkListener(sink);
|
|
2241
|
+
createSwLifecycleProducer({ sink });
|
|
2242
|
+
connectNativeHost(sink, portRef, settingsCache);
|
|
2243
|
+
};
|
|
2244
|
+
bootstrap();
|
|
2245
|
+
|
|
2246
|
+
export { bootstrap };
|
|
2247
|
+
//# sourceMappingURL=service-worker.js.map
|