@jobshimo/browser-link 0.21.0 → 0.22.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/dist/bridge/events.d.ts +1 -1
- package/dist/bridge/events.js +6 -1
- package/dist/bridge/events.js.map +1 -1
- package/dist/bridge/ipc-client.d.ts +6 -7
- package/dist/bridge/ipc-client.js +4 -3
- package/dist/bridge/ipc-client.js.map +1 -1
- package/dist/bridge/protocol.d.ts +28 -16
- package/dist/bridge/protocol.js +23 -7
- package/dist/bridge/protocol.js.map +1 -1
- package/dist/bridge/server.d.ts +8 -10
- package/dist/bridge/server.js.map +1 -1
- package/dist/bridge/ws-bridge.d.ts +53 -18
- package/dist/bridge/ws-bridge.js +129 -21
- package/dist/bridge/ws-bridge.js.map +1 -1
- package/dist/cli.js +14 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +62 -8
- package/dist/commands/config.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.js.map +1 -1
- package/dist/extension/background.js +424 -1
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/flow-recording-policy.d.ts +47 -0
- package/dist/extension/flow-recording-policy.js +54 -0
- package/dist/extension/flow-recording-policy.js.map +1 -0
- package/dist/extension/inpage/recorder.d.ts +35 -0
- package/dist/extension/inpage/recorder.js +333 -0
- package/dist/extension/inpage/recorder.js.map +1 -0
- package/dist/extension/manifest.json +1 -1
- package/dist/extension/popup.html +56 -0
- package/dist/extension/popup.js +195 -4
- package/dist/extension/popup.js.map +1 -1
- package/dist/extension/recording.d.ts +158 -0
- package/dist/extension/recording.js +214 -0
- package/dist/extension/recording.js.map +1 -0
- package/dist/messages.d.ts +53 -12
- package/package.json +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page flow recorder — injected via CDP `Runtime.evaluate` ONLY while a
|
|
3
|
+
* recording is active (see `background.ts`'s `startRecording`/
|
|
4
|
+
* `stopRecording`). Never present on a page unless the user opted in via
|
|
5
|
+
* the popup toggle AND pressed Record on that specific tab.
|
|
6
|
+
*
|
|
7
|
+
* Communicates with background.ts over a CDP binding (`Runtime.addBinding`
|
|
8
|
+
* / `Runtime.bindingCalled`, wired in background.ts) rather than a WS/fetch
|
|
9
|
+
* call — the binding is the one channel CDP gives an in-page script to
|
|
10
|
+
* reach the extension without any page-visible network traffic. Each
|
|
11
|
+
* captured user action is emitted as ONE compact JSON string:
|
|
12
|
+
*
|
|
13
|
+
* { nonce, kind: 'click', selector, ambiguous? }
|
|
14
|
+
* { nonce, kind: 'type', selector, ambiguous? }
|
|
15
|
+
* { nonce, kind: 'press', key }
|
|
16
|
+
*
|
|
17
|
+
* THREAT MODEL — the binding function is reachable by EVERY script running
|
|
18
|
+
* in the page (the page's own code, ads, third-party widgets, injected
|
|
19
|
+
* XSS). Two layered defenses keep hostile page scripts from fabricating
|
|
20
|
+
* steps or using the channel for exfiltration:
|
|
21
|
+
*
|
|
22
|
+
* 1. PER-SESSION NONCE: background.ts generates a random nonce for each
|
|
23
|
+
* recording session and interpolates it into this script at injection
|
|
24
|
+
* time. Every emitted payload carries it, and background.ts REJECTS any
|
|
25
|
+
* binding call whose nonce does not match the active session — a page
|
|
26
|
+
* script calling the binding blind produces zero recorded steps,
|
|
27
|
+
* because it cannot know the nonce (it lives only inside this closure
|
|
28
|
+
* and in the extension process). The binding NAME is likewise
|
|
29
|
+
* randomized per session (see `generateRecordingSession` in
|
|
30
|
+
* `recording.ts`) so a page cannot even cheaply probe a well-known
|
|
31
|
+
* global to detect that recording is active.
|
|
32
|
+
* 2. TRUSTED EVENTS ONLY: every listener bails on `!ev.isTrusted`, so
|
|
33
|
+
* synthetic events a page dispatches programmatically (`el.click()`,
|
|
34
|
+
* `dispatchEvent(new KeyboardEvent(...))`) are never captured as user
|
|
35
|
+
* actions. Deliberate tradeoff: page code that activates its OWN
|
|
36
|
+
* widgets via `.click()` during a demonstration will not be captured
|
|
37
|
+
* either — record the underlying real gesture instead.
|
|
38
|
+
* 3. GESTURE CORRELATION for type-commits: `isTrusted` alone is NOT
|
|
39
|
+
* enough for the focus/blur path — per the HTML spec, programmatic
|
|
40
|
+
* `element.focus()` / `.blur()` from page script DO fire
|
|
41
|
+
* focus/focusin/blur/focusout events with `isTrusted:true` (unlike
|
|
42
|
+
* `.click()`, which fires untrusted). So a hostile page could call
|
|
43
|
+
* `attackerInput.focus(); attackerInput.blur()` to walk
|
|
44
|
+
* focusin->focusout and fabricate a `{type: selector}` step for an
|
|
45
|
+
* attacker-chosen replay target. Defeated by CORRELATION rather than by
|
|
46
|
+
* trusting the focus event: a type-commit is emitted only when the
|
|
47
|
+
* field's focus was driven by a real user gesture — a trusted
|
|
48
|
+
* pointerdown/mousedown that landed on the field just before focus, OR
|
|
49
|
+
* a trusted key event on the field while focused. A field that focuses
|
|
50
|
+
* and blurs with no such gesture is dropped. (A page cannot forge the
|
|
51
|
+
* gesture: synthetic pointer/key events are `isTrusted:false`.)
|
|
52
|
+
*
|
|
53
|
+
* Residual risk (documented in the README's privacy section): a script
|
|
54
|
+
* that can read THIS closure's variables could learn the nonce — but any
|
|
55
|
+
* script with that power already owns the page outright. The nonce closes
|
|
56
|
+
* the realistic blind-injection hole, not a hypothetical full-compromise.
|
|
57
|
+
*
|
|
58
|
+
* PRIVACY-CRITICAL: the `type` payload NEVER carries the field's value —
|
|
59
|
+
* there is no field for it in the shape above. `recording.ts` (the
|
|
60
|
+
* background-side pure step builder) is the ONLY place a placeholder
|
|
61
|
+
* string is attached, and it always uses the constant `"<TEXT>"` — the
|
|
62
|
+
* recorder itself has no code path capable of reading, let alone
|
|
63
|
+
* transmitting, what the user typed. No keystroke-level listener exists
|
|
64
|
+
* anywhere in this file.
|
|
65
|
+
*
|
|
66
|
+
* Idempotent: re-running this expression on a document that already has
|
|
67
|
+
* the recorder installed is a no-op — matters because `background.ts`
|
|
68
|
+
* re-injects after every navigation detected while recording, and the two
|
|
69
|
+
* injections could in principle race on the same document. All three
|
|
70
|
+
* page-visible globals (the binding function, the active flag, the stop
|
|
71
|
+
* function) derive from the per-session binding name, so none of them is a
|
|
72
|
+
* stable, guessable fingerprinting surface. `buildStopRecorderJs()` builds
|
|
73
|
+
* the companion teardown expression background.ts evaluates on
|
|
74
|
+
* stop/disconnect/navigation-away.
|
|
75
|
+
*/
|
|
76
|
+
import { DEEP_QUERY_JS } from './deep-query.js';
|
|
77
|
+
import { DOM_HELPERS_JS } from './dom-helpers.js';
|
|
78
|
+
export function buildRecorderJs(opts) {
|
|
79
|
+
const ACTIVE_FLAG = JSON.stringify(opts.activeFlag);
|
|
80
|
+
const STOP_FN = JSON.stringify(opts.stopFn);
|
|
81
|
+
return `
|
|
82
|
+
(() => {
|
|
83
|
+
if (window[${ACTIVE_FLAG}]) return;
|
|
84
|
+
window[${ACTIVE_FLAG}] = true;
|
|
85
|
+
${DEEP_QUERY_JS}
|
|
86
|
+
${DOM_HELPERS_JS}
|
|
87
|
+
|
|
88
|
+
const BINDING_NAME = ${JSON.stringify(opts.bindingName)};
|
|
89
|
+
const NONCE = ${JSON.stringify(opts.nonce)};
|
|
90
|
+
// Non-text special keys that get their own {press:{key}} step, EXCEPT
|
|
91
|
+
// Enter — Enter's handling depends on focus (see the keydown listener
|
|
92
|
+
// below), so it is deliberately not in this list.
|
|
93
|
+
const SPECIAL_KEYS = ['Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
|
|
94
|
+
// A physical mouse click fires pointerdown then click in quick
|
|
95
|
+
// succession; this window is how long a click is considered "the same
|
|
96
|
+
// gesture" as the pointerdown that preceded it, for dedupe purposes.
|
|
97
|
+
const CLICK_DEDUPE_MS = 800;
|
|
98
|
+
|
|
99
|
+
function emit(payload) {
|
|
100
|
+
try {
|
|
101
|
+
const fn = window[BINDING_NAME];
|
|
102
|
+
payload.nonce = NONCE;
|
|
103
|
+
if (typeof fn === 'function') fn(JSON.stringify(payload));
|
|
104
|
+
} catch (_) {
|
|
105
|
+
// Binding gone (recording stopped mid-flight) or serialization
|
|
106
|
+
// failed — never let the recorder throw into page code.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function deepestTarget(ev) {
|
|
111
|
+
const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null;
|
|
112
|
+
const el = path && path.length > 0 ? path[0] : ev.target;
|
|
113
|
+
return el instanceof Element ? el : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isTextField(el) {
|
|
117
|
+
if (!el) return false;
|
|
118
|
+
if (el.isContentEditable) return true;
|
|
119
|
+
// Fall back to the raw attribute — belt-and-braces for engines/test
|
|
120
|
+
// environments where the isContentEditable computed property is not
|
|
121
|
+
// fully implemented, and for the (rare) contenteditable="plaintext-only"
|
|
122
|
+
// spelling some editors use.
|
|
123
|
+
const ce = el.getAttribute ? el.getAttribute('contenteditable') : null;
|
|
124
|
+
if (ce === 'true' || ce === '' || ce === 'plaintext-only') return true;
|
|
125
|
+
const tag = el.tagName;
|
|
126
|
+
if (tag === 'TEXTAREA') return true;
|
|
127
|
+
if (tag !== 'INPUT') return false;
|
|
128
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase();
|
|
129
|
+
const TEXTY = ['text', 'search', 'email', 'tel', 'url', 'password', 'number', ''];
|
|
130
|
+
return TEXTY.indexOf(type) !== -1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function emitClickFor(el) {
|
|
134
|
+
const info = genSelectorInfo(el);
|
|
135
|
+
const payload = { kind: 'click', selector: info.selector };
|
|
136
|
+
if (info.ambiguous) payload.ambiguous = true;
|
|
137
|
+
emit(payload);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// === trusted-gesture tracking ===
|
|
141
|
+
// Records the element + time of the most recent TRUSTED
|
|
142
|
+
// pointerdown/mousedown, and marks a focused field as "gestured" when a
|
|
143
|
+
// trusted key event lands on it. Used by the type-commit gate below to
|
|
144
|
+
// reject fabricated focus/blur sequences (see the THREAT MODEL doc:
|
|
145
|
+
// element.focus()/.blur() from page script fire focus/blur events with
|
|
146
|
+
// isTrusted:TRUE — unlike .click() — so the isTrusted guard alone does
|
|
147
|
+
// NOT stop a page from walking focusin->focusout to fabricate a type
|
|
148
|
+
// step for an attacker-chosen selector). A window bounds the
|
|
149
|
+
// pointer-gesture-before-focus correlation; the key-on-field signal is a
|
|
150
|
+
// per-session boolean, no window needed.
|
|
151
|
+
const GESTURE_WINDOW_MS = 1000;
|
|
152
|
+
let lastTrustedPointerTarget = null;
|
|
153
|
+
let lastTrustedPointerAt = 0;
|
|
154
|
+
|
|
155
|
+
function noteTrustedPointer(el) {
|
|
156
|
+
lastTrustedPointerTarget = el;
|
|
157
|
+
lastTrustedPointerAt = Date.now();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// === click: pointerdown is the primary signal (survives an element
|
|
161
|
+
// that gets removed from the DOM before 'click' would fire — a common
|
|
162
|
+
// pattern for menu/dropdown items); 'click' is a fallback for
|
|
163
|
+
// keyboard-driven activation (Enter/Space on a focused button) and is
|
|
164
|
+
// deduped against a pointerdown on the SAME element within
|
|
165
|
+
// CLICK_DEDUPE_MS so one physical mouse click is never recorded twice.
|
|
166
|
+
// Both bail on untrusted events — see the THREAT MODEL note above. ===
|
|
167
|
+
let lastPointerDownTarget = null;
|
|
168
|
+
let lastPointerDownAt = 0;
|
|
169
|
+
|
|
170
|
+
function onPointerDown(ev) {
|
|
171
|
+
if (!ev.isTrusted) return;
|
|
172
|
+
const target = deepestTarget(ev);
|
|
173
|
+
if (!target) return;
|
|
174
|
+
noteTrustedPointer(target);
|
|
175
|
+
emitClickFor(target);
|
|
176
|
+
lastPointerDownTarget = target;
|
|
177
|
+
lastPointerDownAt = Date.now();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// mousedown is tracked for gesture-correlation ONLY (it never emits a
|
|
181
|
+
// click — pointerdown already covers that in every modern Chrome). Some
|
|
182
|
+
// widgets fire mousedown without a preceding pointerdown in edge cases;
|
|
183
|
+
// recording the gesture here keeps the type-commit gate from dropping a
|
|
184
|
+
// legitimate field focus in those cases.
|
|
185
|
+
function onMouseDown(ev) {
|
|
186
|
+
if (!ev.isTrusted) return;
|
|
187
|
+
const target = deepestTarget(ev);
|
|
188
|
+
if (target) noteTrustedPointer(target);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function onClick(ev) {
|
|
192
|
+
if (!ev.isTrusted) return;
|
|
193
|
+
const target = deepestTarget(ev);
|
|
194
|
+
if (!target) return;
|
|
195
|
+
if (target === lastPointerDownTarget && Date.now() - lastPointerDownAt < CLICK_DEDUPE_MS) {
|
|
196
|
+
// Second half of the physical click already recorded on pointerdown.
|
|
197
|
+
lastPointerDownTarget = null;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
emitClickFor(target);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
window.addEventListener('pointerdown', onPointerDown, true);
|
|
204
|
+
window.addEventListener('mousedown', onMouseDown, true);
|
|
205
|
+
window.addEventListener('click', onClick, true);
|
|
206
|
+
|
|
207
|
+
// === type: ONE {type:{selector}} step per focused text field, emitted
|
|
208
|
+
// on commit (blur / change / Enter). The actual value is NEVER read.
|
|
209
|
+
//
|
|
210
|
+
// GATE: a commit is emitted ONLY when the field's focus was driven by a
|
|
211
|
+
// real user gesture — a trusted pointerdown/mousedown that landed on the
|
|
212
|
+
// field (or a descendant) just before focus, OR a trusted key event on
|
|
213
|
+
// the field while it held focus (covers Tab-into-then-type). A field
|
|
214
|
+
// that focuses and blurs with NO such gesture (a page calling
|
|
215
|
+
// field.focus(); field.blur()) is DROPPED — it was not user-driven, even
|
|
216
|
+
// though the focus/blur events themselves are isTrusted:true. ===
|
|
217
|
+
let focusedField = null; // { el, committed, gestured }
|
|
218
|
+
|
|
219
|
+
function commitFocusedField() {
|
|
220
|
+
if (!focusedField || focusedField.committed) return;
|
|
221
|
+
focusedField.committed = true;
|
|
222
|
+
if (!focusedField.gestured) return; // fabricated focus/blur — drop it.
|
|
223
|
+
const info = genSelectorInfo(focusedField.el);
|
|
224
|
+
const payload = { kind: 'type', selector: info.selector };
|
|
225
|
+
if (info.ambiguous) payload.ambiguous = true;
|
|
226
|
+
emit(payload);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function onFocusIn(ev) {
|
|
230
|
+
if (!ev.isTrusted) return;
|
|
231
|
+
const target = ev.target;
|
|
232
|
+
if (!isTextField(target)) {
|
|
233
|
+
focusedField = null;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Correlate with a just-happened trusted pointer gesture ON this field
|
|
237
|
+
// (or a child of it, for a click inside a contenteditable). The pointer
|
|
238
|
+
// gesture fires BEFORE focusin, so this is the moment to check it.
|
|
239
|
+
const gestured =
|
|
240
|
+
!!lastTrustedPointerTarget &&
|
|
241
|
+
Date.now() - lastTrustedPointerAt < GESTURE_WINDOW_MS &&
|
|
242
|
+
(lastTrustedPointerTarget === target || composedContains(target, lastTrustedPointerTarget));
|
|
243
|
+
focusedField = { el: target, committed: false, gestured: gestured };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function onFocusOut(ev) {
|
|
247
|
+
if (!ev.isTrusted) return;
|
|
248
|
+
if (focusedField && ev.target === focusedField.el) {
|
|
249
|
+
commitFocusedField();
|
|
250
|
+
focusedField = null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function onChange(ev) {
|
|
255
|
+
if (!ev.isTrusted) return;
|
|
256
|
+
if (focusedField && ev.target === focusedField.el) commitFocusedField();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
window.addEventListener('focusin', onFocusIn, true);
|
|
260
|
+
window.addEventListener('focusout', onFocusOut, true);
|
|
261
|
+
window.addEventListener('change', onChange, true);
|
|
262
|
+
|
|
263
|
+
// === press: special keys outside text-field commits. Enter's dedupe
|
|
264
|
+
// rule is deliberate: Enter WHILE a tracked text field has focus belongs
|
|
265
|
+
// to that field's type-commit (see commitFocusedField above), never a
|
|
266
|
+
// separate press step — pressing Enter to submit a search box is one
|
|
267
|
+
// user action, not two. Tab is NOT swallowed the same way: it both
|
|
268
|
+
// commits any pending text (moving focus away triggers the same commit
|
|
269
|
+
// a native blur would) AND is recorded as its own {press:{key:"Tab"}}
|
|
270
|
+
// step, since Tab is a real, independently-replayable navigational
|
|
271
|
+
// action distinct from "the field lost focus". ===
|
|
272
|
+
function onKeyDown(ev) {
|
|
273
|
+
if (!ev.isTrusted) return;
|
|
274
|
+
const key = ev.key;
|
|
275
|
+
const inTrackedField = !!focusedField && ev.target === focusedField.el;
|
|
276
|
+
// A trusted key event ON the focused field is itself a user gesture —
|
|
277
|
+
// this is what makes the Tab-into-field-then-type path commit even
|
|
278
|
+
// though no pointer gesture focused it (the typed keystrokes, or the
|
|
279
|
+
// Tab keydown that navigates through it, are real user actions). A page
|
|
280
|
+
// cannot forge these: a synthetic keydown is isTrusted:false and bails
|
|
281
|
+
// at the top of this handler.
|
|
282
|
+
if (inTrackedField) focusedField.gestured = true;
|
|
283
|
+
|
|
284
|
+
if (key === 'Enter') {
|
|
285
|
+
if (inTrackedField) {
|
|
286
|
+
commitFocusedField();
|
|
287
|
+
} else {
|
|
288
|
+
emit({ kind: 'press', key: 'Enter' });
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (SPECIAL_KEYS.indexOf(key) === -1) return;
|
|
293
|
+
if (key === 'Tab' && inTrackedField) commitFocusedField();
|
|
294
|
+
emit({ kind: 'press', key: key });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
298
|
+
|
|
299
|
+
window[${STOP_FN}] = () => {
|
|
300
|
+
window.removeEventListener('pointerdown', onPointerDown, true);
|
|
301
|
+
window.removeEventListener('mousedown', onMouseDown, true);
|
|
302
|
+
window.removeEventListener('click', onClick, true);
|
|
303
|
+
window.removeEventListener('focusin', onFocusIn, true);
|
|
304
|
+
window.removeEventListener('focusout', onFocusOut, true);
|
|
305
|
+
window.removeEventListener('change', onChange, true);
|
|
306
|
+
window.removeEventListener('keydown', onKeyDown, true);
|
|
307
|
+
window[${ACTIVE_FLAG}] = false;
|
|
308
|
+
delete window[${STOP_FN}];
|
|
309
|
+
};
|
|
310
|
+
})()
|
|
311
|
+
`;
|
|
312
|
+
}
|
|
313
|
+
/** Companion teardown expression for the given session: calls the
|
|
314
|
+
* installed recorder's own stop function (named by the session's random
|
|
315
|
+
* `stopFn` global) when present, no-op otherwise (e.g. the tab already
|
|
316
|
+
* navigated away and the recorder was never re-injected into the new
|
|
317
|
+
* document — in that case there is nothing to tear down, which is itself
|
|
318
|
+
* the desired end state). background.ts evaluates this on Stop, on
|
|
319
|
+
* Discard-while-recording, on tab disconnect/removal/idle-sweep cleanup,
|
|
320
|
+
* and when the flow-recording setting flips off mid-recording —
|
|
321
|
+
* teardown-on-every-exit-path is part of the documented privacy contract,
|
|
322
|
+
* not an optimization. Safe to evaluate any number of times. */
|
|
323
|
+
export function buildStopRecorderJs(stopFn) {
|
|
324
|
+
const STOP_FN = JSON.stringify(stopFn);
|
|
325
|
+
return `
|
|
326
|
+
(() => {
|
|
327
|
+
const stop = window[${STOP_FN}];
|
|
328
|
+
if (typeof stop === 'function') stop();
|
|
329
|
+
return true;
|
|
330
|
+
})()
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
333
|
+
//# sourceMappingURL=recorder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.js","sourceRoot":"","sources":["../../src/inpage/recorder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AA0BlD,MAAM,UAAU,eAAe,CAAC,IAA8B;IAC5D,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACpD,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC5C,OAAO;;eAEM,WAAW;WACf,WAAW;IAClB,aAAa;IACb,cAAc;;yBAEO,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC;kBACvC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;WAkNjC,OAAO;;;;;;;;aAQL,WAAW;oBACJ,OAAO;;;CAG1B,CAAC;AACF,CAAC;AAED;;;;;;;;;gEASgE;AAChE,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IACvC,OAAO;;wBAEe,OAAO;;;;CAI9B,CAAC;AACF,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "browser-link",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.22.0",
|
|
5
5
|
"description": "Bridge between Chrome and an MCP client (Claude Code, OpenCode, GitHub Copilot CLI, …). Per-tab manual activation.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"debugger",
|
|
@@ -453,6 +453,17 @@
|
|
|
453
453
|
padding: 4px 6px;
|
|
454
454
|
cursor: pointer;
|
|
455
455
|
}
|
|
456
|
+
|
|
457
|
+
/* Flow-recording opt-in toggle — same thin-row treatment as the
|
|
458
|
+
* idle-ttl select, just with a checkbox instead of a <select>. Off by
|
|
459
|
+
* default; the Record/Stop controls only ever appear when this is on
|
|
460
|
+
* AND the current tab is connected (see popup.ts). */
|
|
461
|
+
.settings-row input[type='checkbox'] {
|
|
462
|
+
width: 15px;
|
|
463
|
+
height: 15px;
|
|
464
|
+
cursor: pointer;
|
|
465
|
+
accent-color: var(--brand-to);
|
|
466
|
+
}
|
|
456
467
|
</style>
|
|
457
468
|
</head>
|
|
458
469
|
<body>
|
|
@@ -553,6 +564,51 @@
|
|
|
553
564
|
</select>
|
|
554
565
|
</div>
|
|
555
566
|
|
|
567
|
+
<!-- Flow-recording opt-in. Off by default — recording UI (Record /
|
|
568
|
+
Stop / step review) only renders when this is checked AND the
|
|
569
|
+
active tab is connected. Nothing is ever captured while this is
|
|
570
|
+
unchecked; see the README's "Recording flows by demonstration"
|
|
571
|
+
section for the full privacy contract. -->
|
|
572
|
+
<div class="settings-row">
|
|
573
|
+
<label for="flow-recording-toggle">Enable flow recording</label>
|
|
574
|
+
<input type="checkbox" id="flow-recording-toggle" />
|
|
575
|
+
</div>
|
|
576
|
+
|
|
577
|
+
<!-- Recording controls — hidden unless flow recording is enabled AND
|
|
578
|
+
the active tab is connected (see popup.ts's refreshRecordingUi). -->
|
|
579
|
+
<div id="recording-row" class="settings-row" hidden>
|
|
580
|
+
<span id="recording-indicator" hidden>● Recording…</span>
|
|
581
|
+
<button id="recording-action" class="primary" style="margin-top: 0; width: auto">
|
|
582
|
+
Record
|
|
583
|
+
</button>
|
|
584
|
+
</div>
|
|
585
|
+
|
|
586
|
+
<!-- Review panel — shown after Stop, before Save/Discard. -->
|
|
587
|
+
<div id="recording-review" class="explanation" hidden>
|
|
588
|
+
<div style="margin-bottom: 6px; font-weight: 600; color: var(--text)">
|
|
589
|
+
<span id="recording-step-count">0</span> step(s) captured
|
|
590
|
+
</div>
|
|
591
|
+
<ol
|
|
592
|
+
id="recording-step-list"
|
|
593
|
+
style="margin: 0 0 10px; padding-left: 18px; max-height: 120px; overflow-y: auto"
|
|
594
|
+
></ol>
|
|
595
|
+
<input
|
|
596
|
+
id="recording-name-input"
|
|
597
|
+
class="dialog-prompt"
|
|
598
|
+
type="text"
|
|
599
|
+
placeholder="Name this flow (e.g. "open task detail dialog")"
|
|
600
|
+
/>
|
|
601
|
+
<div
|
|
602
|
+
id="recording-error"
|
|
603
|
+
hidden
|
|
604
|
+
style="color: var(--error-fg); font-size: 11.5px; margin-bottom: 8px"
|
|
605
|
+
></div>
|
|
606
|
+
<div class="dialog-actions">
|
|
607
|
+
<button id="recording-save" class="primary">Save</button>
|
|
608
|
+
<button id="recording-discard" class="danger">Discard</button>
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
|
|
556
612
|
<div class="footer">manual · per-tab · loopback only</div>
|
|
557
613
|
</div>
|
|
558
614
|
|
package/dist/extension/popup.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// so a plain relative import works the same way background.ts already
|
|
4
4
|
// imports settle.js/keymap.js.
|
|
5
5
|
import { IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STORAGE_KEY, clampIdleTtlMinutes, } from './idle-policy.js';
|
|
6
|
+
import { FLOW_RECORDING_STORAGE_KEY, FLOW_RECORDING_UPDATED_AT_STORAGE_KEY, normalizeFlowRecordingEnabled, } from './flow-recording-policy.js';
|
|
7
|
+
import { describeFlowStep } from './recording.js';
|
|
6
8
|
async function getCurrentTab() {
|
|
7
9
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
8
10
|
return tab;
|
|
@@ -165,6 +167,164 @@ async function refreshPendingDialog(tabId) {
|
|
|
165
167
|
const result = await send({ action: 'pendingDialog', tabId });
|
|
166
168
|
renderPendingDialog(result.pending);
|
|
167
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Flow-recording opt-in toggle. Reads/writes `chrome.storage.local`
|
|
172
|
+
* directly, same pattern as `refreshIdleTtlSetting`/`onIdleTtlChange` —
|
|
173
|
+
* background.ts independently reacts to the same key via
|
|
174
|
+
* `chrome.storage.onChanged`.
|
|
175
|
+
*/
|
|
176
|
+
async function refreshFlowRecordingToggle() {
|
|
177
|
+
const checkbox = $('flow-recording-toggle');
|
|
178
|
+
// Same "don't yank a live edit" guard idle-ttl's select uses.
|
|
179
|
+
if (document.activeElement === checkbox)
|
|
180
|
+
return;
|
|
181
|
+
const data = await chrome.storage.local.get(FLOW_RECORDING_STORAGE_KEY);
|
|
182
|
+
checkbox.checked = normalizeFlowRecordingEnabled(data[FLOW_RECORDING_STORAGE_KEY]);
|
|
183
|
+
}
|
|
184
|
+
async function onFlowRecordingToggleChange() {
|
|
185
|
+
const checkbox = $('flow-recording-toggle');
|
|
186
|
+
await chrome.storage.local.set({
|
|
187
|
+
[FLOW_RECORDING_STORAGE_KEY]: checkbox.checked,
|
|
188
|
+
[FLOW_RECORDING_UPDATED_AT_STORAGE_KEY]: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
// Turning the toggle off mid-recording is handled entirely on the
|
|
191
|
+
// background.ts side (chrome.storage.onChanged force-stops + discards) —
|
|
192
|
+
// this just re-renders the popup's own controls to match.
|
|
193
|
+
const tab = await getCurrentTab();
|
|
194
|
+
if (!tab?.id)
|
|
195
|
+
return;
|
|
196
|
+
const status = await send({ action: 'status', tabId: tab.id });
|
|
197
|
+
await refreshRecordingUi(tab.id, status.connected);
|
|
198
|
+
}
|
|
199
|
+
/** Render the step-review panel: count (with a cap notice when the 20-step
|
|
200
|
+
* limit auto-stopped the recording), one line per step via the SAME
|
|
201
|
+
* `describeFlowStep` background.ts's `recording.ts` module exports — the
|
|
202
|
+
* popup never re-derives its own description logic. Steps whose selector
|
|
203
|
+
* was flagged ambiguous get an inline "may match multiple elements"
|
|
204
|
+
* caution appended, so the computed safety signal is surfaced to the user
|
|
205
|
+
* before they save (it also travels into the saved recipe's description —
|
|
206
|
+
* see background.ts's saveRecording). Does not touch the name input, so
|
|
207
|
+
* repeated calls (the 800ms poll) never interrupt typing. */
|
|
208
|
+
function renderRecordingReview(steps, capped, ambiguousIndices) {
|
|
209
|
+
$('recording-review').hidden = false;
|
|
210
|
+
const capNotice = capped ? ' — stopped: 20-step limit reached' : '';
|
|
211
|
+
$('recording-step-count').textContent = `${steps.length}${capNotice}`;
|
|
212
|
+
const ambiguous = new Set(ambiguousIndices);
|
|
213
|
+
const list = $('recording-step-list');
|
|
214
|
+
list.innerHTML = '';
|
|
215
|
+
steps.forEach((step, i) => {
|
|
216
|
+
const li = document.createElement('li');
|
|
217
|
+
li.textContent = describeFlowStep(step);
|
|
218
|
+
if (ambiguous.has(i)) {
|
|
219
|
+
const warn = document.createElement('span');
|
|
220
|
+
warn.textContent = ' ⚠ selector may match multiple elements';
|
|
221
|
+
warn.style.color = 'var(--idle-fg)';
|
|
222
|
+
li.appendChild(warn);
|
|
223
|
+
}
|
|
224
|
+
list.appendChild(li);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function setRecordingError(message) {
|
|
228
|
+
const el = $('recording-error');
|
|
229
|
+
if (message === null) {
|
|
230
|
+
el.hidden = true;
|
|
231
|
+
el.textContent = '';
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
el.hidden = false;
|
|
235
|
+
el.textContent = message;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Show/hide and populate the Record/Stop/review controls for `tabId`.
|
|
239
|
+
* Hidden entirely unless flow recording is enabled AND the tab is
|
|
240
|
+
* connected — "ALL recording UI is hidden ... when disabled" from the
|
|
241
|
+
* privacy contract is enforced right here, not just by the recorder never
|
|
242
|
+
* being injected.
|
|
243
|
+
*/
|
|
244
|
+
async function refreshRecordingUi(tabId, connected) {
|
|
245
|
+
const recordingRow = $('recording-row');
|
|
246
|
+
const data = await chrome.storage.local.get(FLOW_RECORDING_STORAGE_KEY);
|
|
247
|
+
const enabled = normalizeFlowRecordingEnabled(data[FLOW_RECORDING_STORAGE_KEY]);
|
|
248
|
+
if (!enabled || !connected) {
|
|
249
|
+
recordingRow.hidden = true;
|
|
250
|
+
$('recording-review').hidden = true;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const status = await send({ action: 'recordingStatus', tabId });
|
|
254
|
+
recordingRow.hidden = false;
|
|
255
|
+
const indicator = $('recording-indicator');
|
|
256
|
+
const actionBtn = $('recording-action');
|
|
257
|
+
if (status.reviewing) {
|
|
258
|
+
indicator.hidden = true;
|
|
259
|
+
actionBtn.hidden = true;
|
|
260
|
+
renderRecordingReview(status.steps ?? [], status.capped, status.ambiguousStepIndices ?? []);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
actionBtn.hidden = false;
|
|
264
|
+
$('recording-review').hidden = true;
|
|
265
|
+
if (status.active) {
|
|
266
|
+
indicator.hidden = false;
|
|
267
|
+
actionBtn.textContent = 'Stop';
|
|
268
|
+
actionBtn.className = 'danger';
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
indicator.hidden = true;
|
|
272
|
+
actionBtn.textContent = 'Record';
|
|
273
|
+
actionBtn.className = 'primary';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function onRecordingAction() {
|
|
277
|
+
const tab = await getCurrentTab();
|
|
278
|
+
if (!tab?.id)
|
|
279
|
+
return;
|
|
280
|
+
setRecordingError(null);
|
|
281
|
+
const status = await send({ action: 'recordingStatus', tabId: tab.id });
|
|
282
|
+
if (status.active) {
|
|
283
|
+
const result = await send({ action: 'stopRecording', tabId: tab.id });
|
|
284
|
+
if (!result.ok)
|
|
285
|
+
setRecordingError(result.error ?? 'Could not stop recording.');
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const result = await send({ action: 'startRecording', tabId: tab.id });
|
|
289
|
+
if (!result.ok)
|
|
290
|
+
setRecordingError(result.error ?? 'Could not start recording.');
|
|
291
|
+
}
|
|
292
|
+
await refreshRecordingUi(tab.id, true);
|
|
293
|
+
}
|
|
294
|
+
async function onSaveRecording() {
|
|
295
|
+
const tab = await getCurrentTab();
|
|
296
|
+
if (!tab?.id)
|
|
297
|
+
return;
|
|
298
|
+
const nameInput = $('recording-name-input');
|
|
299
|
+
const saveBtn = $('recording-save');
|
|
300
|
+
setRecordingError(null);
|
|
301
|
+
saveBtn.disabled = true;
|
|
302
|
+
try {
|
|
303
|
+
const result = await send({
|
|
304
|
+
action: 'saveRecording',
|
|
305
|
+
tabId: tab.id,
|
|
306
|
+
name: nameInput.value,
|
|
307
|
+
});
|
|
308
|
+
if (!result.ok) {
|
|
309
|
+
setRecordingError(result.error ?? 'Could not save the flow.');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
nameInput.value = '';
|
|
313
|
+
await refreshRecordingUi(tab.id, true);
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
saveBtn.disabled = false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async function onDiscardRecording() {
|
|
320
|
+
const tab = await getCurrentTab();
|
|
321
|
+
if (!tab?.id)
|
|
322
|
+
return;
|
|
323
|
+
await send({ action: 'discardRecording', tabId: tab.id });
|
|
324
|
+
$('recording-name-input').value = '';
|
|
325
|
+
setRecordingError(null);
|
|
326
|
+
await refreshRecordingUi(tab.id, true);
|
|
327
|
+
}
|
|
168
328
|
async function refresh() {
|
|
169
329
|
// Always refresh the version banner — it has no dependency on the active
|
|
170
330
|
// tab, only on whether ANY tab has registered with the server.
|
|
@@ -177,6 +337,8 @@ async function refresh() {
|
|
|
177
337
|
urlEl.textContent = '';
|
|
178
338
|
setExplanation(null);
|
|
179
339
|
renderPendingDialog(null);
|
|
340
|
+
$('recording-row').hidden = true;
|
|
341
|
+
$('recording-review').hidden = true;
|
|
180
342
|
return;
|
|
181
343
|
}
|
|
182
344
|
urlEl.textContent = tab.url ?? '';
|
|
@@ -195,6 +357,7 @@ async function refresh() {
|
|
|
195
357
|
setAction('Connect this tab', 'primary');
|
|
196
358
|
renderPendingDialog(null);
|
|
197
359
|
}
|
|
360
|
+
await refreshRecordingUi(tab.id, status.connected);
|
|
198
361
|
}
|
|
199
362
|
async function respondToDialog(accept) {
|
|
200
363
|
const tab = await getCurrentTab();
|
|
@@ -271,18 +434,43 @@ $('idle-ttl-select').addEventListener('change', () => {
|
|
|
271
434
|
// control to whatever actually made it into storage.
|
|
272
435
|
});
|
|
273
436
|
});
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
|
|
437
|
+
$('flow-recording-toggle').addEventListener('change', () => {
|
|
438
|
+
onFlowRecordingToggleChange().catch(() => {
|
|
439
|
+
// Best effort — the next poll tick re-syncs the checkbox.
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
$('recording-action').addEventListener('click', () => {
|
|
443
|
+
onRecordingAction().catch((err) => {
|
|
444
|
+
setRecordingError(err instanceof Error ? err.message : String(err));
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
$('recording-save').addEventListener('click', () => {
|
|
448
|
+
onSaveRecording().catch((err) => {
|
|
449
|
+
setRecordingError(err instanceof Error ? err.message : String(err));
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
$('recording-discard').addEventListener('click', () => {
|
|
453
|
+
onDiscardRecording().catch(() => {
|
|
454
|
+
// Best effort.
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
// Poll for pending dialogs, version drift, the idle-ttl setting, AND the
|
|
458
|
+
// flow-recording setting/status while the popup is open. Cheap (one round
|
|
459
|
+
// trip per concern, fast paths return null/aligned/unchanged) and only
|
|
460
|
+
// runs while the popup window is visible — Chrome unloads the popup on
|
|
461
|
+
// close. The recording poll is what surfaces a background.ts-side
|
|
462
|
+
// auto-stop (the 20-step cap) without the user having to reopen the popup.
|
|
278
463
|
setInterval(() => {
|
|
279
464
|
void (async () => {
|
|
280
465
|
await refreshVersionBanner();
|
|
281
466
|
await refreshIdleTtlSetting();
|
|
467
|
+
await refreshFlowRecordingToggle();
|
|
282
468
|
const tab = await getCurrentTab();
|
|
283
469
|
if (!tab?.id)
|
|
284
470
|
return;
|
|
285
471
|
await refreshPendingDialog(tab.id);
|
|
472
|
+
const status = await send({ action: 'status', tabId: tab.id });
|
|
473
|
+
await refreshRecordingUi(tab.id, status.connected);
|
|
286
474
|
})();
|
|
287
475
|
}, 800);
|
|
288
476
|
refresh().catch((err) => {
|
|
@@ -291,4 +479,7 @@ refresh().catch((err) => {
|
|
|
291
479
|
refreshIdleTtlSetting().catch(() => {
|
|
292
480
|
// Best effort — the periodic timer above retries every 800ms regardless.
|
|
293
481
|
});
|
|
482
|
+
refreshFlowRecordingToggle().catch(() => {
|
|
483
|
+
// Best effort — same rationale as refreshIdleTtlSetting above.
|
|
484
|
+
});
|
|
294
485
|
//# sourceMappingURL=popup.js.map
|