@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.
Files changed (38) hide show
  1. package/dist/bridge/events.d.ts +1 -1
  2. package/dist/bridge/events.js +6 -1
  3. package/dist/bridge/events.js.map +1 -1
  4. package/dist/bridge/ipc-client.d.ts +6 -7
  5. package/dist/bridge/ipc-client.js +4 -3
  6. package/dist/bridge/ipc-client.js.map +1 -1
  7. package/dist/bridge/protocol.d.ts +28 -16
  8. package/dist/bridge/protocol.js +23 -7
  9. package/dist/bridge/protocol.js.map +1 -1
  10. package/dist/bridge/server.d.ts +8 -10
  11. package/dist/bridge/server.js.map +1 -1
  12. package/dist/bridge/ws-bridge.d.ts +53 -18
  13. package/dist/bridge/ws-bridge.js +129 -21
  14. package/dist/bridge/ws-bridge.js.map +1 -1
  15. package/dist/cli.js +14 -2
  16. package/dist/cli.js.map +1 -1
  17. package/dist/commands/config.d.ts +5 -0
  18. package/dist/commands/config.js +62 -8
  19. package/dist/commands/config.js.map +1 -1
  20. package/dist/config.d.ts +19 -0
  21. package/dist/config.js.map +1 -1
  22. package/dist/extension/background.js +424 -1
  23. package/dist/extension/background.js.map +1 -1
  24. package/dist/extension/flow-recording-policy.d.ts +47 -0
  25. package/dist/extension/flow-recording-policy.js +54 -0
  26. package/dist/extension/flow-recording-policy.js.map +1 -0
  27. package/dist/extension/inpage/recorder.d.ts +35 -0
  28. package/dist/extension/inpage/recorder.js +333 -0
  29. package/dist/extension/inpage/recorder.js.map +1 -0
  30. package/dist/extension/manifest.json +1 -1
  31. package/dist/extension/popup.html +56 -0
  32. package/dist/extension/popup.js +195 -4
  33. package/dist/extension/popup.js.map +1 -1
  34. package/dist/extension/recording.d.ts +158 -0
  35. package/dist/extension/recording.js +214 -0
  36. package/dist/extension/recording.js.map +1 -0
  37. package/dist/messages.d.ts +53 -12
  38. package/package.json +1 -1
@@ -3,6 +3,9 @@ import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, }
3
3
  import { resolveSettleParams, settleSafely } from './settle.js';
4
4
  import { runFlow, } from './flow.js';
5
5
  import { DEFAULT_IDLE_TTL_MINUTES, IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STORAGE_KEY, clampIdleTtlMinutes, parseIncomingSettings, shouldAcceptIncomingSettings, shouldDisconnectForIdle, shouldScheduleIdleSweep, } from './idle-policy.js';
6
+ import { DEFAULT_FLOW_RECORDING_ENABLED, FLOW_RECORDING_STORAGE_KEY, FLOW_RECORDING_UPDATED_AT_STORAGE_KEY, normalizeFlowRecordingEnabled, parseIncomingFlowRecordingSettings, } from './flow-recording-policy.js';
7
+ import { buildRecorderJs, buildStopRecorderJs } from './inpage/recorder.js';
8
+ import { appendRecordingStep, buildAmbiguousNote, buildNavigationWaitForStep, generateRecordingSession, isNavigationForRecording, parseRecordedPayload, toFlowStep, } from './recording.js';
6
9
  const WS_URL = 'ws://127.0.0.1:17529';
7
10
  const CDP_VERSION = '1.3';
8
11
  const CONSOLE_BUFFER_MAX = 200;
@@ -77,21 +80,93 @@ async function applyIncomingSettings(settings) {
77
80
  // next settings.update (or a popup edit) can still apply it.
78
81
  }
79
82
  }
83
+ /* Opt-in flow-recording toggle — mirrors the idle-ttl cache/apply pair
84
+ * above exactly, independent storage keys, independent updatedAt. The
85
+ * recorder is gated behind `flowRecordingEnabledCache`: `startRecording`
86
+ * refuses when it is false, and turning it off WHILE a recording is in
87
+ * progress force-stops and DISCARDS it immediately (see the
88
+ * chrome.storage.onChanged listener below) — "nothing records when the
89
+ * popup toggle is off" is enforced the instant the toggle flips, not just
90
+ * for recordings started afterward. */
91
+ let flowRecordingEnabledCache = DEFAULT_FLOW_RECORDING_ENABLED;
92
+ async function loadFlowRecordingEnabled() {
93
+ try {
94
+ const data = await chrome.storage.local.get(FLOW_RECORDING_STORAGE_KEY);
95
+ flowRecordingEnabledCache = normalizeFlowRecordingEnabled(data[FLOW_RECORDING_STORAGE_KEY]);
96
+ }
97
+ catch {
98
+ flowRecordingEnabledCache = DEFAULT_FLOW_RECORDING_ENABLED;
99
+ }
100
+ }
101
+ void loadFlowRecordingEnabled();
102
+ chrome.storage.onChanged.addListener((changes, areaName) => {
103
+ if (areaName !== 'local')
104
+ return;
105
+ if (!(FLOW_RECORDING_STORAGE_KEY in changes))
106
+ return;
107
+ flowRecordingEnabledCache = normalizeFlowRecordingEnabled(changes[FLOW_RECORDING_STORAGE_KEY].newValue);
108
+ if (flowRecordingEnabledCache)
109
+ return;
110
+ // Turned off (popup toggle, or a losing settings.update — either way the
111
+ // new effective value is false): stop AND discard every in-progress
112
+ // recording right now, across every connected tab. `forceStopAndDiscard
113
+ // Recording` is defined further down (function declarations are hoisted,
114
+ // so this forward reference is safe) alongside the rest of the recording
115
+ // engine.
116
+ for (const tabId of tabStates.keys()) {
117
+ void forceStopAndDiscardRecording(tabId).catch(() => {
118
+ // Best effort — the tab may already be mid-teardown.
119
+ });
120
+ }
121
+ });
122
+ async function applyIncomingFlowRecordingSettings(settings) {
123
+ try {
124
+ const data = await chrome.storage.local.get(FLOW_RECORDING_UPDATED_AT_STORAGE_KEY);
125
+ const rawLocalUpdatedAt = data[FLOW_RECORDING_UPDATED_AT_STORAGE_KEY];
126
+ const localUpdatedAt = typeof rawLocalUpdatedAt === 'number' ? rawLocalUpdatedAt : undefined;
127
+ if (!shouldAcceptIncomingSettings(localUpdatedAt, settings.updatedAt))
128
+ return;
129
+ await chrome.storage.local.set({
130
+ [FLOW_RECORDING_STORAGE_KEY]: settings.flowRecordingEnabled,
131
+ [FLOW_RECORDING_UPDATED_AT_STORAGE_KEY]: settings.updatedAt,
132
+ });
133
+ }
134
+ catch {
135
+ // Best effort — same degrade-gracefully rule as applyIncomingSettings.
136
+ }
137
+ }
80
138
  function isRuntimeMessage(msg) {
81
139
  if (typeof msg !== 'object' || msg === null)
82
140
  return false;
83
141
  const m = msg;
84
142
  if (m.action === 'versionCheck')
85
143
  return true;
144
+ if (m.action === 'saveRecording') {
145
+ return typeof m.tabId === 'number' && typeof m.name === 'string';
146
+ }
86
147
  if (typeof m.tabId !== 'number')
87
148
  return false;
88
149
  return (m.action === 'connect' ||
89
150
  m.action === 'disconnect' ||
90
151
  m.action === 'status' ||
91
152
  m.action === 'pendingDialog' ||
92
- m.action === 'respondDialog');
153
+ m.action === 'respondDialog' ||
154
+ m.action === 'recordingStatus' ||
155
+ m.action === 'startRecording' ||
156
+ m.action === 'stopRecording' ||
157
+ m.action === 'discardRecording');
93
158
  }
94
159
  const tabStates = new Map();
160
+ /** Per-tab `chrome.tabs.onUpdated` listener installed while that tab is
161
+ * recording, so `handleRecordingNavigation` fires on every navigation and
162
+ * `stopRecording`/`cleanup` can remove EXACTLY this listener rather than
163
+ * guessing which one belongs to which tab. Kept out of `TabState` itself
164
+ * (unlike `debuggerListener`) only because it needs to be reachable from
165
+ * `chrome.tabs.onRemoved`/`cleanup` even in the split second before a
166
+ * `TabState` exists — in practice that never happens today, but keeping
167
+ * the two concerns in separate maps avoids coupling tab-removal cleanup to
168
+ * TabState's shape. */
169
+ const recordingNavListeners = new Map();
95
170
  function send(ws, msg) {
96
171
  ws.send(JSON.stringify(msg));
97
172
  }
@@ -399,6 +474,34 @@ function attachDebuggerListener(state) {
399
474
  }
400
475
  return;
401
476
  }
477
+ if (method === 'Runtime.bindingCalled') {
478
+ // Fired for EVERY CDP binding on the tab, not just ours. Two gates
479
+ // before a payload is even parsed: the per-session binding name must
480
+ // match, and — inside parseRecordedPayload — the payload must carry
481
+ // the per-session nonce only the injected recorder knows. A page
482
+ // script calling the binding blind (it CAN reach the function — CDP
483
+ // bindings are page-visible globals) fails the nonce check and
484
+ // records nothing. See recorder.ts's THREAT MODEL doc.
485
+ const p = params;
486
+ const recording = state.recording;
487
+ if (!recording || recording.status !== 'recording')
488
+ return;
489
+ if (p.name !== recording.bindingName)
490
+ return;
491
+ const payload = parseRecordedPayload(p.payload ?? '', recording.nonce);
492
+ if (!payload)
493
+ return;
494
+ const result = appendRecordingStep(recording.steps, toFlowStep(payload));
495
+ recording.steps = result.steps;
496
+ if (!result.capped && payload.kind !== 'press' && payload.ambiguous === true) {
497
+ recording.ambiguousStepIndices.push(result.steps.length - 1);
498
+ }
499
+ if (result.capped) {
500
+ recording.capped = true;
501
+ void finishRecording(state.tabId);
502
+ }
503
+ return;
504
+ }
402
505
  };
403
506
  state.debuggerListener = listener;
404
507
  }
@@ -1303,6 +1406,21 @@ async function cleanup(tabId) {
1303
1406
  const state = tabStates.get(tabId);
1304
1407
  if (!state)
1305
1408
  return;
1409
+ // Recording never survives a disconnect — there is no server connection
1410
+ // left to save to, and leaving captured-but-unsaved steps around after
1411
+ // the tab drops its bridge would be a surprising place for them to
1412
+ // linger.
1413
+ removeRecordingNavListener(tabId);
1414
+ // `chrome.debugger.detach` below drops the CDP binding SUBSCRIPTION but
1415
+ // does NOT remove the plain `window.addEventListener` listeners the
1416
+ // recorder installed in the page — those would leak, staying attached to
1417
+ // a page the user is still browsing. Tear them down in-page FIRST, while
1418
+ // the debugger is still attached and `evaluateInTab` can reach the page.
1419
+ // Best-effort: the tab may already be gone (tab-removal path), in which
1420
+ // case there is nothing left to tear down anyway.
1421
+ if (state.recording) {
1422
+ await teardownRecorderInPage(tabId, state.recording.stopFn);
1423
+ }
1306
1424
  if (state.ws && state.ws.readyState !== WebSocket.CLOSED) {
1307
1425
  try {
1308
1426
  state.ws.close();
@@ -1465,6 +1583,21 @@ async function connectTab(tabId) {
1465
1583
  const settings = parseIncomingSettings(msg.settings);
1466
1584
  if (settings)
1467
1585
  void applyIncomingSettings(settings);
1586
+ const flowRecordingSettings = parseIncomingFlowRecordingSettings(msg.settings);
1587
+ if (flowRecordingSettings)
1588
+ void applyIncomingFlowRecordingSettings(flowRecordingSettings);
1589
+ return;
1590
+ }
1591
+ case 'flow.recorded.result': {
1592
+ // Not agent/tool activity either — same rationale as
1593
+ // settings.update just above.
1594
+ const pending = state.pendingFlowSave;
1595
+ if (!pending)
1596
+ return;
1597
+ if (msg.ok)
1598
+ pending({ ok: true });
1599
+ else
1600
+ pending({ ok: false, error: msg.error });
1468
1601
  return;
1469
1602
  }
1470
1603
  case 'tool.request': {
@@ -1535,6 +1668,276 @@ function getVersionCheck() {
1535
1668
  aligned: server === null ? null : server === extension,
1536
1669
  };
1537
1670
  }
1671
+ // === Flow recording by demonstration ===================================
1672
+ //
1673
+ // STRICTLY OPT-IN (see flowRecordingEnabledCache above) and per-tab/per-
1674
+ // session — nothing here runs unless the user both enabled the setting AND
1675
+ // pressed Record on this specific connected tab. Lifecycle:
1676
+ //
1677
+ // startRecording -> [handleRecordingNavigation]* -> stopRecording
1678
+ // -> saveRecording | discardRecording
1679
+ //
1680
+ // `Runtime.addBinding` (CDP) is added ONCE at start and left in place for
1681
+ // the whole session — per the CDP spec it is re-installed automatically on
1682
+ // every new execution context, INCLUDING ones created by a navigation, so
1683
+ // it survives document swaps on its own. The in-page LISTENERS
1684
+ // (`inpage/recorder.ts`, injected via `Runtime.evaluate`) do NOT survive a
1685
+ // navigation — a document swap tears down every JS global the previous
1686
+ // document set up — so `handleRecordingNavigation` re-injects the recorder
1687
+ // script (not the binding) after every navigation observed while
1688
+ // recording, using the same `chrome.tabs.onUpdated` "complete" signal
1689
+ // `waitForLoad` already uses elsewhere in this file for the standalone
1690
+ // `navigate` tool.
1691
+ function originOfUrl(url) {
1692
+ try {
1693
+ return new URL(url).origin;
1694
+ }
1695
+ catch {
1696
+ return url;
1697
+ }
1698
+ }
1699
+ function removeRecordingNavListener(tabId) {
1700
+ const listener = recordingNavListeners.get(tabId);
1701
+ if (listener) {
1702
+ chrome.tabs.onUpdated.removeListener(listener);
1703
+ recordingNavListeners.delete(tabId);
1704
+ }
1705
+ }
1706
+ async function injectRecorder(tabId, session) {
1707
+ await evaluateInTab(tabId, buildRecorderJs({
1708
+ bindingName: session.bindingName,
1709
+ activeFlag: session.activeFlag,
1710
+ stopFn: session.stopFn,
1711
+ nonce: session.nonce,
1712
+ }));
1713
+ }
1714
+ /** Best-effort in-page teardown: calls the installed recorder's own stop
1715
+ * function (named by the session's random `stopFn` global) so its listeners
1716
+ * are removed even before the CDP binding subscription is dropped. A no-op
1717
+ * when the tab already navigated away (nothing installed on the new
1718
+ * document) — which is itself the correct end state, not a failure. Runs on
1719
+ * EVERY exit path — Stop, Discard, setting-off force-stop, and `cleanup`
1720
+ * (disconnect / tab removal / idle sweep) — because
1721
+ * `chrome.debugger.detach` alone only drops the CDP binding subscription,
1722
+ * NOT the plain `window.addEventListener` listeners the recorder installed;
1723
+ * those must be removed in-page. */
1724
+ async function teardownRecorderInPage(tabId, stopFn) {
1725
+ try {
1726
+ await evaluateInTab(tabId, buildStopRecorderJs(stopFn));
1727
+ }
1728
+ catch {
1729
+ // Tab closed/navigated/detached mid-teardown — nothing left to tear down.
1730
+ }
1731
+ }
1732
+ async function handleRecordingNavigation(tabId, newUrl) {
1733
+ const state = tabStates.get(tabId);
1734
+ const recording = state?.recording;
1735
+ if (!recording || recording.status !== 'recording')
1736
+ return;
1737
+ if (!isNavigationForRecording(recording.lastUrl, newUrl))
1738
+ return;
1739
+ recording.lastUrl = newUrl;
1740
+ const result = appendRecordingStep(recording.steps, buildNavigationWaitForStep());
1741
+ recording.steps = result.steps;
1742
+ if (result.capped) {
1743
+ recording.capped = true;
1744
+ await finishRecording(tabId);
1745
+ return;
1746
+ }
1747
+ // Re-inject only — the CDP binding itself persists across navigations
1748
+ // automatically (see the section doc above). Same session identity: the
1749
+ // nonce/binding name rotate per recording START, not per document.
1750
+ await injectRecorder(tabId, recording);
1751
+ }
1752
+ /** Start recording user interactions on an already-connected tab. Refuses
1753
+ * when the opt-in setting is off, the tab is not connected, or a recording
1754
+ * (recording OR under-review) is already in progress for this tab. */
1755
+ async function startRecording(tabId) {
1756
+ if (!flowRecordingEnabledCache) {
1757
+ return { ok: false, error: 'Flow recording is disabled. Enable it in settings first.' };
1758
+ }
1759
+ const state = tabStates.get(tabId);
1760
+ if (!state)
1761
+ return { ok: false, error: 'This tab is not connected.' };
1762
+ if (state.recording) {
1763
+ return { ok: false, error: 'A recording is already in progress (or awaiting review).' };
1764
+ }
1765
+ let tab;
1766
+ try {
1767
+ tab = await chrome.tabs.get(tabId);
1768
+ }
1769
+ catch {
1770
+ return { ok: false, error: 'Tab no longer exists.' };
1771
+ }
1772
+ // Fresh per-session identity: randomized binding name (no stable global
1773
+ // for a page to probe) + nonce (the shared secret authenticating every
1774
+ // payload). Rotated on EVERY recording start — see recorder.ts's THREAT
1775
+ // MODEL doc.
1776
+ const session = generateRecordingSession();
1777
+ try {
1778
+ await cdp(tabId, 'Runtime.addBinding', { name: session.bindingName });
1779
+ await injectRecorder(tabId, session);
1780
+ }
1781
+ catch (err) {
1782
+ return {
1783
+ ok: false,
1784
+ error: `Could not start recording: ${err instanceof Error ? err.message : String(err)}`,
1785
+ };
1786
+ }
1787
+ state.recording = {
1788
+ status: 'recording',
1789
+ steps: [],
1790
+ lastUrl: tab.url ?? '',
1791
+ capped: false,
1792
+ bindingName: session.bindingName,
1793
+ activeFlag: session.activeFlag,
1794
+ stopFn: session.stopFn,
1795
+ nonce: session.nonce,
1796
+ ambiguousStepIndices: [],
1797
+ };
1798
+ const navListener = (updatedId, info, updatedTab) => {
1799
+ if (updatedId !== tabId)
1800
+ return;
1801
+ if (info.status !== 'complete')
1802
+ return;
1803
+ void handleRecordingNavigation(tabId, updatedTab.url ?? '');
1804
+ };
1805
+ chrome.tabs.onUpdated.addListener(navListener);
1806
+ recordingNavListeners.set(tabId, navListener);
1807
+ return { ok: true };
1808
+ }
1809
+ /** Shared tail of "stop recording": un-injects the in-page recorder,
1810
+ * unsubscribes the CDP binding, removes the navigation listener, and moves
1811
+ * `status` to 'reviewing' — steps stay on `state.recording` for the
1812
+ * popup's review panel. Called both from the explicit Stop action and from
1813
+ * the step-cap auto-stop path (`Runtime.bindingCalled` handler /
1814
+ * `handleRecordingNavigation` above). */
1815
+ async function finishRecording(tabId) {
1816
+ const state = tabStates.get(tabId);
1817
+ if (!state?.recording || state.recording.status !== 'recording')
1818
+ return;
1819
+ removeRecordingNavListener(tabId);
1820
+ state.recording.status = 'reviewing';
1821
+ try {
1822
+ await cdp(tabId, 'Runtime.removeBinding', { name: state.recording.bindingName });
1823
+ }
1824
+ catch {
1825
+ // Best effort — tab may already be detached.
1826
+ }
1827
+ await teardownRecorderInPage(tabId, state.recording.stopFn);
1828
+ }
1829
+ async function stopRecording(tabId) {
1830
+ const state = tabStates.get(tabId);
1831
+ if (!state?.recording || state.recording.status !== 'recording') {
1832
+ return { ok: false, error: 'Not currently recording this tab.' };
1833
+ }
1834
+ await finishRecording(tabId);
1835
+ return { ok: true, steps: state.recording.steps, capped: state.recording.capped };
1836
+ }
1837
+ /** Full teardown, dropping any captured-but-unsaved steps. Used by
1838
+ * Discard, by the "setting turned off mid-recording" force-stop, and by
1839
+ * `cleanup`/disconnect. */
1840
+ function clearRecording(tabId) {
1841
+ removeRecordingNavListener(tabId);
1842
+ const state = tabStates.get(tabId);
1843
+ if (state)
1844
+ state.recording = undefined;
1845
+ }
1846
+ /** Discard a recording without saving. The popup only exposes Discard from
1847
+ * the review panel (recorder already torn down by `finishRecording`), but
1848
+ * routing through `forceStopAndDiscardRecording` makes it correct even if
1849
+ * called while a recording is still ACTIVE — it tears the recorder down
1850
+ * in-page first rather than orphaning its page listeners. */
1851
+ async function discardRecording(tabId) {
1852
+ await forceStopAndDiscardRecording(tabId);
1853
+ return { ok: true };
1854
+ }
1855
+ /** Stop (if still actively recording) AND discard in one call — the
1856
+ * privacy-critical path used when the flow-recording setting flips off
1857
+ * while a recording is in progress. Never leaves captured-but-unreviewed
1858
+ * steps sitting in memory once the user has turned the feature off. */
1859
+ async function forceStopAndDiscardRecording(tabId) {
1860
+ const state = tabStates.get(tabId);
1861
+ if (!state?.recording)
1862
+ return;
1863
+ if (state.recording.status === 'recording') {
1864
+ await finishRecording(tabId);
1865
+ }
1866
+ clearRecording(tabId);
1867
+ }
1868
+ function getRecordingStatus(tabId) {
1869
+ const recording = tabStates.get(tabId)?.recording;
1870
+ if (!recording)
1871
+ return { active: false, reviewing: false, stepCount: 0, capped: false };
1872
+ return {
1873
+ active: recording.status === 'recording',
1874
+ reviewing: recording.status === 'reviewing',
1875
+ stepCount: recording.steps.length,
1876
+ capped: recording.capped,
1877
+ steps: recording.status === 'reviewing' ? recording.steps : undefined,
1878
+ ambiguousStepIndices: recording.status === 'reviewing' ? recording.ambiguousStepIndices : undefined,
1879
+ };
1880
+ }
1881
+ /** Send the reviewed recording to the server for validation + persistence
1882
+ * (`flow.recorded` -> `flow.recorded.result`, see messages.ts). Clears the
1883
+ * recording state on success only — a validation failure (e.g. an
1884
+ * over-budget wait_for) leaves the steps in place so the user can edit the
1885
+ * name/description and retry rather than losing the capture. */
1886
+ async function saveRecording(tabId, name, description) {
1887
+ const state = tabStates.get(tabId);
1888
+ const recording = state?.recording;
1889
+ if (!state || !recording || recording.status !== 'reviewing') {
1890
+ return { ok: false, error: 'No reviewed recording to save for this tab.' };
1891
+ }
1892
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN || !state.serverTabId) {
1893
+ return { ok: false, error: 'Not connected to the server.' };
1894
+ }
1895
+ const trimmedName = name.trim();
1896
+ if (trimmedName.length === 0)
1897
+ return { ok: false, error: 'Name is required.' };
1898
+ if (recording.steps.length === 0)
1899
+ return { ok: false, error: 'No steps were captured.' };
1900
+ // Fold the ambiguous-selector warning into the saved recipe's
1901
+ // description so the computed safety signal travels WITH the recipe, not
1902
+ // just in the transient popup review — an agent (or a human) that later
1903
+ // recalls this flow sees the caution too.
1904
+ const ambiguousNote = buildAmbiguousNote(recording.ambiguousStepIndices);
1905
+ const userDescription = description?.trim() ? description.trim() : undefined;
1906
+ const finalDescription = ambiguousNote && userDescription
1907
+ ? `${userDescription}\n\n${ambiguousNote}`
1908
+ : (ambiguousNote ?? userDescription);
1909
+ const ws = state.ws;
1910
+ const serverTabId = state.serverTabId;
1911
+ const result = await new Promise((resolve) => {
1912
+ let settled = false;
1913
+ const settle = (r) => {
1914
+ if (settled)
1915
+ return;
1916
+ settled = true;
1917
+ state.pendingFlowSave = undefined;
1918
+ resolve(r);
1919
+ };
1920
+ state.pendingFlowSave = settle;
1921
+ send(ws, {
1922
+ kind: 'flow.recorded',
1923
+ payload: {
1924
+ tab_id: serverTabId,
1925
+ origin: originOfUrl(recording.lastUrl),
1926
+ name: trimmedName,
1927
+ description: finalDescription,
1928
+ steps: recording.steps,
1929
+ },
1930
+ });
1931
+ // Best-effort timeout so the popup never hangs forever if the WS drops
1932
+ // mid-request without a clean close event.
1933
+ setTimeout(() => {
1934
+ settle({ ok: false, error: 'Timed out waiting for the server.' });
1935
+ }, 10_000);
1936
+ });
1937
+ if (result.ok)
1938
+ clearRecording(tabId);
1939
+ return result;
1940
+ }
1538
1941
  async function respondToDialogFromPopup(tabId, accept, promptText) {
1539
1942
  try {
1540
1943
  const params = { accept };
@@ -1571,6 +1974,26 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
1571
1974
  sendResponse(getVersionCheck());
1572
1975
  return false;
1573
1976
  }
1977
+ if (msg.action === 'recordingStatus') {
1978
+ sendResponse(getRecordingStatus(msg.tabId));
1979
+ return false;
1980
+ }
1981
+ if (msg.action === 'startRecording') {
1982
+ void startRecording(msg.tabId).then(sendResponse);
1983
+ return true;
1984
+ }
1985
+ if (msg.action === 'stopRecording') {
1986
+ void stopRecording(msg.tabId).then(sendResponse);
1987
+ return true;
1988
+ }
1989
+ if (msg.action === 'discardRecording') {
1990
+ void discardRecording(msg.tabId).then(sendResponse);
1991
+ return true;
1992
+ }
1993
+ if (msg.action === 'saveRecording') {
1994
+ void saveRecording(msg.tabId, msg.name, msg.description).then(sendResponse);
1995
+ return true;
1996
+ }
1574
1997
  // 'status'
1575
1998
  sendResponse(getTabStatus(msg.tabId));
1576
1999
  return false;