browserwire 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/cli/api/bridge.js +64 -0
- package/cli/api/openapi.js +175 -0
- package/cli/api/router.js +280 -0
- package/cli/api/swagger-ui.js +26 -0
- package/cli/discovery/classify.js +304 -0
- package/cli/discovery/compile.js +392 -0
- package/cli/discovery/enrich.js +376 -0
- package/cli/discovery/entities.js +356 -0
- package/cli/discovery/llm-client.js +352 -0
- package/cli/discovery/locators.js +326 -0
- package/cli/discovery/perceive.js +476 -0
- package/cli/discovery/session.js +930 -0
- package/cli/discovery/synthesize-workflows.js +295 -0
- package/cli/index.js +63 -0
- package/cli/manifest-store.js +140 -0
- package/cli/server.js +539 -0
- package/extension/background.js +1512 -0
- package/extension/content-script.js +491 -0
- package/extension/discovery.js +495 -0
- package/extension/executor.js +392 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +33 -0
- package/extension/shared/protocol.js +50 -0
- package/extension/sidepanel.html +277 -0
- package/extension/sidepanel.js +211 -0
- package/extension/vendor/LICENSE +22 -0
- package/extension/vendor/rrweb-record.min.js +84 -0
- package/package.json +49 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content-script.js — Dynamic Discovery Observer
|
|
3
|
+
*
|
|
4
|
+
* Uses rrweb's event stream to detect user interactions and DOM settle,
|
|
5
|
+
* then triggers M1 scans (via discovery.js) and sends tagged snapshots
|
|
6
|
+
* to the CLI server.
|
|
7
|
+
*
|
|
8
|
+
* rrweb events used:
|
|
9
|
+
* - MouseInteraction (source=2): click, focus, blur, touch — interaction trigger
|
|
10
|
+
* - Mutation (source=0): DOM changes — settle detection signal
|
|
11
|
+
* - Input (source=5): text/select/checkbox changes — input trigger
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SETTLE_DEBOUNCE_MS = 300;
|
|
15
|
+
const SETTLE_HARD_TIMEOUT_MS = 3000;
|
|
16
|
+
|
|
17
|
+
/** rrweb IncrementalSource constants */
|
|
18
|
+
const RRWEB_SOURCE = {
|
|
19
|
+
MUTATION: 0,
|
|
20
|
+
MOUSE_INTERACTION: 2,
|
|
21
|
+
INPUT: 5
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** rrweb EventType constants */
|
|
25
|
+
const RRWEB_TYPE = {
|
|
26
|
+
INCREMENTAL: 3,
|
|
27
|
+
META: 4
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ─── Network Idle Tracking ──────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
let _pendingNetwork = 0;
|
|
33
|
+
let _networkHooked = false;
|
|
34
|
+
|
|
35
|
+
const isNetworkIdle = () => _pendingNetwork === 0;
|
|
36
|
+
|
|
37
|
+
const onNetworkSettle = () => {
|
|
38
|
+
if (!discoveryState.active || !discoveryState.pendingSettle) return;
|
|
39
|
+
if (!isNetworkIdle()) return;
|
|
40
|
+
// Network just drained — reschedule settle with short paint-flush delay
|
|
41
|
+
if (discoveryState.settleTimer) clearTimeout(discoveryState.settleTimer);
|
|
42
|
+
discoveryState.settleTimer = setTimeout(() => {
|
|
43
|
+
if (discoveryState.pendingSettle) triggerScan(discoveryState.pendingTrigger);
|
|
44
|
+
}, 100);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const hookNetwork = () => {
|
|
48
|
+
if (_networkHooked) return;
|
|
49
|
+
_networkHooked = true;
|
|
50
|
+
|
|
51
|
+
const _origFetch = window.fetch;
|
|
52
|
+
window.fetch = function(...args) {
|
|
53
|
+
if (discoveryState.active) _pendingNetwork++;
|
|
54
|
+
return _origFetch.apply(this, args).finally(() => {
|
|
55
|
+
if (discoveryState.active) { _pendingNetwork = Math.max(0, _pendingNetwork - 1); onNetworkSettle(); }
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const _origSend = XMLHttpRequest.prototype.send;
|
|
60
|
+
XMLHttpRequest.prototype.send = function(...args) {
|
|
61
|
+
if (discoveryState.active) {
|
|
62
|
+
_pendingNetwork++;
|
|
63
|
+
this.addEventListener('loadend', () => {
|
|
64
|
+
if (discoveryState.active) { _pendingNetwork = Math.max(0, _pendingNetwork - 1); onNetworkSettle(); }
|
|
65
|
+
}, { once: true });
|
|
66
|
+
}
|
|
67
|
+
return _origSend.apply(this, args);
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const discoveryState = {
|
|
72
|
+
active: false,
|
|
73
|
+
sessionId: null,
|
|
74
|
+
stopRecording: null,
|
|
75
|
+
snapshotCount: 0,
|
|
76
|
+
/** Whether we're waiting for DOM to settle after an interaction */
|
|
77
|
+
pendingSettle: false,
|
|
78
|
+
/** Info about the interaction that triggered the current settle wait */
|
|
79
|
+
pendingTrigger: null,
|
|
80
|
+
settleTimer: null,
|
|
81
|
+
hardTimer: null,
|
|
82
|
+
/** Last known URL — for SPA navigation detection */
|
|
83
|
+
lastUrl: window.location.href
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const getRecordApi = () => {
|
|
87
|
+
if (typeof rrweb !== "undefined" && rrweb && typeof rrweb.record === "function") {
|
|
88
|
+
return rrweb;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ─── Trigger Context Capture ────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Walk up the DOM to find nearest landmark role and heading.
|
|
98
|
+
*/
|
|
99
|
+
const getParentContext = (el) => {
|
|
100
|
+
const LANDMARK_ROLES = new Set([
|
|
101
|
+
"navigation", "main", "banner", "contentinfo", "complementary",
|
|
102
|
+
"form", "region", "search", "dialog"
|
|
103
|
+
]);
|
|
104
|
+
const LANDMARK_TAGS = {
|
|
105
|
+
nav: "navigation", main: "main", header: "banner",
|
|
106
|
+
footer: "contentinfo", aside: "complementary", form: "form",
|
|
107
|
+
dialog: "dialog"
|
|
108
|
+
};
|
|
109
|
+
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
110
|
+
|
|
111
|
+
let nearestLandmark = null;
|
|
112
|
+
let nearestHeading = null;
|
|
113
|
+
let node = el?.parentElement;
|
|
114
|
+
|
|
115
|
+
while (node && node !== document.body) {
|
|
116
|
+
// Check landmark
|
|
117
|
+
if (!nearestLandmark) {
|
|
118
|
+
const role = node.getAttribute("role") || LANDMARK_TAGS[node.tagName.toLowerCase()] || null;
|
|
119
|
+
if (role && LANDMARK_ROLES.has(role)) {
|
|
120
|
+
nearestLandmark = role;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check heading (first heading sibling or child near this path)
|
|
125
|
+
if (!nearestHeading) {
|
|
126
|
+
for (const child of node.children) {
|
|
127
|
+
if (HEADING_TAGS.has(child.tagName.toLowerCase())) {
|
|
128
|
+
nearestHeading = (child.textContent || "").trim().slice(0, 100);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (nearestLandmark && nearestHeading) break;
|
|
135
|
+
node = node.parentElement;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { nearestLandmark, nearestHeading };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Extract rich trigger context from a DOM element.
|
|
143
|
+
*/
|
|
144
|
+
const captureTriggerContext = (el, kind) => {
|
|
145
|
+
if (!el || !(el instanceof HTMLElement)) {
|
|
146
|
+
return {
|
|
147
|
+
kind,
|
|
148
|
+
target: null,
|
|
149
|
+
parentContext: null,
|
|
150
|
+
url: window.location.href,
|
|
151
|
+
title: document.title,
|
|
152
|
+
timestamp: Date.now()
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const attrs = {};
|
|
157
|
+
for (const name of ["href", "data-testid", "type", "name", "placeholder", "aria-expanded", "aria-selected"]) {
|
|
158
|
+
const val = el.getAttribute(name);
|
|
159
|
+
if (val != null) attrs[name] = val;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
kind,
|
|
164
|
+
target: {
|
|
165
|
+
tag: el.tagName.toLowerCase(),
|
|
166
|
+
text: (el.textContent || "").trim().slice(0, 100),
|
|
167
|
+
role: el.getAttribute("role") || null,
|
|
168
|
+
name: el.getAttribute("aria-label") || el.getAttribute("title") || null,
|
|
169
|
+
attributes: attrs
|
|
170
|
+
},
|
|
171
|
+
parentContext: getParentContext(el),
|
|
172
|
+
url: window.location.href,
|
|
173
|
+
title: document.title,
|
|
174
|
+
timestamp: Date.now()
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// ─── rrweb Event Filters ────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const isInteractionEvent = (event) => {
|
|
181
|
+
if (event.type !== RRWEB_TYPE.INCREMENTAL) return false;
|
|
182
|
+
const source = event.data?.source;
|
|
183
|
+
return source === RRWEB_SOURCE.MOUSE_INTERACTION || source === RRWEB_SOURCE.INPUT;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const isMutationEvent = (event) =>
|
|
187
|
+
event.type === RRWEB_TYPE.INCREMENTAL && event.data?.source === RRWEB_SOURCE.MUTATION;
|
|
188
|
+
|
|
189
|
+
const isMetaEvent = (event) =>
|
|
190
|
+
event.type === RRWEB_TYPE.META;
|
|
191
|
+
|
|
192
|
+
// ─── DOM Settle Detection ───────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
const clearSettleTimers = () => {
|
|
195
|
+
if (discoveryState.settleTimer) {
|
|
196
|
+
clearTimeout(discoveryState.settleTimer);
|
|
197
|
+
discoveryState.settleTimer = null;
|
|
198
|
+
}
|
|
199
|
+
if (discoveryState.hardTimer) {
|
|
200
|
+
clearTimeout(discoveryState.hardTimer);
|
|
201
|
+
discoveryState.hardTimer = null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolve target element from rrweb MouseInteraction event.
|
|
207
|
+
* rrweb stores a numeric id that maps to the mirrored node.
|
|
208
|
+
* We try rrweb.mirror first, then fall back to document lookup.
|
|
209
|
+
*/
|
|
210
|
+
const resolveTarget = (event) => {
|
|
211
|
+
const id = event.data?.id;
|
|
212
|
+
if (!id) return null;
|
|
213
|
+
|
|
214
|
+
// rrweb uses a mirror to map ids to elements
|
|
215
|
+
if (typeof rrweb !== "undefined" && rrweb.mirror && typeof rrweb.mirror.getNode === "function") {
|
|
216
|
+
return rrweb.mirror.getNode(id) || null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const triggerScan = (trigger) => {
|
|
223
|
+
clearSettleTimers();
|
|
224
|
+
discoveryState.pendingSettle = false;
|
|
225
|
+
discoveryState.pendingTrigger = null;
|
|
226
|
+
|
|
227
|
+
// runSkeletonScan is defined in discovery.js, also injected as content script
|
|
228
|
+
if (typeof runSkeletonScan !== "function") {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const skeletonResult = runSkeletonScan();
|
|
234
|
+
discoveryState.snapshotCount += 1;
|
|
235
|
+
|
|
236
|
+
chrome.runtime.sendMessage({
|
|
237
|
+
source: "content",
|
|
238
|
+
type: "discovery_incremental",
|
|
239
|
+
payload: {
|
|
240
|
+
snapshotId: `snap_${discoveryState.sessionId}_${discoveryState.snapshotCount}`,
|
|
241
|
+
sessionId: discoveryState.sessionId,
|
|
242
|
+
trigger,
|
|
243
|
+
skeleton: skeletonResult.skeleton,
|
|
244
|
+
pageText: skeletonResult.pageText,
|
|
245
|
+
url: skeletonResult.url,
|
|
246
|
+
title: skeletonResult.title,
|
|
247
|
+
devicePixelRatio: skeletonResult.devicePixelRatio,
|
|
248
|
+
capturedAt: skeletonResult.capturedAt,
|
|
249
|
+
pageState: skeletonResult.pageState
|
|
250
|
+
}
|
|
251
|
+
}, () => {
|
|
252
|
+
void chrome.runtime.lastError;
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// Scan failed — log but don't crash
|
|
256
|
+
console.warn("[browserwire] skeleton scan failed:", error);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const onInteraction = (event) => {
|
|
261
|
+
if (!discoveryState.active) return;
|
|
262
|
+
|
|
263
|
+
const target = resolveTarget(event);
|
|
264
|
+
const kind = event.data?.source === RRWEB_SOURCE.INPUT ? "input" : "click";
|
|
265
|
+
const trigger = captureTriggerContext(target, kind);
|
|
266
|
+
|
|
267
|
+
// Start watching for DOM settle
|
|
268
|
+
discoveryState.pendingSettle = true;
|
|
269
|
+
discoveryState.pendingTrigger = trigger;
|
|
270
|
+
|
|
271
|
+
// Reset settle timer
|
|
272
|
+
clearSettleTimers();
|
|
273
|
+
discoveryState.settleTimer = setTimeout(() => {
|
|
274
|
+
if (!discoveryState.pendingSettle) return;
|
|
275
|
+
if (isNetworkIdle()) {
|
|
276
|
+
triggerScan(discoveryState.pendingTrigger);
|
|
277
|
+
} else {
|
|
278
|
+
// Network still active — reschedule; hard timer is the backstop
|
|
279
|
+
discoveryState.settleTimer = setTimeout(() => {
|
|
280
|
+
if (discoveryState.pendingSettle) triggerScan(discoveryState.pendingTrigger);
|
|
281
|
+
}, SETTLE_DEBOUNCE_MS);
|
|
282
|
+
}
|
|
283
|
+
}, SETTLE_DEBOUNCE_MS);
|
|
284
|
+
|
|
285
|
+
// Hard timeout — scan even if mutations keep firing
|
|
286
|
+
discoveryState.hardTimer = setTimeout(() => {
|
|
287
|
+
if (discoveryState.pendingSettle) {
|
|
288
|
+
triggerScan(discoveryState.pendingTrigger);
|
|
289
|
+
}
|
|
290
|
+
}, SETTLE_HARD_TIMEOUT_MS);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const onMutation = () => {
|
|
294
|
+
if (!discoveryState.active || !discoveryState.pendingSettle) return;
|
|
295
|
+
|
|
296
|
+
// DOM still changing — reset the settle debounce
|
|
297
|
+
if (discoveryState.settleTimer) {
|
|
298
|
+
clearTimeout(discoveryState.settleTimer);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
discoveryState.settleTimer = setTimeout(() => {
|
|
302
|
+
if (!discoveryState.pendingSettle) return;
|
|
303
|
+
if (isNetworkIdle()) {
|
|
304
|
+
triggerScan(discoveryState.pendingTrigger);
|
|
305
|
+
} else {
|
|
306
|
+
// Network still active — reschedule; hard timer is the backstop
|
|
307
|
+
discoveryState.settleTimer = setTimeout(() => {
|
|
308
|
+
if (discoveryState.pendingSettle) triggerScan(discoveryState.pendingTrigger);
|
|
309
|
+
}, SETTLE_DEBOUNCE_MS);
|
|
310
|
+
}
|
|
311
|
+
}, SETTLE_DEBOUNCE_MS);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const onNavigation = () => {
|
|
315
|
+
if (!discoveryState.active) return;
|
|
316
|
+
|
|
317
|
+
const trigger = captureTriggerContext(null, "navigation");
|
|
318
|
+
discoveryState.pendingSettle = true;
|
|
319
|
+
discoveryState.pendingTrigger = trigger;
|
|
320
|
+
|
|
321
|
+
// After navigation, wait for DOM to settle before scanning
|
|
322
|
+
clearSettleTimers();
|
|
323
|
+
discoveryState.settleTimer = setTimeout(() => {
|
|
324
|
+
if (!discoveryState.pendingSettle) return;
|
|
325
|
+
if (isNetworkIdle()) {
|
|
326
|
+
triggerScan(discoveryState.pendingTrigger);
|
|
327
|
+
} else {
|
|
328
|
+
// Network still active — reschedule; hard timer is the backstop
|
|
329
|
+
discoveryState.settleTimer = setTimeout(() => {
|
|
330
|
+
if (discoveryState.pendingSettle) triggerScan(discoveryState.pendingTrigger);
|
|
331
|
+
}, SETTLE_DEBOUNCE_MS);
|
|
332
|
+
}
|
|
333
|
+
}, SETTLE_DEBOUNCE_MS);
|
|
334
|
+
|
|
335
|
+
discoveryState.hardTimer = setTimeout(() => {
|
|
336
|
+
if (discoveryState.pendingSettle) {
|
|
337
|
+
triggerScan(discoveryState.pendingTrigger);
|
|
338
|
+
}
|
|
339
|
+
}, SETTLE_HARD_TIMEOUT_MS);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// ─── SPA Navigation Detection ───────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
window.addEventListener("popstate", () => {
|
|
345
|
+
if (discoveryState.active && window.location.href !== discoveryState.lastUrl) {
|
|
346
|
+
discoveryState.lastUrl = window.location.href;
|
|
347
|
+
onNavigation();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
window.addEventListener("hashchange", () => {
|
|
352
|
+
if (discoveryState.active && window.location.href !== discoveryState.lastUrl) {
|
|
353
|
+
discoveryState.lastUrl = window.location.href;
|
|
354
|
+
onNavigation();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ─── Session Lifecycle ──────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
const startExploring = (sessionId) => {
|
|
361
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
362
|
+
return { ok: false, error: "invalid_session" };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (discoveryState.active && discoveryState.sessionId === sessionId) {
|
|
366
|
+
return { ok: true, alreadyRunning: true };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (discoveryState.active) {
|
|
370
|
+
stopExploring();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const recordApi = getRecordApi();
|
|
374
|
+
if (!recordApi) {
|
|
375
|
+
return { ok: false, error: "rrweb_unavailable" };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
discoveryState.active = true;
|
|
380
|
+
hookNetwork(); // one-time wrapping of fetch/XHR
|
|
381
|
+
_pendingNetwork = 0; // reset counter at session start
|
|
382
|
+
discoveryState.sessionId = sessionId;
|
|
383
|
+
discoveryState.snapshotCount = 0;
|
|
384
|
+
discoveryState.lastUrl = window.location.href;
|
|
385
|
+
discoveryState.pendingSettle = false;
|
|
386
|
+
discoveryState.pendingTrigger = null;
|
|
387
|
+
|
|
388
|
+
// Start rrweb recording — only used for event detection, not sent to server
|
|
389
|
+
discoveryState.stopRecording = recordApi.record({
|
|
390
|
+
emit(event) {
|
|
391
|
+
if (!discoveryState.active) return;
|
|
392
|
+
|
|
393
|
+
if (isInteractionEvent(event)) {
|
|
394
|
+
onInteraction(event);
|
|
395
|
+
}
|
|
396
|
+
if (isMutationEvent(event)) {
|
|
397
|
+
onMutation();
|
|
398
|
+
}
|
|
399
|
+
if (isMetaEvent(event)) {
|
|
400
|
+
// URL/title change detected by rrweb
|
|
401
|
+
if (window.location.href !== discoveryState.lastUrl) {
|
|
402
|
+
discoveryState.lastUrl = window.location.href;
|
|
403
|
+
onNavigation();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Initial scan — wait for readyState=complete + paint-flush buffer
|
|
410
|
+
const initialTrigger = captureTriggerContext(null, "initial");
|
|
411
|
+
const doInitialScan = () => setTimeout(() => triggerScan(initialTrigger), 200);
|
|
412
|
+
if (document.readyState === 'complete') {
|
|
413
|
+
doInitialScan();
|
|
414
|
+
} else {
|
|
415
|
+
document.addEventListener('readystatechange', function onReady() {
|
|
416
|
+
if (document.readyState === 'complete') {
|
|
417
|
+
document.removeEventListener('readystatechange', onReady);
|
|
418
|
+
doInitialScan();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return { ok: true, sessionId };
|
|
424
|
+
} catch (error) {
|
|
425
|
+
stopExploring();
|
|
426
|
+
return {
|
|
427
|
+
ok: false,
|
|
428
|
+
error: error instanceof Error ? error.message : "explore_start_failed"
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const stopExploring = () => {
|
|
434
|
+
clearSettleTimers();
|
|
435
|
+
|
|
436
|
+
if (typeof discoveryState.stopRecording === "function") {
|
|
437
|
+
discoveryState.stopRecording();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
discoveryState.active = false;
|
|
441
|
+
discoveryState.sessionId = null;
|
|
442
|
+
discoveryState.snapshotCount = 0;
|
|
443
|
+
discoveryState.stopRecording = null;
|
|
444
|
+
discoveryState.pendingSettle = false;
|
|
445
|
+
discoveryState.pendingTrigger = null;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// ─── Message Listener ───────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
451
|
+
if (!message || message.source !== "background") {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (message.command === "explore_start") {
|
|
456
|
+
sendResponse(startExploring(message.sessionId));
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (message.command === "explore_stop") {
|
|
461
|
+
stopExploring();
|
|
462
|
+
sendResponse({ ok: true });
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Keep legacy discovery_scan for backward compat (called on initial scan too)
|
|
467
|
+
if (message.command === "discovery_scan") {
|
|
468
|
+
try {
|
|
469
|
+
if (typeof runDiscoveryScan === "function") {
|
|
470
|
+
const snapshot = runDiscoveryScan();
|
|
471
|
+
sendResponse({ ok: true, snapshot });
|
|
472
|
+
} else {
|
|
473
|
+
sendResponse({ ok: false, error: "discovery_scan_unavailable" });
|
|
474
|
+
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
sendResponse({
|
|
477
|
+
ok: false,
|
|
478
|
+
error: error instanceof Error ? error.message : "discovery_scan_failed"
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return false;
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
window.addEventListener("beforeunload", () => {
|
|
488
|
+
if (discoveryState.active) {
|
|
489
|
+
stopExploring();
|
|
490
|
+
}
|
|
491
|
+
});
|