/* eslint-disable */ // recorder.js - content script for recording user interactions into steps (function () { if (window.__RR_RECORDER_INSTALLED__) return; window.__RR_RECORDER_INSTALLED__ = true; // ================================================================ // 1) CONFIG + STATELESS HELPERS (namespaced) // ================================================================ const CONFIG = { // Increase debounce to improve step merging for slow/DOM-replacing inputs INPUT_DEBOUNCE_MS: 800, BATCH_SEND_MS: 100, SCROLL_DEBOUNCE_MS: 350, SENSITIVE_INPUT_TYPES: new Set(['password']), UI_MAX_STEPS: 30, // Maximum time to hold flush while user is typing (prevents unbounded batch accumulation) MAX_TYPING_HOLD_MS: 1500, }; // Cross-frame event channel const FRAME_EVENT = 'rr_iframe_event'; // Memoization caches for selector computations during recording const __cacheUnique = new WeakMap(); const __cachePath = new WeakMap(); const SelectorEngine = { buildTarget(el) { const candidates = []; const attrNames = ['data-testid', 'data-testId', 'data-test', 'data-qa', 'data-cy']; for (const an of attrNames) { const v = el.getAttribute && el.getAttribute(an); if (v) candidates.push({ type: 'attr', value: `[${an}="${CSS.escape(v)}"]` }); } const classSel = this._uniqueClassSelector(el); if (classSel) candidates.push({ type: 'css', value: classSel }); const css = this._generateSelector(el); if (css) candidates.push({ type: 'css', value: css }); const name = el.getAttribute && el.getAttribute('name'); if (name) candidates.push({ type: 'attr', value: `[name="${CSS.escape(name)}"]` }); const title = el.getAttribute && el.getAttribute('title'); if (title) candidates.push({ type: 'attr', value: `[title="${CSS.escape(title)}"]` }); const alt = el.getAttribute && el.getAttribute('alt'); if (alt) candidates.push({ type: 'attr', value: `[alt="${CSS.escape(alt)}"]` }); const aria = el.getAttribute && el.getAttribute('aria-label'); const role = el.getAttribute && el.getAttribute('role'); if (aria) { if (role) candidates.push({ type: 'aria', value: `${role}[name=${aria}]` }); else candidates.push({ type: 'aria', value: `textbox[name=${aria}]` }); } const tag = el.tagName?.toLowerCase?.() || ''; if (['button', 'a', 'summary'].includes(tag)) { const text = (el.textContent || '').trim(); if (text) candidates.push({ type: 'text', value: text.substring(0, 64) }); } const selector = SelectorEngine._choosePrimary(el, candidates); return { selector, candidates, tag }; }, _choosePrimary(el, candidates) { if (el.id && document.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1) { return `#${CSS.escape(el.id)}`; } const priority = ['attr', 'css']; for (const p of priority) { const c = candidates.find((c) => c.type === p); if (c) { try { const tag = el.tagName ? el.tagName.toLowerCase() : ''; if (p === 'attr' && (tag === 'input' || tag === 'textarea' || tag === 'select')) { const val = String(c.value || '').trim(); if (val.startsWith('[')) return `${tag}${val}`; } } catch {} return c.value; } } if (candidates.length) return candidates[0].value; return SelectorEngine._generateSelector(el) || ''; }, _uniqueClassSelector(el) { if (__cacheUnique.has(el)) return __cacheUnique.get(el); let result = ''; try { const classes = Array.from(el.classList || []).filter( (c) => c && /^[a-zA-Z0-9_-]+$/.test(c), ); for (const cls of classes) { const sel = `.${CSS.escape(cls)}`; if (document.querySelectorAll(sel).length === 1) { result = sel; break; } } if (!result) { const tag = el.tagName ? el.tagName.toLowerCase() : ''; for (const cls of classes) { const sel = `${tag}.${CSS.escape(cls)}`; if (document.querySelectorAll(sel).length === 1) { result = sel; break; } } } if (!result) { for (let i = 0; i < Math.min(classes.length, 3) && !result; i++) { for (let j = i + 1; j < Math.min(classes.length, 3); j++) { const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`; if (document.querySelectorAll(sel).length === 1) { result = sel; break; } } } } } catch {} __cacheUnique.set(el, result); return result; }, _generateSelector(el) { if (!(el instanceof Element)) return ''; if (__cachePath.has(el)) return __cachePath.get(el); if (el.id) { const idSel = `#${CSS.escape(el.id)}`; if (document.querySelectorAll(idSel).length === 1) return idSel; } for (const attr of ['data-testid', 'data-cy', 'name']) { const attrValue = el.getAttribute(attr); if (attrValue) { const s = `[${attr}="${CSS.escape(attrValue)}"]`; if (document.querySelectorAll(s).length === 1) return s; } } let path = ''; let current = el; while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') { let selector = current.tagName.toLowerCase(); const parent = current.parentElement; if (parent) { const siblings = Array.from(parent.children).filter( (child) => child.tagName === current.tagName, ); if (siblings.length > 1) { const index = siblings.indexOf(current) + 1; selector += `:nth-of-type(${index})`; } } path = path ? `${selector} > ${path}` : selector; current = parent; } const res = path ? `body > ${path}` : 'body'; __cachePath.set(el, res); return res; }, }; // Extend SelectorEngine with a shared ref helper (attached after declaration) SelectorEngine._ensureGlobalRef = function (el) { try { if (!window.__claudeElementMap) window.__claudeElementMap = {}; if (!window.__claudeRefCounter) window.__claudeRefCounter = 0; for (const k in window.__claudeElementMap) { const w = window.__claudeElementMap[k]; if (w && typeof w.deref === 'function' && w.deref() === el) return k; } const id = `ref_${++window.__claudeRefCounter}`; window.__claudeElementMap[id] = new WeakRef(el); return id; } catch { return null; } }; // ================================================================ // 2) UI CLASS (injected via constructor) // ================================================================ class UI { constructor(recorder) { this.recorder = recorder; this._box = null; // Timeline elements state this._timeline = null; this._count = 0; this._timelineBox = null; this._collapsed = false; } ensure() { const rec = this.recorder; if (window !== window.top) return; let root = document.getElementById('__rr_rec_overlay'); if (root) return; root = document.createElement('div'); root.id = '__rr_rec_overlay'; Object.assign(root.style, { position: 'fixed', top: '10px', right: '10px', zIndex: 2147483646, fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial', }); root.innerHTML = `
录制中
`; document.documentElement.appendChild(root); // Build timeline container just below the panel const timeline = document.createElement('div'); timeline.id = '__rr_rec_timeline'; Object.assign(timeline.style, { marginTop: '8px', width: '360px', maxHeight: '220px', overflow: 'auto', background: 'rgba(17,24,39,0.85)', color: '#F9FAFB', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '8px', boxShadow: '0 4px 16px rgba(0,0,0,0.18)', padding: '8px 10px', fontSize: '12px', lineHeight: '1.4', }); const header = document.createElement('div'); header.textContent = '已录制步骤'; header.style.opacity = '0.8'; header.style.marginBottom = '4px'; const list = document.createElement('ol'); list.id = '__rr_rec_timeline_list'; list.style.listStyle = 'none'; list.style.margin = '0'; list.style.padding = '0'; list.style.display = 'flex'; list.style.flexDirection = 'column'; list.style.gap = '4px'; timeline.appendChild(header); timeline.appendChild(list); root.appendChild(timeline); this._timeline = list; this._timelineBox = timeline; const btnPause = root.querySelector('#__rr_pause'); const btnStop = root.querySelector('#__rr_stop'); const hideChk = root.querySelector('#__rr_hide_values'); const highlightChk = root.querySelector('#__rr_enable_highlight'); const btnToggle = root.querySelector('#__rr_toggle_timeline'); hideChk.checked = !!rec.hideInputValues; hideChk.addEventListener('change', () => (rec.hideInputValues = hideChk.checked)); highlightChk.checked = !!rec.highlightEnabled; highlightChk.addEventListener('change', () => { rec.highlightEnabled = !!highlightChk.checked; rec._updateHoverListener(); }); if (btnToggle) { btnToggle.addEventListener('click', () => { this._collapsed = !this._collapsed; if (this._timelineBox) this._timelineBox.style.display = this._collapsed ? 'none' : 'block'; btnToggle.textContent = this._collapsed ? '展开' : '折叠'; }); } btnPause.addEventListener('click', () => { if (!rec.isPaused) rec.pause(); else rec.resume(); }); btnStop.addEventListener('click', () => { chrome.runtime.sendMessage({ type: 'rr_stop_recording' }); }); this._box = document.createElement('div'); Object.assign(this._box.style, { position: 'fixed', border: '2px solid rgba(59,130,246,0.9)', borderRadius: '4px', background: 'rgba(59,130,246,0.15)', pointerEvents: 'none', zIndex: 2147483645, }); document.documentElement.appendChild(this._box); if (rec.highlightEnabled) document.addEventListener('mousemove', rec._onMouseMove, { capture: true, passive: true }); this.updateStatus(); } remove() { if (window === window.top) { const root = document.getElementById('__rr_rec_overlay'); if (root) root.remove(); if (this._box) this._box.remove(); this._timeline = null; this._timelineBox = null; } } updateStatus() { const badge = document.getElementById('__rr_badge'); const pauseBtn = document.getElementById('__rr_pause'); if (badge) badge.textContent = this.recorder.isPaused ? '已暂停' : '录制中'; if (pauseBtn) pauseBtn.textContent = this.recorder.isPaused ? '继续' : '暂停'; } // Reset the timeline list content resetTimeline() { this._count = 0; const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; if (list) list.innerHTML = ''; } // Append a new recorded step into the timeline UI appendStep(step) { const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; if (!list) return; this._count += 1; const item = document.createElement('li'); const text = this._formatStepText(step, this._count); item.setAttribute('data-step-id', step.id || ''); item.style.display = 'flex'; item.style.alignItems = 'flex-start'; item.style.gap = '6px'; item.innerHTML = ` ${this._count}. ${text} `; list.appendChild(item); while (list.children.length > CONFIG.UI_MAX_STEPS) { list.removeChild(list.firstChild); } const container = list.parentElement; if (container) container.scrollTop = container.scrollHeight; } /** * Apply a full timeline update from background. * Steps can be upserted in place (same id, updated fields) during fill debouncing. * Uses smart diffing to minimize DOM operations while ensuring fill values are accurate. */ applyTimelineUpdate(steps) { try { if (window !== window.top) return; const list = Array.isArray(steps) ? steps : []; const total = list.length; // Ensure UI exists if (!this._timeline) this.ensure(); if (!this._timeline) return; if (total === 0) { this.resetTimeline(); return; } // Calculate the window of steps to display (last N steps) const windowStart = Math.max(0, total - CONFIG.UI_MAX_STEPS); const windowSteps = list.slice(windowStart); // Get current displayed step IDs const currentItems = this._timeline.children; const currentIds = []; for (let i = 0; i < currentItems.length; i++) { currentIds.push(currentItems[i].getAttribute('data-step-id') || ''); } // Check if we need a full rebuild or can do incremental update const newIds = windowSteps.map((s) => s.id || ''); const needsRebuild = currentIds.length !== newIds.length || currentIds.some((id, i) => id !== newIds[i]); if (needsRebuild) { // Full rebuild: either structure changed or it's simpler to rebuild this.resetTimeline(); for (let i = 0; i < windowSteps.length; i++) { this._appendStepWithIndex(windowSteps[i], windowStart + i + 1); } } else { // Incremental update: same steps, just update values for (let i = 0; i < windowSteps.length; i++) { const step = windowSteps[i]; const item = currentItems[i]; if (item) { // Update the text content for this step const textSpan = item.querySelector('span:last-child'); if (textSpan) { const newText = this._formatStepText(step, windowStart + i + 1); if (textSpan.textContent !== newText) { textSpan.textContent = newText; } } } } } this._count = total; } catch {} } /** * Internal method to append a step with a specific display index. * Used by applyTimelineUpdate for proper numbering. */ _appendStepWithIndex(step, displayIndex) { const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; if (!list) return; const item = document.createElement('li'); const text = this._formatStepText(step, displayIndex); item.setAttribute('data-step-id', step.id || ''); item.style.display = 'flex'; item.style.alignItems = 'flex-start'; item.style.gap = '6px'; item.innerHTML = ` ${displayIndex}. ${text} `; list.appendChild(item); const container = list.parentElement; if (container) container.scrollTop = container.scrollHeight; } // Create a short, human-readable text for a recorded step _formatStepText(step, _idx) { try { if (!step || typeof step !== 'object') return '未知步骤'; const t = step.type; const sel = step.target && step.target.selector ? step.target.selector : ''; if (t === 'click' || t === 'dblclick') { return `${t === 'dblclick' ? '双击' : '点击'}: ${sel || '(document)'}`; } if (t === 'fill') { const val = step.value; const shown = typeof val === 'string' && val.length > 0 ? val : String(val); return `输入: ${sel} = ${shown}`; } if (t === 'scroll') { const mode = step.mode === 'container' ? '容器' : '页面'; const off = step.offset || {}; return `滚动(${mode}): y=${off.y ?? 0}, x=${off.x ?? 0}`; } if (t === 'openTab') return `打开标签页: ${step.url || ''}`; if (t === 'switchTab') return `切换标签页: 包含 ${step.urlContains || ''}`; if (t === 'switchFrame') return `切换Frame: 包含 ${step.frame && step.frame.urlContains ? step.frame.urlContains : ''}`; if (t === 'waitFor') return `等待: ${sel || step.until || ''}`; return `${t}`; } catch (_) { return '步骤'; } } } // ================================================================ // 3) MAIN CLASS: ContentRecorder (stateful) // ================================================================ class ContentRecorder { constructor() { // State this.isRecording = false; this.isPaused = false; this.hideInputValues = false; this.highlightEnabled = true; this.hoverRAF = 0; this.frameSwitchPushed = false; this.batch = []; this.batchTimer = null; this.scrollTimer = null; // Local, content-side buffer for batching/merging steps during recording. // Not the authoritative Flow (background holds the real one). this.sessionBuffer = this._createSessionBuffer(); // lastFill tracks the most recent fill step for debounce/merge // el: DOM element reference for reading final value on finalize this.lastFill = { step: null, ts: 0, el: null }; // Input activity tracking for flush gate (separate from merge state) // Updated by both local input and iframe upsert messages this._lastInputActivityTs = 0; // Flush gate: tracks when a typing burst started to enforce MAX_TYPING_HOLD_MS this._typingBurstStartTs = 0; // Force flush timer: ensures MAX_TYPING_HOLD_MS is a hard upper bound // This timer is NOT reset on each input, only cleared on actual flush this._forceFlushTimer = null; // Recording-time element identity map (not persisted) this.el2ref = new WeakMap(); this.refCounter = 0; // Bind handlers this._onClick = this._onClick.bind(this); this._onInput = this._onInput.bind(this); this._onDocInput = this._onDocInput.bind(this); this._onChange = this._onChange.bind(this); this._onMouseMove = this._onMouseMove.bind(this); this._onScroll = this._onScroll.bind(this); this._onFocusIn = this._onFocusIn.bind(this); this._onFocusOut = this._onFocusOut.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onKeyUp = this._onKeyUp.bind(this); this._onWindowMessage = this._onWindowMessage.bind(this); // Page lifecycle handlers for best-effort flush on navigation/close this._onPageHide = this._onPageHide.bind(this); this._onVisibilityChange = this._onVisibilityChange.bind(this); this.ui = new UI(this); this._scrollPending = null; // Focus tracking for per-element input listening this._focusedEl = null; // Keyboard state for combo recording this._pressed = new Set(); this._lastKeyTs = 0; // Map to avoid duplicate switchFrame per iframe source (keyed by frame selector) this._frameSwitchMap = new Set(); } // Lifecycle start(flowMeta) { // Idempotent start: if already recording (and not paused), just ensure UI and listeners if (this.isRecording && !this.isPaused) { this.ui.ensure(); this._updateHoverListener(); return; } // If paused, treat start as resume to avoid resetting local buffer/UI timeline if (this.isPaused) { this.resume(); return; } this._reset(flowMeta || {}); this.isRecording = true; this.isPaused = false; this._attach(); this.ui.ensure(); this.ui.resetTimeline(); } /** * Stop recording and flush all pending data. * This is the reliable stop that ensures no data is lost. * Waits for background to acknowledge receipt of all data before returning. * @returns {Promise<{ack: boolean, steps: number, variables: number}>} */ async stop() { if (!this.isRecording) { return { ack: true, steps: 0, variables: 0 }; } this.isRecording = false; // Stop should clear paused state so detach fully cleans up (and barrier works consistently) this.isPaused = false; // Step 1: Finalize pending click (dblclick detector) this._finalizePendingClick(); // Step 2: Finalize any pending input (draft mode) this._finalizePendingInput(); // Step 3: Finalize any pending scroll this._finalizePendingScroll(); // Step 4: In iframes, ensure the top-frame aggregator has processed our final postMessages // before we ACK the background stop (prevents missing iframe steps) let topSyncOk = true; if (window !== window.top) { topSyncOk = await this._syncStopBarrierToTop(); } // Step 5: Clear timers BEFORE flush (prevent race conditions) if (this.batchTimer) clearTimeout(this.batchTimer); this.batchTimer = null; if (this.scrollTimer) clearTimeout(this.scrollTimer); this.scrollTimer = null; if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF); this.hoverRAF = 0; // Step 6: Flush any remaining batched steps and WAIT for ack const stepsCount = this.batch.length; let stepsAck = true; if (stepsCount > 0) { stepsAck = await this._flush(); } // Step 7: Send all collected variables and WAIT for ack const variablesCount = this.sessionBuffer.variables?.length || 0; let variablesAck = true; if (variablesCount > 0) { variablesAck = await this._sendVariables(); } // Step 8: Detach listeners and clean up UI this._detach(); this.ui.remove(); // Step 9: Reset state this.lastFill = { step: null, ts: 0, el: null }; this._lastInputActivityTs = 0; this._typingBurstStartTs = 0; if (this._forceFlushTimer) { clearTimeout(this._forceFlushTimer); this._forceFlushTimer = null; } this.sessionBuffer.steps = []; // Return acknowledgment with stats // ack is true only if all sends were acknowledged return { ack: stepsAck && variablesAck && topSyncOk, steps: stepsCount, variables: variablesCount, }; } /** * Finalize a pending click that hasn't been emitted yet. * The dblclick detector holds single clicks temporarily to detect double-clicks. * This ensures stop/pause flush includes the last single click. */ _finalizePendingClick() { try { if (this._pendingClickTimer) clearTimeout(this._pendingClickTimer); } catch {} this._pendingClickTimer = null; try { if (this._pendingClick) this._pushStep(this._pendingClick); } catch {} this._pendingClick = null; } /** * Finalize any pending input that hasn't been flushed yet. * This ensures the last input value is captured before stop/pause/navigation. * Uses lastFill.el (DOM reference) to read the current value. */ _finalizePendingInput() { const last = this.lastFill; if (!last || !last.step) return; // Commit the latest value from the DOM element try { const el = last.el; if (el) { const freshValue = this._getElementValue(el, last.step.value); if (freshValue !== last.step.value) { last.step.value = freshValue; this.sessionBuffer.meta.updatedAt = new Date().toISOString(); } } } catch { // Element may no longer exist, that's OK - we keep the last known value } // Enqueue for upsert to ensure background gets the final value try { this._enqueueForUpsert(last.step); } catch {} // Reset state this.lastFill = { step: null, ts: 0, el: null }; this._typingBurstStartTs = 0; } /** * Get the current value from an element, handling sensitive fields and contenteditable. * @param {Element} el - The element to read from * @param {string} existingValue - The existing recorded value (may be a variable placeholder) * @returns {string} The value to record */ _getElementValue(el, existingValue) { if (!el) return existingValue || ''; const isContentEditable = el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true; // If existing value is already a variable placeholder, preserve it // Use strict pattern to avoid false positives for user input like "{abc}" const existing = typeof existingValue === 'string' ? existingValue : ''; const varPlaceholderPattern = /^\{(?:var_[a-z0-9]{4}|file_[a-z0-9]{4}|[a-zA-Z_][a-zA-Z0-9_]*)\}$/; if (varPlaceholderPattern.test(existing)) { return existing; } // Check if this is a sensitive field const isSensitive = this.hideInputValues || (!isContentEditable && CONFIG.SENSITIVE_INPUT_TYPES.has( ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(), )); if (isSensitive) { // Return existing variable or create new one (should already exist from initial capture) return existing; } // Read fresh value from DOM try { if (isContentEditable) { return /** @type {HTMLElement} */ (el).innerText || ''; } if ( el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement ) { return el.value || ''; } } catch {} return existing || ''; } /** * Finalize any pending scroll that hasn't been committed yet. * Converts the pending scroll data into a proper scroll step. */ _finalizePendingScroll() { if (!this._scrollPending) return; const pending = this._scrollPending; this._scrollPending = null; const { isDoc, target, top, left } = pending; // Try merge with last step (same logic as _onScroll timer callback) const steps = this.sessionBuffer.steps; const last = steps.length ? steps[steps.length - 1] : null; if (last && last.type === 'scroll') { const sameDoc = isDoc && !last.target && last.mode === 'offset'; const sameEl = !isDoc && last.target && last.target.selector && target && last.target.selector === target.selector && last.mode === 'container'; if (sameDoc || sameEl) { last.offset = { y: top, x: left }; this.sessionBuffer.meta.updatedAt = new Date().toISOString(); return; } } // Create new scroll step if (isDoc) { this._pushStep({ type: 'scroll', mode: 'offset', offset: { y: top, x: left }, screenshotOnFail: false, }); } else { this._pushStep({ type: 'scroll', mode: 'container', target: target, offset: { y: top, x: left }, screenshotOnFail: false, }); } } /** * Send all collected variables to background. * @returns {Promise} - Resolves when background acknowledges receipt */ async _sendVariables() { if (!this.sessionBuffer.variables || this.sessionBuffer.variables.length === 0) { return true; } return this._send({ kind: 'variables', variables: this.sessionBuffer.variables }); } /** * Pause recording. Flushes pending data before pausing. */ pause() { if (!this.isRecording || this.isPaused) return; // Finalize pending data before pausing this._finalizePendingClick(); this._finalizePendingInput(); this._finalizePendingScroll(); // Flush batched steps if (this.batch.length > 0) { this._flush(); } // Clear timers if (this.batchTimer) clearTimeout(this.batchTimer); this.batchTimer = null; if (this.scrollTimer) clearTimeout(this.scrollTimer); this.scrollTimer = null; this.isPaused = true; this._detach(); this.ui.updateStatus(); } /** * Resume recording after pause. */ resume() { if (!this.isPaused) return; this.isRecording = true; this.isPaused = false; this._attach(); this.ui.ensure(); this.ui.updateStatus(); } // DOM listeners _attach() { document.addEventListener('click', this._onClick, true); // Use focusin/out to attach input listener only to focused element document.addEventListener('focusin', this._onFocusIn, true); document.addEventListener('focusout', this._onFocusOut, true); // Document-level input capture to support Shadow DOM (custom elements) // Use capture phase + composedPath to find inner editable control document.addEventListener('input', this._onDocInput, true); document.addEventListener('change', this._onChange, true); // capture-phase scroll to catch non-bubbling events on any container (passive to avoid jank) document.addEventListener('scroll', this._onScroll, { capture: true, passive: true }); // Keyboard: record Enter and modifier combos document.addEventListener('keydown', this._onKeyDown, true); document.addEventListener('keyup', this._onKeyUp, true); // Page lifecycle: best-effort flush on navigation/close window.addEventListener('pagehide', this._onPageHide, true); document.addEventListener('visibilitychange', this._onVisibilityChange, true); // Cross-frame: top window aggregates iframe-recorded steps if (window === window.top) window.addEventListener('message', this._onWindowMessage, true); this._updateHoverListener(); } _detach() { document.removeEventListener('click', this._onClick, true); document.removeEventListener('focusin', this._onFocusIn, true); document.removeEventListener('focusout', this._onFocusOut, true); document.removeEventListener('input', this._onDocInput, true); document.removeEventListener('change', this._onChange, true); document.removeEventListener('scroll', this._onScroll, { capture: true }); document.removeEventListener('keydown', this._onKeyDown, true); document.removeEventListener('keyup', this._onKeyUp, true); window.removeEventListener('pagehide', this._onPageHide, true); document.removeEventListener('visibilitychange', this._onVisibilityChange, true); document.removeEventListener('mousemove', this._onMouseMove, { capture: true }); // Keep top-frame aggregator alive during pause; stop() clears isPaused and will remove it if (window === window.top && !this.isPaused) window.removeEventListener('message', this._onWindowMessage, true); // Detach per-element input listener if any if (this._focusedEl) this._focusedEl.removeEventListener('input', this._onInput, true); this._focusedEl = null; // Best-effort cleanup for timers/raf when detaching if (this.batchTimer) clearTimeout(this.batchTimer); this.batchTimer = null; if (this.scrollTimer) clearTimeout(this.scrollTimer); this.scrollTimer = null; if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF); this.hoverRAF = 0; // Clear pending click state (stop/pause flush it before detach) if (this._pendingClickTimer) { clearTimeout(this._pendingClickTimer); } this._pendingClickTimer = null; this._pendingClick = null; } _updateHoverListener() { if (window !== window.top) return; document.removeEventListener('mousemove', this._onMouseMove, { capture: true }); if (this.isRecording && !this.isPaused && this.highlightEnabled) { document.addEventListener('mousemove', this._onMouseMove, { capture: true, passive: true }); } } // Flow helpers (content-side buffer only) _createSessionBuffer() { const nowIso = new Date().toISOString(); return { id: `flow_${Date.now()}`, name: '未命名录制', version: 1, steps: [], variables: [], meta: { createdAt: nowIso, updatedAt: nowIso }, }; } _reset(meta) { this.sessionBuffer = this._createSessionBuffer(); try { if (meta && typeof meta === 'object') { if (meta.id) this.sessionBuffer.id = String(meta.id); if (meta.name) this.sessionBuffer.name = String(meta.name); if (meta.description) this.sessionBuffer.description = String(meta.description); } } catch {} this.lastFill = { step: null, ts: 0, el: null }; this._lastInputActivityTs = 0; this._typingBurstStartTs = 0; if (this._forceFlushTimer) { clearTimeout(this._forceFlushTimer); this._forceFlushTimer = null; } this.frameSwitchPushed = false; } /** * Update input activity timestamp (used for flush gate). * Called on local input and iframe upsert messages. */ _updateInputActivity() { const now = Date.now(); const prevActivityTs = this._lastInputActivityTs || 0; this._lastInputActivityTs = now; // Start a new burst if previous one expired (or this is first input) if (!this._typingBurstStartTs || now - prevActivityTs > CONFIG.INPUT_DEBOUNCE_MS) { this._typingBurstStartTs = now; // Start force flush timer (hard upper bound for MAX_TYPING_HOLD_MS) this._startForceFlushTimer(); } } /** * Start the force flush timer. * This timer ensures MAX_TYPING_HOLD_MS is a hard upper bound. * Unlike batchTimer, this timer is NOT reset on each input. */ _startForceFlushTimer() { // Don't restart if already running if (this._forceFlushTimer) return; this._forceFlushTimer = setTimeout(() => { this._forceFlushTimer = null; // Force flush regardless of current input state if (this.batch.length > 0) { this._flush(); } }, CONFIG.MAX_TYPING_HOLD_MS); } /** * Clear the force flush timer (called on actual flush). */ _clearForceFlushTimer() { if (this._forceFlushTimer) { clearTimeout(this._forceFlushTimer); this._forceFlushTimer = null; } this._typingBurstStartTs = 0; } /** * Unified commit and flush logic. * Called at commit points: focusout, Enter key, pagehide, visibilitychange. * @param {Object} options * @param {boolean} [options.bestEffort=false] - If true, don't await (for unload events) */ _commitAndFlush(options = {}) { if (!this.isRecording || this.isPaused) return; try { this._finalizePendingInput(); this._finalizePendingScroll(); } catch {} // Reset flush gate to allow immediate flush this._lastInputActivityTs = 0; this._typingBurstStartTs = 0; this._clearForceFlushTimer(); // Flush (best-effort for unload events) try { if (this.batch.length > 0) this._flush(); } catch {} try { const variablesCount = this.sessionBuffer.variables?.length || 0; if (variablesCount > 0) this._sendVariables(); } catch {} // If in iframe, ask top to flush too this._requestTopFlush(); } _pushStep(step) { step.id = step.id || `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; // In iframes, forward to top for aggregation (compute frame selector there) if (window !== window.top) { try { const payload = { kind: 'iframeStep', href: String(location && location.href ? location.href : ''), step, }; window.top.postMessage({ type: FRAME_EVENT, payload }, '*'); return; // Do not push locally in subframe } catch {} } // Top window: optionally insert a switchFrame if this step originated from an iframe message this.sessionBuffer.steps.push(step); this.sessionBuffer.meta.updatedAt = new Date().toISOString(); this.batch.push(step); // Track input activity for fill steps (to enforce flush gate) if (step && step.type === 'fill') { this._updateInputActivity(); } this._scheduleFlush(); } /** * Calculate the appropriate flush delay based on typing activity. * During active typing, delay flush to avoid sending incomplete values. * Note: MAX_TYPING_HOLD_MS is enforced by _forceFlushTimer, not here. * @returns {number} Delay in milliseconds before next flush */ _getFlushDelayMs() { const now = Date.now(); const lastInputTs = this._lastInputActivityTs || 0; // If no recent input activity, use default batch delay if (!lastInputTs || now - lastInputTs >= CONFIG.INPUT_DEBOUNCE_MS) { return CONFIG.BATCH_SEND_MS; } // Wait for input debounce to complete const notBefore = lastInputTs + CONFIG.INPUT_DEBOUNCE_MS; const delay = Math.max(CONFIG.BATCH_SEND_MS, notBefore - now); return delay; } /** * Schedule a batch flush with appropriate delay. * Respects typing gate to avoid flushing incomplete fill values. */ _scheduleFlush() { if (this.batchTimer) { clearTimeout(this.batchTimer); } const delay = this._getFlushDelayMs(); this.batchTimer = setTimeout(() => { this.batchTimer = null; this._flush(); }, delay); } /** * Request top frame to immediately flush its aggregated buffer. * Used by iframes on commit points (focusout, navigation) to ensure * their updates are sent to background promptly. */ _requestTopFlush() { if (window === window.top) return; try { const payload = { kind: 'iframeFlush', href: String(location && location.href ? location.href : ''), }; window.top.postMessage({ type: FRAME_EVENT, payload }, '*'); } catch {} } /** * Iframe -> top stop barrier sync. * Ensures the top frame has processed all prior iframe postMessages (steps/upserts) * before this iframe responds to background STOP. * @returns {Promise} */ _syncStopBarrierToTop() { if (window === window.top) return Promise.resolve(true); const id = `sb_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const href = String(location && location.href ? location.href : ''); const timeoutMs = 400; return new Promise((resolve) => { let done = false; const cleanup = (ok) => { if (done) return; done = true; try { window.removeEventListener('message', onMessage, true); } catch {} try { clearTimeout(t); } catch {} resolve(!!ok); }; const onMessage = (ev) => { try { if (ev.source !== window.top) return; const d = ev && ev.data; if (!d || d.type !== FRAME_EVENT || !d.payload) return; const p = d.payload || {}; if (p.kind !== 'iframeStopBarrierAck' || p.id !== id) return; cleanup(true); } catch {} }; const t = setTimeout(() => cleanup(false), timeoutMs); try { window.addEventListener('message', onMessage, true); window.top.postMessage( { type: FRAME_EVENT, payload: { kind: 'iframeStopBarrier', id, href } }, '*', ); } catch { cleanup(false); } }); } /** * Best-effort drain and flush on page navigation/close. * Called by pagehide/visibilitychange handlers. * Does not await - unload events are time-constrained. */ _bestEffortDrainAndFlush() { if (!this.isRecording || this.isPaused) return; // Flush pending single click (dblclick detector) before we may be unloaded this._finalizePendingClick(); // Cancel timers (unload may not wait for them) try { if (this.batchTimer) clearTimeout(this.batchTimer); this.batchTimer = null; if (this.scrollTimer) clearTimeout(this.scrollTimer); this.scrollTimer = null; } catch {} // Use unified commit and flush this._commitAndFlush({ bestEffort: true }); } /** * Handle pagehide event - best-effort flush before navigation/close. */ _onPageHide() { this._bestEffortDrainAndFlush(); } /** * Handle visibilitychange event - flush when page becomes hidden. * This catches some cases that pagehide misses (e.g., tab switch before navigation). */ _onVisibilityChange() { try { if (document.visibilityState === 'hidden') { this._bestEffortDrainAndFlush(); } } catch {} } /** * Flush batched steps to background. * @returns {Promise} - Resolves when background acknowledges receipt */ async _flush() { if (!this.batch.length) return true; // Clear force flush timer since we're flushing now this._clearForceFlushTimer(); const steps = this.batch.map((s) => { // sanitize internal fields before sending to background const { _recordingRef, ...rest } = s || {}; return rest; }); this.batch.length = 0; return this._send({ kind: 'steps', steps }); } /** * Send payload to background and wait for acknowledgment. * @param {Object} payload - The payload to send * @returns {Promise} - Resolves true if background acknowledged, false otherwise */ _send(payload) { return new Promise((resolve) => { try { chrome.runtime.sendMessage({ type: 'rr_recorder_event', payload }, (response) => { // Check for runtime error (e.g., no receiver) if (chrome.runtime.lastError) { console.warn('Recorder: send failed', chrome.runtime.lastError.message); resolve(false); return; } resolve(response && response.ok); }); } catch (e) { console.warn('Recorder: send exception', e); resolve(false); } }); } _addVariable(key, sensitive, defVal) { if (!this.sessionBuffer.variables) this.sessionBuffer.variables = []; if (this.sessionBuffer.variables.find((v) => v.key === key)) return; this.sessionBuffer.variables.push({ key, sensitive: !!sensitive, default: defVal || '' }); } // Handlers // Pending click state for dblclick detection _pendingClick = null; _pendingClickTimer = null; _DBLCLICK_THRESHOLD_MS = 300; _onClick(e) { if (!this.isRecording || this.isPaused) return; const el = e.target instanceof Element ? e.target : null; if (!el) return; try { if (el instanceof HTMLInputElement) { const t = (el.getAttribute && el.getAttribute('type')) || ''; const tt = String(t).toLowerCase(); if (tt === 'checkbox' || tt === 'radio') return; // avoid duplicate with change } const overlay = document.getElementById('__rr_rec_overlay'); if (overlay && (el === overlay || (el.closest && el.closest('#__rr_rec_overlay')))) return; const a = el.closest && el.closest('a[href]'); const href = a && a.getAttribute && a.getAttribute('href'); const tgt = a && a.getAttribute && a.getAttribute('target'); if (a && href && tgt && tgt.toLowerCase() === '_blank') { try { const abs = new URL(href, location.href).href; this._pushStep({ type: 'openTab', url: abs }); this._pushStep({ type: 'switchTab', urlContains: abs }); return; } catch (_) { this._pushStep({ type: 'openTab', url: href }); this._pushStep({ type: 'switchTab', urlContains: href }); return; } } } catch {} const target = SelectorEngine.buildTarget(el); try { const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el); if (gref) target.ref = gref; } catch {} // Double-click detection: if e.detail >= 2 means this is the second click of a dblclick if (e.detail >= 2) { // Cancel pending single click and record dblclick instead if (this._pendingClickTimer) { clearTimeout(this._pendingClickTimer); this._pendingClickTimer = null; } this._pendingClick = null; this._pushStep({ type: 'dblclick', target, screenshotOnFail: true, }); return; } // Single click: wait briefly to see if it becomes a dblclick // Cancel any previous pending click first if (this._pendingClickTimer) { clearTimeout(this._pendingClickTimer); // Flush previous pending click before starting new one if (this._pendingClick) { this._pushStep(this._pendingClick); } } this._pendingClick = { type: 'click', target, screenshotOnFail: true, }; this._pendingClickTimer = setTimeout(() => { if (this._pendingClick) { this._pushStep(this._pendingClick); this._pendingClick = null; } this._pendingClickTimer = null; }, this._DBLCLICK_THRESHOLD_MS); } // Per-element input handler (attached on focusin for native inputs/textarea/contenteditable) _onInput(e) { if (!this.isRecording || this.isPaused) return; // Avoid mid-composition spam (IME): handle final committed value try { if (e && typeof e.isComposing === 'boolean' && e.isComposing) return; } catch {} const target = e.target; // Support input/textarea and contenteditable elements const el = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target : target && target.nodeType === 1 && /** @type {HTMLElement} */ (target).isContentEditable === true ? /** @type {HTMLElement} */ (target) : null; if (!el) return; this._handleInputForElement(el); } // Document-level input handler: supports composed events from Shadow DOM (custom elements) _onDocInput(e) { if (!this.isRecording || this.isPaused) return; try { if (e && typeof e.isComposing === 'boolean' && e.isComposing) return; } catch {} // Avoid double handling when per-element listener already attached to same element if (this._focusedEl && e.target === this._focusedEl) return; // Find the innermost editable element from composedPath const path = typeof e.composedPath === 'function' ? e.composedPath() : []; let el = null; for (let i = 0; i < path.length; i++) { const n = path[i]; if (n instanceof HTMLInputElement || n instanceof HTMLTextAreaElement) { el = n; break; } // Also check for contenteditable if (n && n.nodeType === 1 && /** @type {HTMLElement} */ (n).isContentEditable === true) { el = /** @type {HTMLElement} */ (n); break; } } // As a fallback, walk down activeElement chain (deep active element via shadow roots) if (!el) { try { let ae = document.activeElement; let guard = 0; while (ae && guard++ < 10) { if (ae instanceof HTMLInputElement || ae instanceof HTMLTextAreaElement) { el = ae; break; } // Check contenteditable in shadow DOM traversal if ( ae && ae.nodeType === 1 && /** @type {HTMLElement} */ (ae).isContentEditable === true ) { el = /** @type {HTMLElement} */ (ae); break; } const anyAe = ae; if (anyAe && anyAe.shadowRoot && anyAe.shadowRoot.activeElement) { ae = anyAe.shadowRoot.activeElement; continue; } break; } } catch {} } if (!el) return; this._handleInputForElement(el); } // Shared input processing logic (debounce/merge/sensitivity) // Uses Draft/Upsert model: updates are re-enqueued to ensure background gets final value _handleInputForElement(el) { try { const t = (el.getAttribute && el.getAttribute('type')) || ''; const tt = String(t).toLowerCase(); if (tt === 'checkbox' || tt === 'radio' || tt === 'file') return; } catch {} const elRef = this._getElRef(el); const target = SelectorEngine.buildTarget(el); // Check if element is contenteditable const isContentEditable = el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true; const isSensitive = this.hideInputValues || (!isContentEditable && CONFIG.SENSITIVE_INPUT_TYPES.has( ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(), )); // Get value: use .value for input/textarea, .innerText for contenteditable let value = isContentEditable ? /** @type {HTMLElement} */ (el).innerText || '' : el.value || ''; if (isSensitive) { const varKey = el.name ? el.name : `var_${Math.random().toString(36).slice(2, 6)}`; this._addVariable(varKey, true, ''); value = `{${varKey}}`; } const nowTs = Date.now(); const last = this.lastFill.step; const sameRef = !!(last && last._recordingRef === elRef); const sameSelector = !!( last && last.target && last.target.selector && target && target.selector && last.target.selector === target.selector ); const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS; if ((sameRef || sameSelector) && within) { // Update existing step's value this.lastFill.step.value = value; this.sessionBuffer.meta.updatedAt = new Date().toISOString(); this.lastFill.ts = nowTs; this.lastFill.el = el; // Keep DOM reference updated for finalize // Keep flush gate aligned to the latest keystroke this._updateInputActivity(); // Re-enqueue the updated step for upsert (ensures background gets final value) this._enqueueForUpsert(this.lastFill.step); return; } const newStep = { type: 'fill', target, value, screenshotOnFail: true }; newStep._recordingRef = elRef; this._pushStep(newStep); this.lastFill = { step: newStep, ts: nowTs, el: el }; } /** * Enqueue a step for upsert - if step with same id exists in batch, update it. * This ensures the background receives the final value for fill steps. * In iframes, forwards to top window to maintain selector composition consistency. */ _enqueueForUpsert(step) { if (!step || !step.id) return; // In iframes, forward upsert updates to top so we don't lose composed selectors. // The top window aggregates iframe steps and computes "frame |> inner" selectors. // If iframe sends directly to background, it would overwrite the composed selector. if (window !== window.top) { try { const payload = { kind: 'iframeStepUpsert', href: String(location && location.href ? location.href : ''), step, }; window.top.postMessage({ type: FRAME_EVENT, payload }, '*'); } catch {} return; } // Check if step already in batch const existingIdx = this.batch.findIndex((s) => s.id === step.id); if (existingIdx >= 0) { // Update existing entry in batch this.batch[existingIdx] = step; } else { // Add to batch (step was already flushed, so we need to send update) this.batch.push(step); } // Schedule flush with appropriate delay (respects typing gate) this._scheduleFlush(); } _onChange(e) { if (!this.isRecording || this.isPaused) return; const el = e.target; if (el instanceof HTMLSelectElement) { const val = el.value; const nowTs = Date.now(); const elRef = this._getElRef(el); const sameRef = !!(this.lastFill.step && this.lastFill.step._recordingRef === elRef); const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS; if (sameRef && within) { this.lastFill.step.value = val; this.sessionBuffer.meta.updatedAt = new Date().toISOString(); this.lastFill.ts = nowTs; this.lastFill.el = el; // Keep DOM reference updated // Re-enqueue for upsert this._enqueueForUpsert(this.lastFill.step); return; } const target = SelectorEngine.buildTarget(el); try { const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el); if (gref) target.ref = gref; } catch {} const st = { type: 'fill', target, value: val, screenshotOnFail: true }; st._recordingRef = elRef; this._pushStep(st); this.lastFill = { step: st, ts: nowTs, el: el }; return; } if (el instanceof HTMLInputElement) { const t = (el.getAttribute && el.getAttribute('type')) || ''; const tt = String(t).toLowerCase(); const target = SelectorEngine.buildTarget(el); try { const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el); if (gref) target.ref = gref; } catch {} const elRef = this._getElRef(el); if (tt === 'checkbox') { const st = { type: 'fill', target, value: !!el.checked, screenshotOnFail: true }; st._recordingRef = elRef; this._pushStep(st); return; } if (tt === 'radio') { const st = { type: 'fill', target, value: true, screenshotOnFail: true }; st._recordingRef = elRef; this._pushStep(st); return; } if (tt === 'file') { const varKey = el.name ? el.name : `file_${Math.random().toString(36).slice(2, 6)}`; this._addVariable(varKey, false, ''); this._pushStep({ type: 'fill', target, value: `{${varKey}}`, screenshotOnFail: true }); return; } } } _getElRef(el) { try { let ref = this.el2ref.get(el); if (ref) return ref; ref = `ref_${++this.refCounter}`; this.el2ref.set(el, ref); return ref; } catch { // Fallback to timestamp-based ref if WeakMap fails (should not happen) return `ref_${Date.now()}`; } } // UI handled by injected UI class _onFocusIn(e) { if (!this.isRecording || this.isPaused) return; const el = e.target; const isEditable = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el && el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true); if (!isEditable) return; if (this._focusedEl && this._focusedEl !== el) this._focusedEl.removeEventListener('input', this._onInput, true); el.addEventListener('input', this._onInput, true); this._focusedEl = el; } _onFocusOut(e) { const el = e.target; if (!el) return; if (this._focusedEl === el) { // Commit point: leaving an input field - finalize and flush pending input // This ensures we don't lose values when user tabs away or clicks elsewhere this._commitAndFlush(); el.removeEventListener('input', this._onInput, true); this._focusedEl = null; } } _onMouseMove(e) { if (!this.highlightEnabled || !this.ui._box || !this.isRecording || this.isPaused) return; if (this.hoverRAF) return; const el = e.target instanceof Element ? e.target : null; if (!el) return; this.hoverRAF = requestAnimationFrame(() => { try { const r = el.getBoundingClientRect(); Object.assign(this.ui._box.style, { left: `${Math.round(r.left)}px`, top: `${Math.round(r.top)}px`, width: `${Math.round(Math.max(0, r.width))}px`, height: `${Math.round(Math.max(0, r.height))}px`, display: r.width > 0 && r.height > 0 ? 'block' : 'none', }); } catch {} this.hoverRAF = 0; }); } _onScroll(e) { if (!this.isRecording || this.isPaused) return; try { const overlay = document.getElementById('__rr_rec_overlay'); if (overlay) { // Use composedPath for shadow DOM compatibility, fallback to target const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target]; for (const element of path) { // If the event path contains our overlay, ignore this scroll event if (element === overlay) { return; } } } } catch { // ignore } // Determine scroll source and positions const isDoc = e.target === document; const el = isDoc ? document.documentElement : e.target instanceof Element ? e.target : null; if (!el) return; let top = 0, left = 0; try { if (isDoc) { top = typeof window.scrollY === 'number' ? window.scrollY : document.documentElement.scrollTop || 0; left = typeof window.scrollX === 'number' ? window.scrollX : document.documentElement.scrollLeft || 0; } else { top = el.scrollTop || 0; left = el.scrollLeft || 0; } } catch {} const target = isDoc ? null : SelectorEngine.buildTarget(el); // Debounce/coalesce this._scrollPending = { isDoc, target, top, left }; if (this.scrollTimer) { clearTimeout(this.scrollTimer); } this.scrollTimer = setTimeout(() => { this.scrollTimer = null; const pending = this._scrollPending; this._scrollPending = null; if (!pending) return; const { isDoc: pDoc, target: pTarget, top: pTop, left: pLeft } = pending; // Try merge with last step const steps = this.sessionBuffer.steps; const last = steps.length ? steps[steps.length - 1] : null; if (last && last.type === 'scroll') { const sameDoc = pDoc && !last.target && last.mode === 'offset'; const sameEl = !pDoc && last.target && last.target.selector && pTarget && last.target.selector === pTarget.selector && last.mode === 'container'; if (sameDoc || sameEl) { last.offset = { y: pTop, x: pLeft }; this.sessionBuffer.meta.updatedAt = new Date().toISOString(); return; } } // New scroll step if (pDoc) { this._pushStep({ type: 'scroll', mode: 'offset', offset: { y: pTop, x: pLeft }, screenshotOnFail: false, }); } else { this._pushStep({ type: 'scroll', mode: 'container', target: pTarget, offset: { y: pTop, x: pLeft }, screenshotOnFail: false, }); } }, CONFIG.SCROLL_DEBOUNCE_MS); } // Minimal key recorder: record Enter and modifier combos; avoid plain typing _onKeyDown(e) { if (!this.isRecording || this.isPaused) return; try { // Ignore autorepeat to prevent spam if (e.repeat) return; const key = String(e.key || '').toLowerCase(); const isModifier = key === 'shift' || key === 'control' || key === 'meta' || key === 'alt'; const isEditable = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target && e.target.nodeType === 1 && /** @type {HTMLElement} */ (e.target).isContentEditable === true); const enterKey = key === 'enter'; // Track pressed modifiers if (isModifier) this._pressed.add(key); // Handle Enter in editable contexts (including contenteditable) if (isEditable && enterKey) { // Commit point: Enter may trigger form submission/navigation // Record explicit key action with target first const target = SelectorEngine.buildTarget(/** @type {Element} */ (e.target)); const combo = this._formatKeysCombo(e, 'Enter'); this._pushStep({ type: 'key', keys: combo, target, screenshotOnFail: false }); // Then commit and flush (form submit may navigate away) this._commitAndFlush(); this._lastKeyTs = Date.now(); return; } // For non-text fields: record modifier combos and special keys const special = enterKey || key === 'escape' || key === 'tab'; if (special || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { const comboName = this._formatKeysCombo(e, e.key); this._pushStep({ type: 'key', keys: comboName, screenshotOnFail: false }); this._lastKeyTs = Date.now(); } } catch {} } _onKeyUp(e) { const key = String(e.key || '').toLowerCase(); if (key === 'shift' || key === 'control' || key === 'meta' || key === 'alt') this._pressed.delete(key); } _formatKeysCombo(e, mainKey) { const parts = []; if (e.ctrlKey) parts.push('Ctrl'); if (e.altKey) parts.push('Alt'); if (e.shiftKey) parts.push('Shift'); if (e.metaKey) parts.push('Meta'); const mk = String(mainKey || '').trim(); // Normalize common names to match keyboard-helper parsing const norm = (s) => { const k = s.toLowerCase(); if (k === 'escape') return 'Esc'; if (k === ' ') return 'Space'; if (k.length === 1) return k.toUpperCase(); return s; }; parts.push(norm(mk)); return parts.join('+'); } // Top-level aggregator: receives iframe events and merges into session _onWindowMessage(ev) { try { const d = ev && ev.data; if (!d || d.type !== FRAME_EVENT || !d.payload) return; // Security: validate message source is from a known iframe in our page // ev.source must match contentWindow of an iframe element we control let frameEl = null; try { const frames = document.querySelectorAll('iframe,frame'); for (let i = 0; i < frames.length; i++) { const f = frames[i]; if (f && f.contentWindow === ev.source) { frameEl = f; break; } } } catch {} // Reject messages not from a recognized iframe in our document if (!frameEl) { // Message source is not from a child iframe we control - ignore return; } // Additional origin check: only accept from same origin or about:blank iframes // (cross-origin iframes legitimately send from their origin) try { const selfOrigin = window.location.origin; const msgOrigin = ev.origin; // Allow same-origin, null (for sandboxed iframes), or if iframe src is same-origin const frameSrc = frameEl.getAttribute('src') || ''; let iframeSameOrigin = false; try { if (!frameSrc || frameSrc === 'about:blank') { iframeSameOrigin = true; } else { const frameUrl = new URL(frameSrc, selfOrigin); iframeSameOrigin = frameUrl.origin === selfOrigin; } } catch { // Invalid URL - assume cross-origin } // If iframe is same-origin, message origin should match if (iframeSameOrigin && msgOrigin !== selfOrigin && msgOrigin !== 'null') { return; // Origin mismatch for same-origin iframe - suspicious } } catch {} const payload = d.payload || {}; const kind = payload.kind; // Stop barrier sync: ACK back to the iframe so it can finish stop only after // its final postMessages have been processed by the top aggregator if (kind === 'iframeStopBarrier') { try { const id = payload.id; if (id && ev.source && typeof ev.source.postMessage === 'function') { ev.source.postMessage( { type: FRAME_EVENT, payload: { kind: 'iframeStopBarrierAck', id } }, '*', ); } } catch {} return; } // Handle iframe flush request: immediately flush top's aggregated buffer if (kind === 'iframeFlush') { this._lastInputActivityTs = 0; this._typingBurstStartTs = 0; this._clearForceFlushTimer(); if (this.batchTimer) clearTimeout(this.batchTimer); this.batchTimer = null; if (this.batch.length > 0) this._flush(); return; } const { step, href } = payload; if (!step || typeof step !== 'object') return; // Compose frame selector for iframe steps const frameTarget = SelectorEngine.buildTarget(frameEl); const frameSel = frameTarget?.selector || ''; // For upsert: find existing step in session and update it if (kind === 'iframeStepUpsert') { // Update input activity for iframe fills (enables flush gate for iframe input) if (step.type === 'fill') { this._updateInputActivity(); } // Find step by id in session buffer and update its value const existingIdx = this.sessionBuffer.steps.findIndex((s) => s.id === step.id); if (existingIdx >= 0) { // Update value but preserve the composed selector this.sessionBuffer.steps[existingIdx].value = step.value; this.sessionBuffer.meta.updatedAt = new Date().toISOString(); // Also update in batch if present const batchIdx = this.batch.findIndex((s) => s.id === step.id); if (batchIdx >= 0) { this.batch[batchIdx].value = step.value; } else { // Step was already flushed, add updated version to batch const updatedStep = { ...this.sessionBuffer.steps[existingIdx] }; this.batch.push(updatedStep); } this._scheduleFlush(); } return; } // Regular iframe step: compose composite selector and push if (step.target) { const inner = String(step.target.selector || '').trim(); if (frameSel && inner) { const composite = `${frameSel} |> ${inner}`; step.target.selector = composite; if (Array.isArray(step.target.candidates)) { step.target.candidates.unshift({ type: 'css', value: composite }); } } } this._pushStep(step); } catch {} } } // ================================================================ // 3) SINGLETON + MESSAGE HANDLERS // ================================================================ let recorderInstance = null; function getRecorder() { if (!recorderInstance) recorderInstance = new ContentRecorder(); return recorderInstance; } chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { try { if (!request || !request.action) return false; if (request.action === 'rr_timeline_update') { const rec = getRecorder(); // Only respond to timeline updates when recording is active if (!rec.isRecording) { sendResponse({ ok: true, ignored: true }); return true; } // Replace entire timeline to avoid divergence across tabs const steps = Array.isArray(request.steps) ? request.steps : []; rec.ui.applyTimelineUpdate(steps); sendResponse({ ok: true }); return true; } if (request.action === 'rr_recorder_control') { const rec = getRecorder(); const cmd = request.cmd; if (cmd === 'start') { rec.start(request.meta || {}); sendResponse({ success: true }); return true; } if (cmd === 'pause') { rec.pause(); sendResponse({ success: true }); return true; } if (cmd === 'resume') { rec.resume(); sendResponse({ success: true }); return true; } if (cmd === 'stop') { // Stop is now async - flush all data and wait for ack before responding rec .stop() .then((result) => { sendResponse({ success: true, ack: result.ack, stats: result }); }) .catch((err) => { sendResponse({ success: false, ack: false, error: String(err) }); }); return true; // Keep channel open for async response } sendResponse({ success: false, error: 'Unknown command' }); return true; } // Handle direct stop message with ack (sent by recorder-manager) if (request.action === 'stop' && request.requireAck) { const rec = getRecorder(); rec .stop() .then((result) => { sendResponse({ ack: result.ack, stats: result }); }) .catch(() => { sendResponse({ ack: false }); }); return true; } if (request.action === 'rr_recorder_ping') { sendResponse({ status: 'pong' }); return false; } } catch (e) { sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); return true; } return false; }); console.log('Record & Replay recorder.js loaded'); })();