@jobshimo/browser-link 0.21.0 → 0.23.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.
Files changed (92) hide show
  1. package/README.md +5 -0
  2. package/dist/agent-instructions/content.js +11 -0
  3. package/dist/agent-instructions/content.js.map +1 -1
  4. package/dist/bridge/events.d.ts +1 -1
  5. package/dist/bridge/events.js +6 -1
  6. package/dist/bridge/events.js.map +1 -1
  7. package/dist/bridge/ipc-client.d.ts +6 -7
  8. package/dist/bridge/ipc-client.js +4 -3
  9. package/dist/bridge/ipc-client.js.map +1 -1
  10. package/dist/bridge/protocol.d.ts +28 -16
  11. package/dist/bridge/protocol.js +23 -7
  12. package/dist/bridge/protocol.js.map +1 -1
  13. package/dist/bridge/server.d.ts +8 -10
  14. package/dist/bridge/server.js.map +1 -1
  15. package/dist/bridge/ws-bridge.d.ts +53 -18
  16. package/dist/bridge/ws-bridge.js +129 -21
  17. package/dist/bridge/ws-bridge.js.map +1 -1
  18. package/dist/cdp/client.d.ts +35 -0
  19. package/dist/cdp/client.js +149 -0
  20. package/dist/cdp/client.js.map +1 -0
  21. package/dist/cdp/flow.d.ts +178 -0
  22. package/dist/cdp/flow.js +217 -0
  23. package/dist/cdp/flow.js.map +1 -0
  24. package/dist/cdp/gate.d.ts +31 -0
  25. package/dist/cdp/gate.js +35 -0
  26. package/dist/cdp/gate.js.map +1 -0
  27. package/dist/cdp/grant.d.ts +45 -0
  28. package/dist/cdp/grant.js +81 -0
  29. package/dist/cdp/grant.js.map +1 -0
  30. package/dist/cdp/inpage/builders.d.ts +165 -0
  31. package/dist/cdp/inpage/builders.js +501 -0
  32. package/dist/cdp/inpage/builders.js.map +1 -0
  33. package/dist/cdp/inpage/deep-query.d.ts +57 -0
  34. package/dist/cdp/inpage/deep-query.js +325 -0
  35. package/dist/cdp/inpage/deep-query.js.map +1 -0
  36. package/dist/cdp/inpage/dom-helpers.d.ts +27 -0
  37. package/dist/cdp/inpage/dom-helpers.js +147 -0
  38. package/dist/cdp/inpage/dom-helpers.js.map +1 -0
  39. package/dist/cdp/keymap.d.ts +83 -0
  40. package/dist/cdp/keymap.js +205 -0
  41. package/dist/cdp/keymap.js.map +1 -0
  42. package/dist/cdp/settle.d.ts +38 -0
  43. package/dist/cdp/settle.js +76 -0
  44. package/dist/cdp/settle.js.map +1 -0
  45. package/dist/cdp/support.d.ts +26 -0
  46. package/dist/cdp/support.js +58 -0
  47. package/dist/cdp/support.js.map +1 -0
  48. package/dist/cdp/targets.d.ts +54 -0
  49. package/dist/cdp/targets.js +176 -0
  50. package/dist/cdp/targets.js.map +1 -0
  51. package/dist/cdp/transport.d.ts +31 -0
  52. package/dist/cdp/transport.js +531 -0
  53. package/dist/cdp/transport.js.map +1 -0
  54. package/dist/cli.js +64 -2
  55. package/dist/cli.js.map +1 -1
  56. package/dist/commands/about.js +13 -0
  57. package/dist/commands/about.js.map +1 -1
  58. package/dist/commands/cdp.d.ts +28 -0
  59. package/dist/commands/cdp.js +172 -0
  60. package/dist/commands/cdp.js.map +1 -0
  61. package/dist/commands/config.d.ts +97 -0
  62. package/dist/commands/config.js +228 -13
  63. package/dist/commands/config.js.map +1 -1
  64. package/dist/config.d.ts +89 -0
  65. package/dist/config.js +94 -1
  66. package/dist/config.js.map +1 -1
  67. package/dist/extension/background.js +424 -1
  68. package/dist/extension/background.js.map +1 -1
  69. package/dist/extension/flow-recording-policy.d.ts +47 -0
  70. package/dist/extension/flow-recording-policy.js +54 -0
  71. package/dist/extension/flow-recording-policy.js.map +1 -0
  72. package/dist/extension/inpage/recorder.d.ts +35 -0
  73. package/dist/extension/inpage/recorder.js +333 -0
  74. package/dist/extension/inpage/recorder.js.map +1 -0
  75. package/dist/extension/manifest.json +1 -1
  76. package/dist/extension/popup.html +56 -0
  77. package/dist/extension/popup.js +195 -4
  78. package/dist/extension/popup.js.map +1 -1
  79. package/dist/extension/recording.d.ts +158 -0
  80. package/dist/extension/recording.js +214 -0
  81. package/dist/extension/recording.js.map +1 -0
  82. package/dist/messages.d.ts +53 -12
  83. package/dist/server.js +9 -0
  84. package/dist/server.js.map +1 -1
  85. package/dist/tools/browser-definitions.js +4 -3
  86. package/dist/tools/browser-definitions.js.map +1 -1
  87. package/dist/tools/browser-dispatch.d.ts +31 -0
  88. package/dist/tools/browser-dispatch.js +88 -40
  89. package/dist/tools/browser-dispatch.js.map +1 -1
  90. package/dist/tools/server-instructions.js +19 -0
  91. package/dist/tools/server-instructions.js.map +1 -1
  92. 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.21.0",
4
+ "version": "0.23.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. &quot;open task detail dialog&quot;)"
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
 
@@ -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
- // Poll for pending dialogs, version drift, AND the idle-ttl setting while
275
- // the popup is open. Cheap (one round trip per concern, fast paths return
276
- // null/aligned/unchanged) and only runs while the popup window is visible
277
- // — Chrome unloads the popup on close.
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