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 } }
|
||||
|
||||
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<void> {
|
||||
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 nextSessionId = 1
|
||||
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 {
|
||||
ws: WebSocket | 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> {
|
||||
if (msg.method !== 'forwardCDPCommand') return
|
||||
|
||||
@@ -1072,240 +1061,6 @@ async function disconnectEverything(): Promise<void> {
|
||||
// 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> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
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<number, OffscreenRecordingState>()
|
||||
|
||||
// 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<any> {
|
||||
async function handleMessage(message: OffscreenMessage): Promise<OffscreenResult> {
|
||||
switch (message.action) {
|
||||
case 'startRecording':
|
||||
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
|
||||
|
||||
if (recordings.has(tabId)) {
|
||||
@@ -103,23 +91,30 @@ async function handleStartRecording(params: StartRecordingMessage): Promise<any>
|
||||
}
|
||||
|
||||
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<any>
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopRecording(params: StopRecordingMessage): Promise<any> {
|
||||
async function handleStopRecording(params: OffscreenStopRecordingMessage): Promise<OffscreenStopRecordingResult> {
|
||||
const { tabId } = params
|
||||
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 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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user