From 4a8caf19bdcaebfa893d12e9e6fd192cc26f5325 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 25 Jan 2026 23:25:59 +0100 Subject: [PATCH] refactor: extract recording code into separate modules with proper types - Create extension/src/offscreen-types.ts with Chrome tabCapture constraint types - Create extension/src/recording.ts extracting recording functions from background.ts - Create playwriter/src/recording-relay.ts extracting recording logic from cdp-relay.ts - Remove 'as any' usages by adding proper MediaStream constraint types - Add typesafe message types for offscreen document communication - Remove duplicate OUR_EXTENSION_IDS constant (uses EXTENSION_IDS from utils.ts) --- extension/src/background.ts | 307 +++------------------------- extension/src/offscreen-types.ts | 106 ++++++++++ extension/src/offscreen.ts | 91 ++++----- extension/src/recording.ts | 325 ++++++++++++++++++++++++++++++ playwriter/src/cdp-relay.ts | 273 +++---------------------- playwriter/src/recording-relay.ts | 302 +++++++++++++++++++++++++++ 6 files changed, 837 insertions(+), 567 deletions(-) create mode 100644 extension/src/offscreen-types.ts create mode 100644 extension/src/recording.ts create mode 100644 playwriter/src/recording-relay.ts diff --git a/extension/src/background.ts b/extension/src/background.ts index a4d8e11..5f22286 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,9 +1,18 @@ declare const process: { env: { PLAYWRITER_PORT: string } } import { createStore } from 'zustand/vanilla' -import type { ExtensionState, ConnectionState, TabState, TabInfo, RecordingInfo } from './types' +import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types' import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types' -import type { ExtensionCommandMessage, ExtensionResponseMessage, StartRecordingParams, StopRecordingParams, IsRecordingParams, CancelRecordingParams, StartRecordingResult, ExtensionStopRecordingResult, IsRecordingResult, CancelRecordingResult } from 'playwriter/src/protocol' +import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/protocol' +import { + initRecording, + getActiveRecordings, + handleStartRecording, + handleStopRecording, + handleIsRecording, + handleCancelRecording, + cleanupRecordingForTab, +} from './recording' const RELAY_PORT = process.env.PLAYWRITER_PORT const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension` @@ -12,49 +21,10 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -// ============= Offscreen Document Management ============= -// Offscreen documents are needed for screen recording because tabCapture.capture() -// is not available in MV3 service workers. Instead we use tabCapture.getMediaStreamId() -// and pass the stream ID to an offscreen document that can use getUserMedia(). - -let offscreenDocumentCreating: Promise | null = null - -async function ensureOffscreenDocument(): Promise { - // Check if already exists - const existingContexts = await chrome.runtime.getContexts({ - contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], - documentUrls: [chrome.runtime.getURL('src/offscreen.html')], - }) - - if (existingContexts.length > 0) { - return - } - - // Reuse in-progress creation - if (offscreenDocumentCreating) { - return offscreenDocumentCreating - } - - offscreenDocumentCreating = chrome.offscreen.createDocument({ - url: 'src/offscreen.html', - reasons: [chrome.offscreen.Reason.USER_MEDIA], - justification: 'Screen recording via chrome.tabCapture', - }) - - try { - await offscreenDocumentCreating - } finally { - offscreenDocumentCreating = null - } -} - let childSessions: Map = new Map() let nextSessionId = 1 let tabGroupQueue: Promise = Promise.resolve() -// Active recordings - kept outside store since MediaRecorder/MediaStream can't be serialized -const activeRecordings: Map = new Map() - class ConnectionManager { ws: WebSocket | null = null private connectionPromise: Promise | null = null @@ -670,6 +640,25 @@ function emitChildDetachesForTab(tabId: number): void { }) } +// Initialize recording module with required dependencies +initRecording({ + getTabBySessionId, + getTabs: () => store.getState().tabs, + updateTabRecordingState: (tabId: number, isRecording: boolean) => { + store.setState((state) => { + const newTabs = new Map(state.tabs) + const existing = newTabs.get(tabId) + if (existing) { + newTabs.set(tabId, { ...existing, isRecording }) + } + return { tabs: newTabs } + }) + }, + sendMessage, + isWebSocketOpen: () => connectionManager.ws?.readyState === WebSocket.OPEN, + logger, +}) + async function handleCommand(msg: ExtensionCommandMessage): Promise { if (msg.method !== 'forwardCDPCommand') return @@ -1072,240 +1061,6 @@ async function disconnectEverything(): Promise { // WS connection is maintained - maintainConnection handles it } -// ============= Recording Functions ============= - -function resolveTabIdFromSessionId(sessionId?: string): number | undefined { - if (!sessionId) { - // Return the first connected tab - for (const [tabId, tab] of store.getState().tabs) { - if (tab.state === 'connected') { - return tabId - } - } - return undefined - } - - const found = getTabBySessionId(sessionId) - return found?.tabId -} - -async function handleStartRecording(params: StartRecordingParams): Promise { - const tabId = resolveTabIdFromSessionId(params.sessionId) - if (!tabId) { - return { success: false, error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.' } - } - - if (activeRecordings.has(tabId)) { - return { success: false, error: 'Recording already in progress for this tab' } - } - - const tabInfo = store.getState().tabs.get(tabId) - if (!tabInfo || tabInfo.state !== 'connected') { - return { success: false, error: 'Tab is not connected' } - } - - logger.debug('Starting recording for tab:', tabId, 'params:', params) - - try { - // Ensure offscreen document exists - await ensureOffscreenDocument() - - // Get stream ID using chrome.tabCapture.getMediaStreamId (requires activeTab permission - user must click extension icon) - const streamId = await new Promise((resolve, reject) => { - chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (id) => { - if (chrome.runtime.lastError) { - const errorMsg = chrome.runtime.lastError.message || 'Unknown error' - // Chrome returns this error when activeTab permission hasn't been granted - // User must click the extension icon at least once per session - this is a Chrome security requirement - if (errorMsg.includes('Extension has not been invoked') || errorMsg.includes('activeTab')) { - reject(new Error(`${errorMsg}. Click the Playwriter extension icon on this tab to enable recording.`)) - } else { - reject(new Error(errorMsg)) - } - } else if (!id) { - reject(new Error('Failed to get media stream ID')) - } else { - resolve(id) - } - }) - }) - - logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...') - - // Send message to offscreen document to start recording - const result = await chrome.runtime.sendMessage({ - action: 'startRecording', - tabId, - streamId, - frameRate: params.frameRate ?? 30, - videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000, - audioBitsPerSecond: params.audioBitsPerSecond ?? 128000, - audio: params.audio ?? false, - }) as { success: boolean; tabId?: number; startedAt?: number; mimeType?: string; error?: string } - - if (!result.success) { - return { success: false, error: result.error || 'Failed to start recording in offscreen document' } - } - - const startedAt = result.startedAt || Date.now() - - // Store recording info - activeRecordings.set(tabId, { tabId, startedAt }) - - // Update tab state - store.setState((state) => { - const newTabs = new Map(state.tabs) - const existing = newTabs.get(tabId) - if (existing) { - newTabs.set(tabId, { ...existing, isRecording: true }) - } - return { tabs: newTabs } - }) - - logger.debug('Recording started for tab:', tabId, 'mimeType:', result.mimeType) - return { success: true, tabId, startedAt } - } catch (error: any) { - logger.error('Failed to start recording:', error) - return { success: false, error: error.message } - } -} - -async function handleStopRecording(params: StopRecordingParams): Promise { - const tabId = resolveTabIdFromSessionId(params.sessionId) - if (!tabId) { - return { success: false, error: 'No connected tab found' } - } - - const recording = activeRecordings.get(tabId) - if (!recording) { - return { success: false, error: 'No active recording for this tab' } - } - - logger.debug('Stopping recording for tab:', tabId) - - try { - // Send message to offscreen document to stop recording - include tabId for concurrent support - const result = await chrome.runtime.sendMessage({ - action: 'stopRecording', - tabId, - }) as { success: boolean; tabId?: number; duration?: number; error?: string } - - if (!result.success) { - return { success: false, error: result.error || 'Failed to stop recording in offscreen document' } - } - - const duration = result.duration || (Date.now() - recording.startedAt) - - // Clean up - activeRecordings.delete(tabId) - store.setState((state) => { - const newTabs = new Map(state.tabs) - const existing = newTabs.get(tabId) - if (existing) { - newTabs.set(tabId, { ...existing, isRecording: false }) - } - return { tabs: newTabs } - }) - - logger.debug('Recording stopped for tab:', tabId, 'duration:', duration) - return { success: true, tabId, duration } - } catch (error: any) { - logger.error('Failed to stop recording:', error) - return { success: false, error: error.message } - } -} - -async function handleIsRecording(params: IsRecordingParams): Promise { - const tabId = resolveTabIdFromSessionId(params.sessionId) - if (!tabId) { - return { isRecording: false } - } - - const recording = activeRecordings.get(tabId) - if (!recording) { - return { isRecording: false, tabId } - } - - // Check with offscreen document for actual recording state - include tabId for concurrent support - try { - const result = await chrome.runtime.sendMessage({ - action: 'isRecording', - tabId, - }) as { isRecording: boolean; tabId?: number; startedAt?: number } - - return { - isRecording: result.isRecording, - tabId, - startedAt: recording.startedAt, - } - } catch { - // If offscreen doc is gone, recording is not active - return { isRecording: false, tabId } - } -} - -async function handleCancelRecording(params: CancelRecordingParams): Promise { - const tabId = resolveTabIdFromSessionId(params.sessionId) - if (!tabId) { - return { success: false, error: 'No connected tab found' } - } - - const recording = activeRecordings.get(tabId) - if (!recording) { - return { success: true } // Already not recording - } - - logger.debug('Cancelling recording for tab:', tabId) - - try { - // Send message to offscreen document to cancel recording - include tabId for concurrent support - await chrome.runtime.sendMessage({ - action: 'cancelRecording', - tabId, - }) - - activeRecordings.delete(tabId) - store.setState((state) => { - const newTabs = new Map(state.tabs) - const existing = newTabs.get(tabId) - if (existing) { - newTabs.set(tabId, { ...existing, isRecording: false }) - } - return { tabs: newTabs } - }) - - // Send cancel marker - if (connectionManager.ws?.readyState === WebSocket.OPEN) { - sendMessage({ - method: 'recordingCancelled', - params: { tabId }, - }) - } - - return { success: true } - } catch (error: any) { - logger.error('Failed to cancel recording:', error) - return { success: false, error: error.message } - } -} - -// Clean up recordings when tab is disconnected -async function cleanupRecordingForTab(tabId: number): Promise { - const recording = activeRecordings.get(tabId) - if (recording) { - logger.debug('Cleaning up recording for disconnected tab:', tabId) - try { - // Tell offscreen document to cancel recording - include tabId for concurrent support - await chrome.runtime.sendMessage({ action: 'cancelRecording', tabId }) - } catch (e) { - logger.debug('Error cleaning up recording:', e) - } - activeRecordings.delete(tabId) - } -} - -// ============= End Recording Functions ============= - async function resetDebugger(): Promise { let targets = await chrome.debugger.getTargets() targets = targets.filter((x) => x.tabId && x.attached) @@ -1723,7 +1478,7 @@ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => { if (message.action === 'recordingCancelled') { const { tabId } = message - activeRecordings.delete(tabId) + getActiveRecordings().delete(tabId) store.setState((state) => { const newTabs = new Map(state.tabs) const existing = newTabs.get(tabId) diff --git a/extension/src/offscreen-types.ts b/extension/src/offscreen-types.ts new file mode 100644 index 0000000..cfedb79 --- /dev/null +++ b/extension/src/offscreen-types.ts @@ -0,0 +1,106 @@ +/** + * Types for communication between background.ts and offscreen.ts. + * These are Chrome extension internal messages, separate from the WS protocol types. + */ + +// Chrome-specific MediaStreamConstraints that TypeScript doesn't know about. +// These use Chrome's proprietary tabCapture API constraints. +// See: https://developer.chrome.com/docs/extensions/reference/tabCapture/ +export interface ChromeTabCaptureAudioConstraints { + mandatory: { + chromeMediaSource: 'tab' + chromeMediaSourceId: string + } +} + +export interface ChromeTabCaptureVideoConstraints { + mandatory: { + chromeMediaSource: 'tab' + chromeMediaSourceId: string + minFrameRate?: number + maxFrameRate?: number + } +} + +// Offscreen document message types +export interface OffscreenStartRecordingMessage { + action: 'startRecording' + tabId: number + streamId: string + frameRate?: number + videoBitsPerSecond?: number + audioBitsPerSecond?: number + audio?: boolean +} + +export interface OffscreenStopRecordingMessage { + action: 'stopRecording' + tabId: number +} + +export interface OffscreenIsRecordingMessage { + action: 'isRecording' + tabId: number +} + +export interface OffscreenCancelRecordingMessage { + action: 'cancelRecording' + tabId: number +} + +export type OffscreenMessage = + | OffscreenStartRecordingMessage + | OffscreenStopRecordingMessage + | OffscreenIsRecordingMessage + | OffscreenCancelRecordingMessage + +// Offscreen document response types +export type OffscreenStartRecordingResult = { + success: true + tabId: number + startedAt: number + mimeType: string +} | { + success: false + error: string +} + +export type OffscreenStopRecordingResult = { + success: true + tabId: number + duration: number +} | { + success: false + error: string +} + +export interface OffscreenIsRecordingResult { + isRecording: boolean + tabId: number + startedAt?: number +} + +export type OffscreenCancelRecordingResult = { + success: true + tabId: number +} | { + success: false + error: string +} + +// Messages sent FROM offscreen TO background +export interface OffscreenRecordingChunkMessage { + action: 'recordingChunk' + tabId: number + data?: number[] // Array from Uint8Array for message passing + final?: boolean +} + +export interface OffscreenRecordingCancelledMessage { + action: 'recordingCancelled' + tabId: number +} + +export type OffscreenOutgoingMessage = + | OffscreenRecordingChunkMessage + | OffscreenRecordingCancelledMessage diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index 2272c25..975518d 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -37,6 +37,20 @@ * - MediaRecorder - Web API, encodes video to webm */ +import type { + OffscreenMessage, + OffscreenStartRecordingMessage, + OffscreenStopRecordingMessage, + OffscreenIsRecordingMessage, + OffscreenCancelRecordingMessage, + OffscreenStartRecordingResult, + OffscreenStopRecordingResult, + OffscreenIsRecordingResult, + OffscreenCancelRecordingResult, + ChromeTabCaptureAudioConstraints, + ChromeTabCaptureVideoConstraints, +} from './offscreen-types' + interface OffscreenRecordingState { recorder: MediaRecorder stream: MediaStream @@ -47,40 +61,14 @@ interface OffscreenRecordingState { // Map of tabId -> recording state for concurrent recording support const recordings = new Map() -// Message types -type StartRecordingMessage = { - action: 'startRecording' - tabId: number - streamId: string - frameRate?: number - videoBitsPerSecond?: number - audioBitsPerSecond?: number - audio?: boolean -} - -type StopRecordingMessage = { - action: 'stopRecording' - tabId: number -} - -type IsRecordingMessage = { - action: 'isRecording' - tabId: number -} - -type CancelRecordingMessage = { - action: 'cancelRecording' - tabId: number -} - -type OffscreenMessage = StartRecordingMessage | StopRecordingMessage | IsRecordingMessage | CancelRecordingMessage +type OffscreenResult = OffscreenStartRecordingResult | OffscreenStopRecordingResult | OffscreenIsRecordingResult | OffscreenCancelRecordingResult chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => { handleMessage(message).then(sendResponse) return true // Keep channel open for async response }) -async function handleMessage(message: OffscreenMessage): Promise { +async function handleMessage(message: OffscreenMessage): Promise { switch (message.action) { case 'startRecording': return handleStartRecording(message) @@ -95,7 +83,7 @@ async function handleMessage(message: OffscreenMessage): Promise { } } -async function handleStartRecording(params: StartRecordingMessage): Promise { +async function handleStartRecording(params: OffscreenStartRecordingMessage): Promise { const { tabId } = params if (recordings.has(tabId)) { @@ -103,23 +91,30 @@ async function handleStartRecording(params: StartRecordingMessage): Promise } try { + // Build Chrome-specific tabCapture constraints + // These use Chrome's proprietary API that TypeScript doesn't have built-in types for + const audioConstraints: ChromeTabCaptureAudioConstraints | false = params.audio ? { + mandatory: { + chromeMediaSource: 'tab', + chromeMediaSourceId: params.streamId, + } + } : false + + const videoConstraints: ChromeTabCaptureVideoConstraints = { + mandatory: { + chromeMediaSource: 'tab', + chromeMediaSourceId: params.streamId, + minFrameRate: params.frameRate || 30, + maxFrameRate: params.frameRate || 30, + } + } + // Get media stream from the streamId provided by tabCapture.getMediaStreamId + // Cast to MediaStreamConstraints since Chrome accepts the extended constraints const stream = await navigator.mediaDevices.getUserMedia({ - audio: params.audio ? { - mandatory: { - chromeMediaSource: 'tab', - chromeMediaSourceId: params.streamId, - } - } as any : false, - video: { - mandatory: { - chromeMediaSource: 'tab', - chromeMediaSourceId: params.streamId, - minFrameRate: params.frameRate || 30, - maxFrameRate: params.frameRate || 30, - } - } as any, - }) + audio: audioConstraints, + video: videoConstraints, + } as MediaStreamConstraints) const recorder = new MediaRecorder(stream, { mimeType: 'video/mp4', @@ -169,7 +164,7 @@ async function handleStartRecording(params: StartRecordingMessage): Promise } } -async function handleStopRecording(params: StopRecordingMessage): Promise { +async function handleStopRecording(params: OffscreenStopRecordingMessage): Promise { const { tabId } = params const recording = recordings.get(tabId) @@ -217,7 +212,7 @@ async function handleStopRecording(params: StopRecordingMessage): Promise { } } -function handleIsRecording(params: IsRecordingMessage): any { +function handleIsRecording(params: OffscreenIsRecordingMessage): OffscreenIsRecordingResult { const { tabId } = params const recording = recordings.get(tabId) @@ -232,13 +227,13 @@ function handleIsRecording(params: IsRecordingMessage): any { } } -function handleCancelRecording(params: CancelRecordingMessage): any { +function handleCancelRecording(params: OffscreenCancelRecordingMessage): OffscreenCancelRecordingResult { const { tabId } = params return handleCancelRecordingForTab(tabId) } // Helper function to cancel recording for a specific tab - used by error handlers too -function handleCancelRecordingForTab(tabId: number): any { +function handleCancelRecordingForTab(tabId: number): OffscreenCancelRecordingResult { const recording = recordings.get(tabId) if (!recording) { diff --git a/extension/src/recording.ts b/extension/src/recording.ts new file mode 100644 index 0000000..22742d5 --- /dev/null +++ b/extension/src/recording.ts @@ -0,0 +1,325 @@ +/** + * Screen recording functionality for the Playwriter extension. + * Uses chrome.tabCapture to record tabs via an offscreen document. + */ + +import type { RecordingInfo, TabInfo } from './types' +import type { + StartRecordingParams, + StopRecordingParams, + IsRecordingParams, + CancelRecordingParams, + StartRecordingResult, + ExtensionStopRecordingResult, + IsRecordingResult, + CancelRecordingResult, +} from 'playwriter/src/protocol' +import type { + OffscreenStartRecordingResult, + OffscreenStopRecordingResult, + OffscreenIsRecordingResult, +} from './offscreen-types' + +// Dependencies injected from background.ts +interface RecordingDeps { + getTabBySessionId: (sessionId: string) => { tabId: number; tab: TabInfo } | undefined + getTabs: () => Map + updateTabRecordingState: (tabId: number, isRecording: boolean) => void + sendMessage: (message: unknown) => void + isWebSocketOpen: () => boolean + logger: { + debug: (...args: unknown[]) => void + error: (...args: unknown[]) => void + } +} + +// Active recordings - kept outside store since MediaRecorder/MediaStream can't be serialized +const activeRecordings: Map = new Map() + +// Module-level dependencies (set via initRecording) +let deps: RecordingDeps | null = null + +// Offscreen document management +let offscreenDocumentCreating: Promise | null = null + +/** + * Initialize the recording module with dependencies from background.ts. + * Must be called before using any recording functions. + */ +export function initRecording(dependencies: RecordingDeps): void { + deps = dependencies +} + +/** + * Get the active recordings map (for cleanup on tab disconnect). + */ +export function getActiveRecordings(): Map { + return activeRecordings +} + +async function ensureOffscreenDocument(): Promise { + // Check if already exists + const existingContexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [chrome.runtime.getURL('src/offscreen.html')], + }) + + if (existingContexts.length > 0) { + return + } + + // Reuse in-progress creation + if (offscreenDocumentCreating) { + return offscreenDocumentCreating + } + + offscreenDocumentCreating = chrome.offscreen.createDocument({ + url: 'src/offscreen.html', + reasons: [chrome.offscreen.Reason.USER_MEDIA], + justification: 'Screen recording via chrome.tabCapture', + }) + + try { + await offscreenDocumentCreating + } finally { + offscreenDocumentCreating = null + } +} + +function resolveTabIdFromSessionId(sessionId?: string): number | undefined { + if (!deps) { + throw new Error('Recording module not initialized') + } + + if (!sessionId) { + // Return the first connected tab + for (const [tabId, tab] of deps.getTabs()) { + if (tab.state === 'connected') { + return tabId + } + } + return undefined + } + + const found = deps.getTabBySessionId(sessionId) + return found?.tabId +} + +export async function handleStartRecording(params: StartRecordingParams): Promise { + if (!deps) { + throw new Error('Recording module not initialized') + } + + const tabId = resolveTabIdFromSessionId(params.sessionId) + if (!tabId) { + return { success: false, error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.' } + } + + if (activeRecordings.has(tabId)) { + return { success: false, error: 'Recording already in progress for this tab' } + } + + const tabInfo = deps.getTabs().get(tabId) + if (!tabInfo || tabInfo.state !== 'connected') { + return { success: false, error: 'Tab is not connected' } + } + + deps.logger.debug('Starting recording for tab:', tabId, 'params:', params) + + try { + // Ensure offscreen document exists + await ensureOffscreenDocument() + + // Get stream ID using chrome.tabCapture.getMediaStreamId (requires activeTab permission - user must click extension icon) + const streamId = await new Promise((resolve, reject) => { + chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (id) => { + if (chrome.runtime.lastError) { + const errorMsg = chrome.runtime.lastError.message || 'Unknown error' + // Chrome returns this error when activeTab permission hasn't been granted + // User must click the extension icon at least once per session - this is a Chrome security requirement + if (errorMsg.includes('Extension has not been invoked') || errorMsg.includes('activeTab')) { + reject(new Error(`${errorMsg}. Click the Playwriter extension icon on this tab to enable recording.`)) + } else { + reject(new Error(errorMsg)) + } + } else if (!id) { + reject(new Error('Failed to get media stream ID')) + } else { + resolve(id) + } + }) + }) + + deps.logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...') + + // Send message to offscreen document to start recording + const result = await chrome.runtime.sendMessage({ + action: 'startRecording', + tabId, + streamId, + frameRate: params.frameRate ?? 30, + videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000, + audioBitsPerSecond: params.audioBitsPerSecond ?? 128000, + audio: params.audio ?? false, + }) as OffscreenStartRecordingResult + + if (!result.success) { + return { success: false, error: result.error || 'Failed to start recording in offscreen document' } + } + + const startedAt = result.startedAt || Date.now() + + // Store recording info + activeRecordings.set(tabId, { tabId, startedAt }) + + // Update tab state + deps.updateTabRecordingState(tabId, true) + + deps.logger.debug('Recording started for tab:', tabId, 'mimeType:', result.mimeType) + return { success: true, tabId, startedAt } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + deps.logger.error('Failed to start recording:', error) + return { success: false, error: errorMessage } + } +} + +export async function handleStopRecording(params: StopRecordingParams): Promise { + if (!deps) { + throw new Error('Recording module not initialized') + } + + const tabId = resolveTabIdFromSessionId(params.sessionId) + if (!tabId) { + return { success: false, error: 'No connected tab found' } + } + + const recording = activeRecordings.get(tabId) + if (!recording) { + return { success: false, error: 'No active recording for this tab' } + } + + deps.logger.debug('Stopping recording for tab:', tabId) + + try { + // Send message to offscreen document to stop recording - include tabId for concurrent support + const result = await chrome.runtime.sendMessage({ + action: 'stopRecording', + tabId, + }) as OffscreenStopRecordingResult + + if (!result.success) { + return { success: false, error: result.error || 'Failed to stop recording in offscreen document' } + } + + const duration = result.duration || (Date.now() - recording.startedAt) + + // Clean up + activeRecordings.delete(tabId) + deps.updateTabRecordingState(tabId, false) + + deps.logger.debug('Recording stopped for tab:', tabId, 'duration:', duration) + return { success: true, tabId, duration } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + deps.logger.error('Failed to stop recording:', error) + return { success: false, error: errorMessage } + } +} + +export async function handleIsRecording(params: IsRecordingParams): Promise { + if (!deps) { + throw new Error('Recording module not initialized') + } + + const tabId = resolveTabIdFromSessionId(params.sessionId) + if (!tabId) { + return { isRecording: false } + } + + const recording = activeRecordings.get(tabId) + if (!recording) { + return { isRecording: false, tabId } + } + + // Check with offscreen document for actual recording state - include tabId for concurrent support + try { + const result = await chrome.runtime.sendMessage({ + action: 'isRecording', + tabId, + }) as OffscreenIsRecordingResult + + return { + isRecording: result.isRecording, + tabId, + startedAt: recording.startedAt, + } + } catch { + // If offscreen doc is gone, recording is not active + return { isRecording: false, tabId } + } +} + +export async function handleCancelRecording(params: CancelRecordingParams): Promise { + if (!deps) { + throw new Error('Recording module not initialized') + } + + const tabId = resolveTabIdFromSessionId(params.sessionId) + if (!tabId) { + return { success: false, error: 'No connected tab found' } + } + + const recording = activeRecordings.get(tabId) + if (!recording) { + return { success: true } // Already not recording + } + + deps.logger.debug('Cancelling recording for tab:', tabId) + + try { + // Send message to offscreen document to cancel recording - include tabId for concurrent support + await chrome.runtime.sendMessage({ + action: 'cancelRecording', + tabId, + }) + + activeRecordings.delete(tabId) + deps.updateTabRecordingState(tabId, false) + + // Send cancel marker + if (deps.isWebSocketOpen()) { + deps.sendMessage({ + method: 'recordingCancelled', + params: { tabId }, + }) + } + + return { success: true } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + deps.logger.error('Failed to cancel recording:', error) + return { success: false, error: errorMessage } + } +} + +/** + * Clean up recordings when tab is disconnected. + */ +export async function cleanupRecordingForTab(tabId: number): Promise { + if (!deps) { + return + } + + const recording = activeRecordings.get(tabId) + if (recording) { + deps.logger.debug('Cleaning up recording for disconnected tab:', tabId) + try { + // Tell offscreen document to cancel recording - include tabId for concurrent support + await chrome.runtime.sendMessage({ action: 'cancelRecording', tabId }) + } catch (e) { + deps.logger.debug('Error cleaning up recording:', e) + } + activeRecordings.delete(tabId) + } +} diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index 9d502d3..3ee5296 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -6,13 +6,12 @@ import { createNodeWebSocket } from '@hono/node-ws' import type { WSContext } from 'hono/ws' import type { Protocol } from './cdp-types.js' import type { CDPCommand, CDPResponseBase, CDPEventBase, CDPEventFor, RelayServerEvents } from './cdp-types.js' -import type { ExtensionMessage, ExtensionEventMessage, StartRecordingParams, StopRecordingParams, IsRecordingParams, CancelRecordingParams, StartRecordingResult, StopRecordingResult, IsRecordingResult, CancelRecordingResult } from './protocol.js' -import fs from 'node:fs' -import path from 'node:path' +import type { ExtensionMessage, ExtensionEventMessage, RecordingDataMessage, RecordingCancelledMessage } from './protocol.js' import pc from 'picocolors' import { EventEmitter } from 'node:events' import { VERSION, EXTENSION_IDS } from './utils.js' import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js' +import { RecordingRelay } from './recording-relay.js' type ConnectedTarget = { sessionId: string @@ -20,8 +19,6 @@ type ConnectedTarget = { targetInfo: Protocol.Target.TargetInfo } -// Our extension IDs - allow attaching to our own extension pages for debugging -const OUR_EXTENSION_IDS = EXTENSION_IDS /** * Checks if a target should be filtered out (not exposed to Playwright). * Filters extension pages, service workers, and other restricted targets, @@ -86,21 +83,6 @@ export async function startPlayWriterCDPRelayServer({ const logCdpJson = (entry: CdpLogEntry) => { resolvedCdpLogger.log(entry) } - - // Recording state - tracks active recordings and their accumulated chunks - // Keyed by tabId for routing chunks, but also stores sessionId for lookup when stopping - type ActiveRecording = { - tabId: number - sessionId?: string // The sessionId used to start this recording, for lookup when stopping - outputPath: string - chunks: Buffer[] - startedAt: number - resolveStop?: (result: StopRecordingResult) => void - } - const activeRecordings = new Map() - // Track which tabId just sent recordingData metadata - used to route the next binary chunk - let lastRecordingMetadataTabId: number | null = null - const playwrightClients = new Map() let extensionWs: WSContext | null = null @@ -240,7 +222,7 @@ export async function startPlayWriterCDPRelayServer({ } } - async function sendToExtension({ method, params, timeout = 30000 }: { method: string; params?: any; timeout?: number }) { + async function sendToExtension({ method, params, timeout = 30000 }: { method: string; params?: unknown; timeout?: number }): Promise { if (!extensionWs) { throw new Error('Extension not connected') } @@ -281,6 +263,13 @@ export async function startPlayWriterCDPRelayServer({ }) } + // Recording relay for screen recording functionality + const recordingRelay = new RecordingRelay({ + sendToExtension, + isExtensionConnected: () => extensionWs !== null, + logger, + }) + // Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist. // This allows Playwright to connect and immediately have a page to work with. async function maybeAutoCreateInitialTab(): Promise { @@ -839,21 +828,7 @@ export async function startPlayWriterCDPRelayServer({ // Handle binary data (recording chunks) if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) { const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data) - // Route to the recording that just sent metadata - const tabId = lastRecordingMetadataTabId - lastRecordingMetadataTabId = null - - if (tabId !== null) { - const recording = activeRecordings.get(tabId) - if (recording) { - recording.chunks.push(buffer) - logger?.log(pc.blue(`Received recording chunk for tab ${tabId}: ${buffer.length} bytes (total chunks: ${recording.chunks.length})`)) - } else { - logger?.log(pc.yellow(`Received recording chunk for unknown tab ${tabId}, ignoring`)) - } - } else { - logger?.log(pc.yellow('Received recording chunk without preceding metadata, ignoring')) - } + recordingRelay.handleBinaryData(buffer) return } @@ -884,58 +859,14 @@ export async function startPlayWriterCDPRelayServer({ // Keep-alive response, nothing to do } else if (message.method === 'log') { const { level, args } = message.params - const logFn = (logger as any)?.[level] || logger?.log + const logFn = (logger as Record)?.[level] as ((...args: unknown[]) => void) | undefined + const logFunc = logFn || logger?.log const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`) - logFn?.(prefix, ...args) + logFunc?.(prefix, ...args) } else if (message.method === 'recordingData') { - const { tabId, final } = (message as any).params - const recording = activeRecordings.get(tabId) - - // Track which tab sent this metadata for routing the next binary chunk - if (!final) { - lastRecordingMetadataTabId = tabId - } - - if (recording && final) { - // This is the final marker - write all chunks to file - try { - const totalSize = recording.chunks.reduce((sum, chunk) => sum + chunk.length, 0) - const combined = Buffer.concat(recording.chunks) - fs.writeFileSync(recording.outputPath, combined) - - const duration = Date.now() - recording.startedAt - logger?.log(pc.green(`Recording saved: ${recording.outputPath} (${totalSize} bytes, ${duration}ms)`)) - - // Resolve the stop promise - if (recording.resolveStop) { - recording.resolveStop({ - success: true, - tabId, - duration, - path: recording.outputPath, - size: totalSize, - } as any) - } - } catch (error: any) { - logger?.error('Failed to write recording:', error) - if (recording.resolveStop) { - recording.resolveStop({ success: false, error: error.message }) - } - } - - activeRecordings.delete(tabId) - } - // Non-final recordingData is just a marker that binary follows - handled above + recordingRelay.handleRecordingData(message as RecordingDataMessage) } else if (message.method === 'recordingCancelled') { - const { tabId } = (message as any).params - const recording = activeRecordings.get(tabId) - if (recording) { - logger?.log(pc.yellow(`Recording cancelled for tab ${tabId}`)) - if (recording.resolveStop) { - recording.resolveStop({ success: false, error: 'Recording was cancelled' }) - } - activeRecordings.delete(tabId) - } + recordingRelay.handleRecordingCancelled(message as RecordingCancelledMessage) } else { const extensionEvent = message as ExtensionEventMessage @@ -1223,173 +1154,29 @@ export async function startPlayWriterCDPRelayServer({ // ============================================================================ app.post('/recording/start', async (c) => { - try { - const body = await c.req.json() as StartRecordingParams & { outputPath: string } - const { outputPath, ...params } = body - - if (!outputPath) { - return c.json({ success: false, error: 'outputPath is required' } as StartRecordingResult, 400) - } - - if (!extensionWs) { - return c.json({ success: false, error: 'Extension not connected' } as StartRecordingResult, 503) - } - - // Ensure output directory exists - const dir = path.dirname(outputPath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - - const result = await sendToExtension({ - method: 'startRecording', - params, - timeout: 10000, - }) as StartRecordingResult - - if (!result) { - return c.json({ success: false, error: 'Extension returned empty result' } as StartRecordingResult, 500) - } - - if (result.success) { - // Track this recording - store sessionId for lookup when stopping - activeRecordings.set(result.tabId, { - tabId: result.tabId, - sessionId: params.sessionId, - outputPath, - chunks: [], - startedAt: result.startedAt, - }) - logger?.log(pc.green(`Recording started for tab ${result.tabId} (sessionId: ${params.sessionId || 'none'}), output: ${outputPath}`)) - } - - return c.json(result) - } catch (error: any) { - logger?.error('Start recording error:', error) - return c.json({ success: false, error: error.message } as StartRecordingResult, 500) - } + const body = await c.req.json() as { outputPath?: string; sessionId?: string; frameRate?: number; audio?: boolean; videoBitsPerSecond?: number; audioBitsPerSecond?: number } + const result = await recordingRelay.startRecording(body as { outputPath: string } & typeof body) + const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500) + return c.json(result, status) }) app.post('/recording/stop', async (c) => { - try { - const body = await c.req.json() as StopRecordingParams - const params = body - - if (!extensionWs) { - return c.json({ success: false, error: 'Extension not connected' } as StopRecordingResult, 503) - } - - // Find the active recording by sessionId for concurrent recording support - // If no sessionId provided, fall back to the first recording (backward compatibility) - const findRecording = (): ActiveRecording | undefined => { - if (params.sessionId) { - for (const recording of activeRecordings.values()) { - if (recording.sessionId === params.sessionId) { - return recording - } - } - // SessionId provided but no matching recording found - return undefined - } - // No sessionId - return first recording (backward compat for single-recording case) - return activeRecordings.values().next().value - } - - const recording = findRecording() - - if (!recording) { - const errorMsg = params.sessionId - ? `No active recording found for sessionId: ${params.sessionId}` - : 'No active recording found' - return c.json({ success: false, error: errorMsg } as StopRecordingResult, 404) - } - - // Set up promise to wait for final chunk - let timeoutId: ReturnType - const finalPromise = new Promise((resolve) => { - const wrappedResolve = (result: StopRecordingResult) => { - clearTimeout(timeoutId) - resolve(result) - } - recording.resolveStop = wrappedResolve - // Timeout after 30 seconds - timeoutId = setTimeout(() => { - if (recording.resolveStop) { - recording.resolveStop = undefined - resolve({ success: false, error: 'Timeout waiting for recording data' }) - } - }, 30000) - }) - - // Tell extension to stop recording - const result = await sendToExtension({ - method: 'stopRecording', - params, - timeout: 10000, - }) as StopRecordingResult - - if (!result.success) { - recording.resolveStop = undefined - activeRecordings.delete(recording.tabId) - return c.json(result) - } - - // Wait for final chunk to arrive - const finalResult = await finalPromise - - return c.json(finalResult) - } catch (error: any) { - logger?.error('Stop recording error:', error) - return c.json({ success: false, error: error.message } as StopRecordingResult, 500) - } + const body = await c.req.json() as { sessionId?: string } + const result = await recordingRelay.stopRecording(body) + const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500) + return c.json(result, status) }) app.get('/recording/status', async (c) => { - try { - const sessionId = c.req.query('sessionId') - const params: IsRecordingParams = { sessionId } - - if (!extensionWs) { - return c.json({ isRecording: false } as IsRecordingResult) - } - - const result = await sendToExtension({ - method: 'isRecording', - params, - timeout: 5000, - }) as IsRecordingResult - - return c.json(result) - } catch (error: any) { - logger?.error('Recording status error:', error) - return c.json({ isRecording: false } as IsRecordingResult, 500) - } + const sessionId = c.req.query('sessionId') + const result = await recordingRelay.isRecording({ sessionId }) + return c.json(result) }) app.post('/recording/cancel', async (c) => { - try { - const body = await c.req.json() as CancelRecordingParams - const params = body - - if (!extensionWs) { - return c.json({ success: false, error: 'Extension not connected' } as CancelRecordingResult, 503) - } - - const result = await sendToExtension({ - method: 'cancelRecording', - params, - timeout: 5000, - }) as CancelRecordingResult - - // Note: Recording cleanup is handled by the 'recordingCancelled' event handler - // which is triggered by the extension after cancellation. This ensures we only - // delete the specific recording that was cancelled, not all active recordings. - - return c.json(result) - } catch (error: any) { - logger?.error('Cancel recording error:', error) - return c.json({ success: false, error: error.message } as CancelRecordingResult, 500) - } + const body = await c.req.json() as { sessionId?: string } + const result = await recordingRelay.cancelRecording(body) + return c.json(result) }) const server = serve({ fetch: app.fetch, port, hostname: host }) diff --git a/playwriter/src/recording-relay.ts b/playwriter/src/recording-relay.ts new file mode 100644 index 0000000..722ec73 --- /dev/null +++ b/playwriter/src/recording-relay.ts @@ -0,0 +1,302 @@ +/** + * Recording relay functionality for the CDP relay server. + * Handles recording state, chunk accumulation, and file writing. + */ + +import fs from 'node:fs' +import path from 'node:path' +import pc from 'picocolors' +import type { + StartRecordingParams, + StopRecordingParams, + IsRecordingParams, + CancelRecordingParams, + StartRecordingResult, + StopRecordingResult, + IsRecordingResult, + CancelRecordingResult, + RecordingDataMessage, + RecordingCancelledMessage, +} from './protocol.js' + +// Recording state - tracks active recordings and their accumulated chunks +export interface ActiveRecording { + tabId: number + sessionId?: string // The sessionId used to start this recording, for lookup when stopping + outputPath: string + chunks: Buffer[] + startedAt: number + resolveStop?: (result: StopRecordingResult) => void +} + +interface RecordingRelayDeps { + sendToExtension: (params: { method: string; params?: unknown; timeout?: number }) => Promise + isExtensionConnected: () => boolean + logger?: { + log(...args: unknown[]): void + error(...args: unknown[]): void + } +} + +export class RecordingRelay { + private activeRecordings = new Map() + // Track which tabId just sent recordingData metadata - used to route the next binary chunk + private lastRecordingMetadataTabId: number | null = null + private deps: RecordingRelayDeps + + constructor(deps: RecordingRelayDeps) { + this.deps = deps + } + + /** + * Handle incoming binary data (recording chunks) from the extension. + * Returns true if the data was handled as a recording chunk. + */ + handleBinaryData(buffer: Buffer): boolean { + const tabId = this.lastRecordingMetadataTabId + this.lastRecordingMetadataTabId = null + + if (tabId !== null) { + const recording = this.activeRecordings.get(tabId) + if (recording) { + recording.chunks.push(buffer) + this.deps.logger?.log(pc.blue(`Received recording chunk for tab ${tabId}: ${buffer.length} bytes (total chunks: ${recording.chunks.length})`)) + return true + } else { + this.deps.logger?.log(pc.yellow(`Received recording chunk for unknown tab ${tabId}, ignoring`)) + } + } else { + this.deps.logger?.log(pc.yellow('Received recording chunk without preceding metadata, ignoring')) + } + return false + } + + /** + * Handle recordingData message from extension. + * This is sent before binary chunks to identify which recording they belong to. + */ + handleRecordingData(message: RecordingDataMessage): void { + const { tabId, final } = message.params + const recording = this.activeRecordings.get(tabId) + + // Track which tab sent this metadata for routing the next binary chunk + if (!final) { + this.lastRecordingMetadataTabId = tabId + } + + if (recording && final) { + // This is the final marker - write all chunks to file + try { + const totalSize = recording.chunks.reduce((sum, chunk) => sum + chunk.length, 0) + const combined = Buffer.concat(recording.chunks) + fs.writeFileSync(recording.outputPath, combined) + + const duration = Date.now() - recording.startedAt + this.deps.logger?.log(pc.green(`Recording saved: ${recording.outputPath} (${totalSize} bytes, ${duration}ms)`)) + + // Resolve the stop promise + if (recording.resolveStop) { + recording.resolveStop({ + success: true, + tabId, + duration, + path: recording.outputPath, + size: totalSize, + }) + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.deps.logger?.error('Failed to write recording:', error) + if (recording.resolveStop) { + recording.resolveStop({ success: false, error: errorMessage }) + } + } + + this.activeRecordings.delete(tabId) + } + // Non-final recordingData is just a marker that binary follows - handled in handleBinaryData + } + + /** + * Handle recordingCancelled message from extension. + */ + handleRecordingCancelled(message: RecordingCancelledMessage): void { + const { tabId } = message.params + const recording = this.activeRecordings.get(tabId) + if (recording) { + this.deps.logger?.log(pc.yellow(`Recording cancelled for tab ${tabId}`)) + if (recording.resolveStop) { + recording.resolveStop({ success: false, error: 'Recording was cancelled' }) + } + this.activeRecordings.delete(tabId) + } + } + + /** + * Start recording a tab. + */ + async startRecording(params: StartRecordingParams & { outputPath: string }): Promise { + const { outputPath, ...recordingParams } = params + + if (!outputPath) { + return { success: false, error: 'outputPath is required' } + } + + if (!this.deps.isExtensionConnected()) { + return { success: false, error: 'Extension not connected' } + } + + // Ensure output directory exists + const dir = path.dirname(outputPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + try { + const result = await this.deps.sendToExtension({ + method: 'startRecording', + params: recordingParams, + timeout: 10000, + }) as StartRecordingResult + + if (!result) { + return { success: false, error: 'Extension returned empty result' } + } + + if (result.success) { + // Track this recording - store sessionId for lookup when stopping + this.activeRecordings.set(result.tabId, { + tabId: result.tabId, + sessionId: recordingParams.sessionId, + outputPath, + chunks: [], + startedAt: result.startedAt, + }) + this.deps.logger?.log(pc.green(`Recording started for tab ${result.tabId} (sessionId: ${recordingParams.sessionId || 'none'}), output: ${outputPath}`)) + } + + return result + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.deps.logger?.error('Start recording error:', error) + return { success: false, error: errorMessage } + } + } + + /** + * Stop recording and save to file. + */ + async stopRecording(params: StopRecordingParams): Promise { + if (!this.deps.isExtensionConnected()) { + return { success: false, error: 'Extension not connected' } + } + + // Find the active recording by sessionId for concurrent recording support + // If no sessionId provided, fall back to the first recording (backward compatibility) + const findRecording = (): ActiveRecording | undefined => { + if (params.sessionId) { + for (const recording of this.activeRecordings.values()) { + if (recording.sessionId === params.sessionId) { + return recording + } + } + // SessionId provided but no matching recording found + return undefined + } + // No sessionId - return first recording (backward compat for single-recording case) + return this.activeRecordings.values().next().value + } + + const recording = findRecording() + + if (!recording) { + const errorMsg = params.sessionId + ? `No active recording found for sessionId: ${params.sessionId}` + : 'No active recording found' + return { success: false, error: errorMsg } + } + + // Set up promise to wait for final chunk + let timeoutId: ReturnType + const finalPromise = new Promise((resolve) => { + const wrappedResolve = (result: StopRecordingResult) => { + clearTimeout(timeoutId) + resolve(result) + } + recording.resolveStop = wrappedResolve + // Timeout after 30 seconds + timeoutId = setTimeout(() => { + if (recording.resolveStop) { + recording.resolveStop = undefined + resolve({ success: false, error: 'Timeout waiting for recording data' }) + } + }, 30000) + }) + + try { + // Tell extension to stop recording + const result = await this.deps.sendToExtension({ + method: 'stopRecording', + params, + timeout: 10000, + }) as StopRecordingResult + + if (!result.success) { + recording.resolveStop = undefined + this.activeRecordings.delete(recording.tabId) + return result + } + + // Wait for final chunk to arrive + return await finalPromise + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.deps.logger?.error('Stop recording error:', error) + return { success: false, error: errorMessage } + } + } + + /** + * Check if recording is active. + */ + async isRecording(params: IsRecordingParams): Promise { + if (!this.deps.isExtensionConnected()) { + return { isRecording: false } + } + + try { + return await this.deps.sendToExtension({ + method: 'isRecording', + params, + timeout: 5000, + }) as IsRecordingResult + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.deps.logger?.error('Recording status error:', error) + return { isRecording: false } + } + } + + /** + * Cancel recording without saving. + */ + async cancelRecording(params: CancelRecordingParams): Promise { + if (!this.deps.isExtensionConnected()) { + return { success: false, error: 'Extension not connected' } + } + + try { + // Note: Recording cleanup is handled by the 'recordingCancelled' event handler + // which is triggered by the extension after cancellation. + return await this.deps.sendToExtension({ + method: 'cancelRecording', + params, + timeout: 5000, + }) as CancelRecordingResult + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.deps.logger?.error('Cancel recording error:', error) + return { success: false, error: errorMessage } + } + } +}