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)
This commit is contained in:
+31
-276
@@ -1,9 +1,18 @@
|
|||||||
declare const process: { env: { PLAYWRITER_PORT: string } }
|
declare const process: { env: { PLAYWRITER_PORT: string } }
|
||||||
|
|
||||||
import { createStore } from 'zustand/vanilla'
|
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 { 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_PORT = process.env.PLAYWRITER_PORT
|
||||||
const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension`
|
const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension`
|
||||||
@@ -12,49 +21,10 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
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<void> | null = null
|
|
||||||
|
|
||||||
async function ensureOffscreenDocument(): Promise<void> {
|
|
||||||
// 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<string, { tabId: number; targetId?: string }> = new Map()
|
let childSessions: Map<string, { tabId: number; targetId?: string }> = new Map()
|
||||||
let nextSessionId = 1
|
let nextSessionId = 1
|
||||||
let tabGroupQueue: Promise<void> = Promise.resolve()
|
let tabGroupQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
// Active recordings - kept outside store since MediaRecorder/MediaStream can't be serialized
|
|
||||||
const activeRecordings: Map<number, RecordingInfo> = new Map()
|
|
||||||
|
|
||||||
class ConnectionManager {
|
class ConnectionManager {
|
||||||
ws: WebSocket | null = null
|
ws: WebSocket | null = null
|
||||||
private connectionPromise: Promise<void> | null = null
|
private connectionPromise: Promise<void> | 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<any> {
|
async function handleCommand(msg: ExtensionCommandMessage): Promise<any> {
|
||||||
if (msg.method !== 'forwardCDPCommand') return
|
if (msg.method !== 'forwardCDPCommand') return
|
||||||
|
|
||||||
@@ -1072,240 +1061,6 @@ async function disconnectEverything(): Promise<void> {
|
|||||||
// WS connection is maintained - maintainConnection handles it
|
// 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<StartRecordingResult> {
|
|
||||||
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<string>((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<ExtensionStopRecordingResult> {
|
|
||||||
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<IsRecordingResult> {
|
|
||||||
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<CancelRecordingResult> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
async function resetDebugger(): Promise<void> {
|
||||||
let targets = await chrome.debugger.getTargets()
|
let targets = await chrome.debugger.getTargets()
|
||||||
targets = targets.filter((x) => x.tabId && x.attached)
|
targets = targets.filter((x) => x.tabId && x.attached)
|
||||||
@@ -1723,7 +1478,7 @@ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
|
|||||||
if (message.action === 'recordingCancelled') {
|
if (message.action === 'recordingCancelled') {
|
||||||
const { tabId } = message
|
const { tabId } = message
|
||||||
|
|
||||||
activeRecordings.delete(tabId)
|
getActiveRecordings().delete(tabId)
|
||||||
store.setState((state) => {
|
store.setState((state) => {
|
||||||
const newTabs = new Map(state.tabs)
|
const newTabs = new Map(state.tabs)
|
||||||
const existing = newTabs.get(tabId)
|
const existing = newTabs.get(tabId)
|
||||||
|
|||||||
@@ -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
|
||||||
+43
-48
@@ -37,6 +37,20 @@
|
|||||||
* - MediaRecorder - Web API, encodes video to webm
|
* - 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 {
|
interface OffscreenRecordingState {
|
||||||
recorder: MediaRecorder
|
recorder: MediaRecorder
|
||||||
stream: MediaStream
|
stream: MediaStream
|
||||||
@@ -47,40 +61,14 @@ interface OffscreenRecordingState {
|
|||||||
// Map of tabId -> recording state for concurrent recording support
|
// Map of tabId -> recording state for concurrent recording support
|
||||||
const recordings = new Map<number, OffscreenRecordingState>()
|
const recordings = new Map<number, OffscreenRecordingState>()
|
||||||
|
|
||||||
// Message types
|
type OffscreenResult = OffscreenStartRecordingResult | OffscreenStopRecordingResult | OffscreenIsRecordingResult | OffscreenCancelRecordingResult
|
||||||
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
|
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => {
|
||||||
handleMessage(message).then(sendResponse)
|
handleMessage(message).then(sendResponse)
|
||||||
return true // Keep channel open for async response
|
return true // Keep channel open for async response
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleMessage(message: OffscreenMessage): Promise<any> {
|
async function handleMessage(message: OffscreenMessage): Promise<OffscreenResult> {
|
||||||
switch (message.action) {
|
switch (message.action) {
|
||||||
case 'startRecording':
|
case 'startRecording':
|
||||||
return handleStartRecording(message)
|
return handleStartRecording(message)
|
||||||
@@ -95,7 +83,7 @@ async function handleMessage(message: OffscreenMessage): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartRecording(params: StartRecordingMessage): Promise<any> {
|
async function handleStartRecording(params: OffscreenStartRecordingMessage): Promise<OffscreenStartRecordingResult> {
|
||||||
const { tabId } = params
|
const { tabId } = params
|
||||||
|
|
||||||
if (recordings.has(tabId)) {
|
if (recordings.has(tabId)) {
|
||||||
@@ -103,23 +91,30 @@ async function handleStartRecording(params: StartRecordingMessage): Promise<any>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// 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({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: params.audio ? {
|
audio: audioConstraints,
|
||||||
mandatory: {
|
video: videoConstraints,
|
||||||
chromeMediaSource: 'tab',
|
} as MediaStreamConstraints)
|
||||||
chromeMediaSourceId: params.streamId,
|
|
||||||
}
|
|
||||||
} as any : false,
|
|
||||||
video: {
|
|
||||||
mandatory: {
|
|
||||||
chromeMediaSource: 'tab',
|
|
||||||
chromeMediaSourceId: params.streamId,
|
|
||||||
minFrameRate: params.frameRate || 30,
|
|
||||||
maxFrameRate: params.frameRate || 30,
|
|
||||||
}
|
|
||||||
} as any,
|
|
||||||
})
|
|
||||||
|
|
||||||
const recorder = new MediaRecorder(stream, {
|
const recorder = new MediaRecorder(stream, {
|
||||||
mimeType: 'video/mp4',
|
mimeType: 'video/mp4',
|
||||||
@@ -169,7 +164,7 @@ async function handleStartRecording(params: StartRecordingMessage): Promise<any>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStopRecording(params: StopRecordingMessage): Promise<any> {
|
async function handleStopRecording(params: OffscreenStopRecordingMessage): Promise<OffscreenStopRecordingResult> {
|
||||||
const { tabId } = params
|
const { tabId } = params
|
||||||
const recording = recordings.get(tabId)
|
const recording = recordings.get(tabId)
|
||||||
|
|
||||||
@@ -217,7 +212,7 @@ async function handleStopRecording(params: StopRecordingMessage): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIsRecording(params: IsRecordingMessage): any {
|
function handleIsRecording(params: OffscreenIsRecordingMessage): OffscreenIsRecordingResult {
|
||||||
const { tabId } = params
|
const { tabId } = params
|
||||||
const recording = recordings.get(tabId)
|
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
|
const { tabId } = params
|
||||||
return handleCancelRecordingForTab(tabId)
|
return handleCancelRecordingForTab(tabId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to cancel recording for a specific tab - used by error handlers too
|
// 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)
|
const recording = recordings.get(tabId)
|
||||||
|
|
||||||
if (!recording) {
|
if (!recording) {
|
||||||
|
|||||||
@@ -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<number, TabInfo>
|
||||||
|
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<number, RecordingInfo> = new Map()
|
||||||
|
|
||||||
|
// Module-level dependencies (set via initRecording)
|
||||||
|
let deps: RecordingDeps | null = null
|
||||||
|
|
||||||
|
// Offscreen document management
|
||||||
|
let offscreenDocumentCreating: Promise<void> | 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<number, RecordingInfo> {
|
||||||
|
return activeRecordings
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureOffscreenDocument(): Promise<void> {
|
||||||
|
// 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<StartRecordingResult> {
|
||||||
|
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<string>((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<ExtensionStopRecordingResult> {
|
||||||
|
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<IsRecordingResult> {
|
||||||
|
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<CancelRecordingResult> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-243
@@ -6,13 +6,12 @@ import { createNodeWebSocket } from '@hono/node-ws'
|
|||||||
import type { WSContext } from 'hono/ws'
|
import type { WSContext } from 'hono/ws'
|
||||||
import type { Protocol } from './cdp-types.js'
|
import type { Protocol } from './cdp-types.js'
|
||||||
import type { CDPCommand, CDPResponseBase, CDPEventBase, CDPEventFor, RelayServerEvents } 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 type { ExtensionMessage, ExtensionEventMessage, RecordingDataMessage, RecordingCancelledMessage } from './protocol.js'
|
||||||
import fs from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
import pc from 'picocolors'
|
import pc from 'picocolors'
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
import { VERSION, EXTENSION_IDS } from './utils.js'
|
import { VERSION, EXTENSION_IDS } from './utils.js'
|
||||||
import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js'
|
import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js'
|
||||||
|
import { RecordingRelay } from './recording-relay.js'
|
||||||
|
|
||||||
type ConnectedTarget = {
|
type ConnectedTarget = {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -20,8 +19,6 @@ type ConnectedTarget = {
|
|||||||
targetInfo: Protocol.Target.TargetInfo
|
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).
|
* Checks if a target should be filtered out (not exposed to Playwright).
|
||||||
* Filters extension pages, service workers, and other restricted targets,
|
* Filters extension pages, service workers, and other restricted targets,
|
||||||
@@ -86,21 +83,6 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
const logCdpJson = (entry: CdpLogEntry) => {
|
const logCdpJson = (entry: CdpLogEntry) => {
|
||||||
resolvedCdpLogger.log(entry)
|
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<number, ActiveRecording>()
|
|
||||||
// Track which tabId just sent recordingData metadata - used to route the next binary chunk
|
|
||||||
let lastRecordingMetadataTabId: number | null = null
|
|
||||||
|
|
||||||
const playwrightClients = new Map<string, PlaywrightClient>()
|
const playwrightClients = new Map<string, PlaywrightClient>()
|
||||||
let extensionWs: WSContext | null = null
|
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<unknown> {
|
||||||
if (!extensionWs) {
|
if (!extensionWs) {
|
||||||
throw new Error('Extension not connected')
|
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.
|
// 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.
|
// This allows Playwright to connect and immediately have a page to work with.
|
||||||
async function maybeAutoCreateInitialTab(): Promise<void> {
|
async function maybeAutoCreateInitialTab(): Promise<void> {
|
||||||
@@ -839,21 +828,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
// Handle binary data (recording chunks)
|
// Handle binary data (recording chunks)
|
||||||
if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
|
if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
|
||||||
const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
|
const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
|
||||||
// Route to the recording that just sent metadata
|
recordingRelay.handleBinaryData(buffer)
|
||||||
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'))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,58 +859,14 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
// Keep-alive response, nothing to do
|
// Keep-alive response, nothing to do
|
||||||
} else if (message.method === 'log') {
|
} else if (message.method === 'log') {
|
||||||
const { level, args } = message.params
|
const { level, args } = message.params
|
||||||
const logFn = (logger as any)?.[level] || logger?.log
|
const logFn = (logger as Record<string, unknown>)?.[level] as ((...args: unknown[]) => void) | undefined
|
||||||
|
const logFunc = logFn || logger?.log
|
||||||
const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`)
|
const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`)
|
||||||
logFn?.(prefix, ...args)
|
logFunc?.(prefix, ...args)
|
||||||
} else if (message.method === 'recordingData') {
|
} else if (message.method === 'recordingData') {
|
||||||
const { tabId, final } = (message as any).params
|
recordingRelay.handleRecordingData(message as RecordingDataMessage)
|
||||||
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
|
|
||||||
} else if (message.method === 'recordingCancelled') {
|
} else if (message.method === 'recordingCancelled') {
|
||||||
const { tabId } = (message as any).params
|
recordingRelay.handleRecordingCancelled(message as RecordingCancelledMessage)
|
||||||
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)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const extensionEvent = message as ExtensionEventMessage
|
const extensionEvent = message as ExtensionEventMessage
|
||||||
|
|
||||||
@@ -1223,173 +1154,29 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
app.post('/recording/start', async (c) => {
|
app.post('/recording/start', async (c) => {
|
||||||
try {
|
const body = await c.req.json() as { outputPath?: string; sessionId?: string; frameRate?: number; audio?: boolean; videoBitsPerSecond?: number; audioBitsPerSecond?: number }
|
||||||
const body = await c.req.json() as StartRecordingParams & { outputPath: string }
|
const result = await recordingRelay.startRecording(body as { outputPath: string } & typeof body)
|
||||||
const { outputPath, ...params } = body
|
const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500)
|
||||||
|
return c.json(result, status)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/recording/stop', async (c) => {
|
app.post('/recording/stop', async (c) => {
|
||||||
try {
|
const body = await c.req.json() as { sessionId?: string }
|
||||||
const body = await c.req.json() as StopRecordingParams
|
const result = await recordingRelay.stopRecording(body)
|
||||||
const params = body
|
const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500)
|
||||||
|
return c.json(result, status)
|
||||||
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<typeof setTimeout>
|
|
||||||
const finalPromise = new Promise<StopRecordingResult>((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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/recording/status', async (c) => {
|
app.get('/recording/status', async (c) => {
|
||||||
try {
|
const sessionId = c.req.query('sessionId')
|
||||||
const sessionId = c.req.query('sessionId')
|
const result = await recordingRelay.isRecording({ sessionId })
|
||||||
const params: IsRecordingParams = { sessionId }
|
return c.json(result)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/recording/cancel', async (c) => {
|
app.post('/recording/cancel', async (c) => {
|
||||||
try {
|
const body = await c.req.json() as { sessionId?: string }
|
||||||
const body = await c.req.json() as CancelRecordingParams
|
const result = await recordingRelay.cancelRecording(body)
|
||||||
const params = body
|
return c.json(result)
|
||||||
|
|
||||||
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 server = serve({ fetch: app.fetch, port, hostname: host })
|
const server = serve({ fetch: app.fetch, port, hostname: host })
|
||||||
|
|||||||
@@ -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<unknown>
|
||||||
|
isExtensionConnected: () => boolean
|
||||||
|
logger?: {
|
||||||
|
log(...args: unknown[]): void
|
||||||
|
error(...args: unknown[]): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecordingRelay {
|
||||||
|
private activeRecordings = new Map<number, ActiveRecording>()
|
||||||
|
// 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<StartRecordingResult> {
|
||||||
|
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<StopRecordingResult> {
|
||||||
|
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<typeof setTimeout>
|
||||||
|
const finalPromise = new Promise<StopRecordingResult>((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<IsRecordingResult> {
|
||||||
|
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<CancelRecordingResult> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user