@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
|
@@ -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;
|