From 54d3f02a5b7c86cb6196a58a553a241f035cec13 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 25 Jan 2026 17:18:20 +0100 Subject: [PATCH] feat: support concurrent screen recordings across multiple tabs - Change offscreen.ts from single recording variable to Map - Update all recording message handlers to route by tabId - Track sessionId in relay server for proper recording lookup when stopping - All recording operations now consistently require tabId --- extension/src/background.ts | 13 +++--- extension/src/offscreen.ts | 81 +++++++++++++++++++++++-------------- playwriter/src/cdp-relay.ts | 34 ++++++++++++---- 3 files changed, 85 insertions(+), 43 deletions(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index ea1b5c3..a4d8e11 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1184,9 +1184,10 @@ async function handleStopRecording(params: StopRecordingParams): Promise { if (recording) { logger.debug('Cleaning up recording for disconnected tab:', tabId) try { - // Tell offscreen document to cancel recording - await chrome.runtime.sendMessage({ action: 'cancelRecording' }) + // 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) } diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index c2efeb1..2272c25 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -44,7 +44,8 @@ interface OffscreenRecordingState { tabId: number } -let recording: OffscreenRecordingState | null = null +// Map of tabId -> recording state for concurrent recording support +const recordings = new Map() // Message types type StartRecordingMessage = { @@ -59,14 +60,17 @@ type StartRecordingMessage = { type StopRecordingMessage = { action: 'stopRecording' + tabId: number } type IsRecordingMessage = { action: 'isRecording' + tabId: number } type CancelRecordingMessage = { action: 'cancelRecording' + tabId: number } type OffscreenMessage = StartRecordingMessage | StopRecordingMessage | IsRecordingMessage | CancelRecordingMessage @@ -81,19 +85,21 @@ async function handleMessage(message: OffscreenMessage): Promise { case 'startRecording': return handleStartRecording(message) case 'stopRecording': - return handleStopRecording() + return handleStopRecording(message) case 'isRecording': - return handleIsRecording() + return handleIsRecording(message) case 'cancelRecording': - return handleCancelRecording() + return handleCancelRecording(message) default: return { success: false, error: 'Unknown action' } } } async function handleStartRecording(params: StartRecordingMessage): Promise { - if (recording) { - return { success: false, error: 'Recording already in progress' } + const { tabId } = params + + if (recordings.has(tabId)) { + return { success: false, error: `Recording already in progress for tab ${tabId}` } } try { @@ -123,14 +129,14 @@ async function handleStartRecording(params: StartRecordingMessage): Promise const startedAt = Date.now() - recording = { + recordings.set(tabId, { recorder, stream, startedAt, - tabId: params.tabId, - } + tabId, + }) - // Send chunks to service worker + // Send chunks to service worker - each chunk includes tabId for routing recorder.ondataavailable = async (event) => { if (event.data.size > 0) { // Convert blob to array buffer and send to service worker @@ -138,38 +144,41 @@ async function handleStartRecording(params: StartRecordingMessage): Promise const uint8Array = new Uint8Array(arrayBuffer) chrome.runtime.sendMessage({ action: 'recordingChunk', - tabId: params.tabId, + tabId, data: Array.from(uint8Array), // Convert to regular array for message passing }) } } recorder.onerror = (event: Event) => { - console.error('MediaRecorder error:', (event as ErrorEvent).error) - handleCancelRecording() + console.error(`MediaRecorder error for tab ${tabId}:`, (event as ErrorEvent).error) + handleCancelRecordingForTab(tabId) } recorder.onstop = () => { - console.log('MediaRecorder stopped') + console.log(`MediaRecorder stopped for tab ${tabId}`) } // Start with 1 second chunks recorder.start(1000) - return { success: true, tabId: params.tabId, startedAt, mimeType: 'video/mp4' } + return { success: true, tabId, startedAt, mimeType: 'video/mp4' } } catch (error: any) { - console.error('Failed to start recording:', error) + console.error(`Failed to start recording for tab ${tabId}:`, error) return { success: false, error: error.message } } } -async function handleStopRecording(): Promise { +async function handleStopRecording(params: StopRecordingMessage): Promise { + const { tabId } = params + const recording = recordings.get(tabId) + if (!recording) { - return { success: false, error: 'No active recording' } + return { success: false, error: `No active recording for tab ${tabId}` } } try { - const { recorder, stream, startedAt, tabId } = recording + const { recorder, stream, startedAt } = recording // Stop recorder and wait for final data await new Promise((resolve) => { @@ -199,33 +208,45 @@ async function handleStopRecording(): Promise { final: true, }) - recording = null + recordings.delete(tabId) return { success: true, tabId, duration } } catch (error: any) { - console.error('Failed to stop recording:', error) + console.error(`Failed to stop recording for tab ${tabId}:`, error) return { success: false, error: error.message } } } -function handleIsRecording(): any { +function handleIsRecording(params: IsRecordingMessage): any { + const { tabId } = params + const recording = recordings.get(tabId) + if (!recording) { - return { isRecording: false } + return { isRecording: false, tabId } } + return { isRecording: recording.recorder?.state === 'recording', - tabId: recording.tabId, + tabId, startedAt: recording.startedAt, } } -function handleCancelRecording(): any { +function handleCancelRecording(params: CancelRecordingMessage): any { + 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 { + const recording = recordings.get(tabId) + if (!recording) { - return { success: true } + return { success: true, tabId } } try { - const { recorder, stream, tabId } = recording + const { recorder, stream } = recording if (recorder.state !== 'inactive') { recorder.stop() @@ -237,11 +258,11 @@ function handleCancelRecording(): any { tabId, }) - recording = null + recordings.delete(tabId) - return { success: true } + return { success: true, tabId } } catch (error: any) { - console.error('Failed to cancel recording:', error) + console.error(`Failed to cancel recording for tab ${tabId}:`, error) return { success: false, error: error.message } } } diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index a37a6e1..9d502d3 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -88,8 +88,10 @@ export async function startPlayWriterCDPRelayServer({ } // 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 @@ -1250,14 +1252,15 @@ export async function startPlayWriterCDPRelayServer({ } if (result.success) { - // Track this recording + // 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}, output: ${outputPath}`)) + logger?.log(pc.green(`Recording started for tab ${result.tabId} (sessionId: ${params.sessionId || 'none'}), output: ${outputPath}`)) } return c.json(result) @@ -1276,14 +1279,29 @@ export async function startPlayWriterCDPRelayServer({ return c.json({ success: false, error: 'Extension not connected' } as StopRecordingResult, 503) } - // Find the active recording. Currently we only support one recording at a time. - // The extension handles sessionId → tabId resolution on its side when stopping. - // TODO: If we need to support multiple concurrent recordings, we'd need to track - // which sessionId corresponds to which tabId when starting a recording. - const recording = activeRecordings.values().next().value as typeof activeRecordings extends Map ? V : never | undefined + // 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) { - return c.json({ success: false, error: 'No active recording found' } as StopRecordingResult, 404) + 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