feat: add Chrome flags for automated recording without user interaction
- Add getChromeRestartCommand() with --allowlisted-extension-id and --auto-accept-this-tab-capture flags - Move EXTENSION_IDS to utils.ts for cleaner imports - Add sessionId support for multi-tab recording - Add helpful error message when activeTab permission is missing - Add null check for empty extension result - Document offscreen recording flow with ASCII diagram
This commit is contained in:
@@ -1116,8 +1116,9 @@ async function handleStartRecording(params: StartRecordingParams): Promise<Start
|
|||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
const errorMsg = chrome.runtime.lastError.message || 'Unknown error'
|
const errorMsg = chrome.runtime.lastError.message || 'Unknown error'
|
||||||
// Chrome returns this error when activeTab permission hasn't been granted
|
// 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')) {
|
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, then try again.`))
|
reject(new Error(`${errorMsg}. Click the Playwriter extension icon on this tab to enable recording.`))
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(errorMsg))
|
reject(new Error(errorMsg))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Offscreen document for Playwriter extension.
|
* Offscreen document for Playwriter screen recording.
|
||||||
* Handles operations that require DOM APIs not available in service workers:
|
*
|
||||||
* - Screen recording via chrome.tabCapture + MediaRecorder
|
* WHY OFFSCREEN DOCUMENT?
|
||||||
* - Future: audio processing, canvas operations, etc.
|
* Manifest V3 service workers cannot use MediaRecorder or getUserMedia directly.
|
||||||
|
* This hidden document provides access to Web APIs while the service worker orchestrates.
|
||||||
|
*
|
||||||
|
* RECORDING FLOW:
|
||||||
|
*
|
||||||
|
* ┌─────────────────┐ HTTP ┌─────────────────┐ WebSocket ┌─────────────────┐
|
||||||
|
* │ User Code │ ────────────► │ Relay Server │ ───────────────►│ Extension │
|
||||||
|
* │ startRecording │ │ /recording/* │ │ background.ts │
|
||||||
|
* └─────────────────┘ └─────────────────┘ └────────┬────────┘
|
||||||
|
* │
|
||||||
|
* ┌─────────────────────────────────────┘
|
||||||
|
* ▼
|
||||||
|
* ┌─────────────────┐
|
||||||
|
* │ Offscreen Doc │ ◄── MediaRecorder
|
||||||
|
* │ (this file) │
|
||||||
|
* └─────────────────┘
|
||||||
|
*
|
||||||
|
* STEP BY STEP:
|
||||||
|
* 1. User calls startRecording() → HTTP POST to relay server
|
||||||
|
* 2. Relay server forwards to extension via WebSocket
|
||||||
|
* 3. Extension calls chrome.tabCapture.getMediaStreamId() to get capture permission
|
||||||
|
* - Requires --allowlisted-extension-id flag OR user clicking extension icon
|
||||||
|
* 4. Extension creates this offscreen document via chrome.offscreen.createDocument()
|
||||||
|
* 5. Extension sends streamId to offscreen document
|
||||||
|
* 6. Offscreen calls navigator.mediaDevices.getUserMedia() with streamId
|
||||||
|
* 7. Offscreen creates MediaRecorder and starts encoding to webm
|
||||||
|
* 8. Chunks are sent back to extension → relay server → written to output file
|
||||||
|
*
|
||||||
|
* KEY APIS:
|
||||||
|
* - chrome.tabCapture.getMediaStreamId() - Extension API, gets capture permission
|
||||||
|
* - chrome.offscreen.createDocument() - Extension API, creates this document
|
||||||
|
* - navigator.mediaDevices.getUserMedia() - Web API, gets MediaStream from streamId
|
||||||
|
* - MediaRecorder - Web API, encodes video to webm
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface OffscreenRecordingState {
|
interface OffscreenRecordingState {
|
||||||
|
|||||||
+10
-10
@@ -11,7 +11,7 @@ import fs from 'node:fs'
|
|||||||
import path from 'node:path'
|
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 } 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'
|
||||||
|
|
||||||
type ConnectedTarget = {
|
type ConnectedTarget = {
|
||||||
@@ -21,11 +21,7 @@ type ConnectedTarget = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Our extension IDs - allow attaching to our own extension pages for debugging
|
// Our extension IDs - allow attaching to our own extension pages for debugging
|
||||||
const OUR_EXTENSION_IDS = [
|
const OUR_EXTENSION_IDS = EXTENSION_IDS
|
||||||
'jfeammnjpkecdekppnclgkkffahnhfhe', // Production extension (Chrome Web Store)
|
|
||||||
'pebbngnfojnignonigcnkdilknapkgid', // Dev extension (stable ID from manifest key)
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
||||||
@@ -47,7 +43,7 @@ function isRestrictedTarget(targetInfo: Protocol.Target.TargetInfo): boolean {
|
|||||||
// Allow our own extension pages
|
// Allow our own extension pages
|
||||||
if (url.startsWith('chrome-extension://')) {
|
if (url.startsWith('chrome-extension://')) {
|
||||||
const extensionId = url.replace('chrome-extension://', '').split('/')[0]
|
const extensionId = url.replace('chrome-extension://', '').split('/')[0]
|
||||||
if (OUR_EXTENSION_IDS.includes(extensionId)) {
|
if (EXTENSION_IDS.includes(extensionId)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -461,7 +457,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const extensionId = origin.replace('chrome-extension://', '')
|
const extensionId = origin.replace('chrome-extension://', '')
|
||||||
if (!OUR_EXTENSION_IDS.includes(extensionId)) {
|
if (!EXTENSION_IDS.includes(extensionId)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return origin
|
return origin
|
||||||
@@ -589,7 +585,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
if (origin) {
|
if (origin) {
|
||||||
if (origin.startsWith('chrome-extension://')) {
|
if (origin.startsWith('chrome-extension://')) {
|
||||||
const extensionId = origin.replace('chrome-extension://', '')
|
const extensionId = origin.replace('chrome-extension://', '')
|
||||||
if (!OUR_EXTENSION_IDS.includes(extensionId)) {
|
if (!EXTENSION_IDS.includes(extensionId)) {
|
||||||
logger?.log(pc.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`))
|
logger?.log(pc.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`))
|
||||||
return c.text('Forbidden', 403)
|
return c.text('Forbidden', 403)
|
||||||
}
|
}
|
||||||
@@ -798,7 +794,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const extensionId = origin.replace('chrome-extension://', '')
|
const extensionId = origin.replace('chrome-extension://', '')
|
||||||
if (!OUR_EXTENSION_IDS.includes(extensionId)) {
|
if (!EXTENSION_IDS.includes(extensionId)) {
|
||||||
logger?.log(pc.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`))
|
logger?.log(pc.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`))
|
||||||
return c.text('Forbidden', 403)
|
return c.text('Forbidden', 403)
|
||||||
}
|
}
|
||||||
@@ -1275,6 +1271,10 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
}) as StartRecordingResult
|
}) as StartRecordingResult
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return c.json({ success: false, error: 'Extension returned empty result' } as StartRecordingResult, 500)
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Track this recording
|
// Track this recording
|
||||||
activeRecordings.set(result.tabId, {
|
activeRecordings.set(result.tabId, {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ICDPSession {
|
|||||||
on(event: string, callback: (params: any) => void): unknown
|
on(event: string, callback: (params: any) => void): unknown
|
||||||
off(event: string, callback: (params: any) => void): unknown
|
off(event: string, callback: (params: any) => void): unknown
|
||||||
detach(): Promise<void>
|
detach(): Promise<void>
|
||||||
|
getSessionId?(): string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingRequest {
|
interface PendingRequest {
|
||||||
@@ -68,6 +69,10 @@ export class CDPSession implements ICDPSession {
|
|||||||
this.sessionId = sessionId
|
this.sessionId = sessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSessionId(): string | null {
|
||||||
|
return this.sessionId
|
||||||
|
}
|
||||||
|
|
||||||
send<K extends keyof ProtocolMapping.Commands>(
|
send<K extends keyof ProtocolMapping.Commands>(
|
||||||
method: K,
|
method: K,
|
||||||
params?: ProtocolMapping.Commands[K]['paramsType'][0],
|
params?: ProtocolMapping.Commands[K]['paramsType'][0],
|
||||||
|
|||||||
@@ -595,10 +595,16 @@ export class PlaywrightExecutor {
|
|||||||
// This permission is granted when the user clicks the Playwriter extension icon on a tab.
|
// This permission is granted when the user clicks the Playwriter extension icon on a tab.
|
||||||
const relayPort = this.cdpConfig.port || 19988
|
const relayPort = this.cdpConfig.port || 19988
|
||||||
// Recording will work on any tab where the user has clicked the icon.
|
// Recording will work on any tab where the user has clicked the icon.
|
||||||
const withRecordingDefaults = <T extends { page?: Page }, R>(
|
const withRecordingDefaults = <T extends { page?: Page; sessionId?: string }, R>(
|
||||||
fn: (opts: T & { relayPort: number }) => Promise<R>
|
fn: (opts: T & { relayPort: number; sessionId?: string }) => Promise<R>
|
||||||
) => {
|
) => {
|
||||||
return (options: T = {} as T) => fn({ page: options.page || page, relayPort, ...options })
|
return async (options: T = {} as T) => {
|
||||||
|
const targetPage = options.page || page
|
||||||
|
// Get sessionId from cached CDP session to identify which tab to record
|
||||||
|
const cdp = await getCDPSession({ page: targetPage })
|
||||||
|
const sessionId = options.sessionId || cdp.getSessionId() || undefined
|
||||||
|
return fn({ page: targetPage, sessionId, relayPort, ...options })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const self = this
|
const self = this
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
* Recording happens in the extension context, so it survives page navigation.
|
* Recording happens in the extension context, so it survives page navigation.
|
||||||
*
|
*
|
||||||
* This module communicates with the relay server which forwards commands to the extension.
|
* This module communicates with the relay server which forwards commands to the extension.
|
||||||
*
|
* SessionId (pw-tab-X format) is used to identify which tab to record.
|
||||||
* Note: Recording uses the first connected tab. Multi-tab recording support would require
|
|
||||||
* proper sessionId mapping between Playwright pages and extension tabs.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import os from 'node:os'
|
||||||
import type { Page } from 'playwright-core'
|
import type { Page } from 'playwright-core'
|
||||||
import type {
|
import type {
|
||||||
StartRecordingResult,
|
StartRecordingResult,
|
||||||
@@ -15,10 +14,44 @@ import type {
|
|||||||
IsRecordingResult,
|
IsRecordingResult,
|
||||||
CancelRecordingResult,
|
CancelRecordingResult,
|
||||||
} from './protocol.js'
|
} from './protocol.js'
|
||||||
|
import { EXTENSION_IDS } from './utils.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a shell command to quit and restart Chrome with flags that allow automatic tab capture.
|
||||||
|
* This enables screen recording without user interaction (clicking extension icon).
|
||||||
|
*
|
||||||
|
* Required flags:
|
||||||
|
* - --allowlisted-extension-id=<id> - grants the extension special privileges (one per extension)
|
||||||
|
* - --auto-accept-this-tab-capture - auto-accepts tab capture permission requests
|
||||||
|
*/
|
||||||
|
export function getChromeRestartCommand(): string {
|
||||||
|
const platform = os.platform()
|
||||||
|
const flags = EXTENSION_IDS.map(id => `--allowlisted-extension-id=${id}`).join(' ') + ' --auto-accept-this-tab-capture'
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return `osascript -e 'quit app "Google Chrome"' && sleep 1 && open -a "Google Chrome" --args ${flags}`
|
||||||
|
}
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return `taskkill /IM chrome.exe /F & timeout /t 1 & start chrome.exe ${flags}`
|
||||||
|
}
|
||||||
|
// Linux
|
||||||
|
return `pkill chrome; sleep 1; google-chrome ${flags}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is related to missing activeTab permission for recording.
|
||||||
|
*/
|
||||||
|
function isActiveTabPermissionError(error: string): boolean {
|
||||||
|
return error.includes('Extension has not been invoked') ||
|
||||||
|
error.includes('activeTab') ||
|
||||||
|
error.includes('enable recording')
|
||||||
|
}
|
||||||
|
|
||||||
export interface StartRecordingOptions {
|
export interface StartRecordingOptions {
|
||||||
/** Target page to record (currently unused - records first connected tab) */
|
/** Target page to record */
|
||||||
page: Page
|
page: Page
|
||||||
|
/** Session ID (pw-tab-X format) to identify which tab to record */
|
||||||
|
sessionId?: string
|
||||||
/** Frame rate (default: 30) */
|
/** Frame rate (default: 30) */
|
||||||
frameRate?: number
|
frameRate?: number
|
||||||
/** Video bitrate in bps (default: 2500000 = 2.5 Mbps) */
|
/** Video bitrate in bps (default: 2500000 = 2.5 Mbps) */
|
||||||
@@ -34,8 +67,10 @@ export interface StartRecordingOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StopRecordingOptions {
|
export interface StopRecordingOptions {
|
||||||
/** Target page that is being recorded (currently unused) */
|
/** Target page that is being recorded */
|
||||||
page: Page
|
page: Page
|
||||||
|
/** Session ID (pw-tab-X format) to identify which tab to stop recording */
|
||||||
|
sessionId?: string
|
||||||
/** Relay server port (default: 19988) */
|
/** Relay server port (default: 19988) */
|
||||||
relayPort?: number
|
relayPort?: number
|
||||||
}
|
}
|
||||||
@@ -52,6 +87,7 @@ export interface RecordingState {
|
|||||||
*/
|
*/
|
||||||
export async function startRecording(options: StartRecordingOptions): Promise<RecordingState> {
|
export async function startRecording(options: StartRecordingOptions): Promise<RecordingState> {
|
||||||
const {
|
const {
|
||||||
|
sessionId,
|
||||||
frameRate = 30,
|
frameRate = 30,
|
||||||
videoBitsPerSecond = 2500000,
|
videoBitsPerSecond = 2500000,
|
||||||
audioBitsPerSecond = 128000,
|
audioBitsPerSecond = 128000,
|
||||||
@@ -63,13 +99,27 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
|||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ frameRate, videoBitsPerSecond, audioBitsPerSecond, audio, outputPath }),
|
body: JSON.stringify({ sessionId, frameRate, videoBitsPerSecond, audioBitsPerSecond, audio, outputPath }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json() as StartRecordingResult
|
const result = await response.json() as StartRecordingResult
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(`Failed to start recording: ${result.error}`)
|
const errorMsg = result.error || 'Unknown error'
|
||||||
|
|
||||||
|
// If the error is about missing activeTab permission, provide helpful guidance
|
||||||
|
if (isActiveTabPermissionError(errorMsg)) {
|
||||||
|
const restartCmd = getChromeRestartCommand()
|
||||||
|
throw new Error(
|
||||||
|
`Failed to start recording: ${errorMsg}\n\n` +
|
||||||
|
`For automated recording, Chrome must be restarted with special flags.\n` +
|
||||||
|
`WARNING: This will close all Chrome windows. Save your work first!\n\n` +
|
||||||
|
` ${restartCmd}\n\n` +
|
||||||
|
`Or click the Playwriter extension icon on the tab once to grant permission.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to start recording: ${errorMsg}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -84,12 +134,12 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
|||||||
* Returns the path to the saved video file.
|
* Returns the path to the saved video file.
|
||||||
*/
|
*/
|
||||||
export async function stopRecording(options: StopRecordingOptions): Promise<{ path: string; duration: number; size: number }> {
|
export async function stopRecording(options: StopRecordingOptions): Promise<{ path: string; duration: number; size: number }> {
|
||||||
const { relayPort = 19988 } = options
|
const { sessionId, relayPort = 19988 } = options
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, {
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({ sessionId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json() as StopRecordingResult
|
const result = await response.json() as StopRecordingResult
|
||||||
@@ -104,10 +154,13 @@ export async function stopRecording(options: StopRecordingOptions): Promise<{ pa
|
|||||||
/**
|
/**
|
||||||
* Check if recording is currently active.
|
* Check if recording is currently active.
|
||||||
*/
|
*/
|
||||||
export async function isRecording(options: { page: Page; relayPort?: number }): Promise<RecordingState> {
|
export async function isRecording(options: { page: Page; sessionId?: string; relayPort?: number }): Promise<RecordingState> {
|
||||||
const { relayPort = 19988 } = options
|
const { sessionId, relayPort = 19988 } = options
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/status`)
|
const url = sessionId
|
||||||
|
? `http://127.0.0.1:${relayPort}/recording/status?sessionId=${encodeURIComponent(sessionId)}`
|
||||||
|
: `http://127.0.0.1:${relayPort}/recording/status`
|
||||||
|
const response = await fetch(url)
|
||||||
const result = await response.json() as IsRecordingResult
|
const result = await response.json() as IsRecordingResult
|
||||||
|
|
||||||
return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
|
return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
|
||||||
@@ -116,13 +169,13 @@ export async function isRecording(options: { page: Page; relayPort?: number }):
|
|||||||
/**
|
/**
|
||||||
* Cancel recording without saving.
|
* Cancel recording without saving.
|
||||||
*/
|
*/
|
||||||
export async function cancelRecording(options: { page: Page; relayPort?: number }): Promise<void> {
|
export async function cancelRecording(options: { page: Page; sessionId?: string; relayPort?: number }): Promise<void> {
|
||||||
const { relayPort = 19988 } = options
|
const { sessionId, relayPort = 19988 } = options
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({ sessionId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json() as CancelRecordingResult
|
const result = await response.json() as CancelRecordingResult
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import os from 'node:os'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
// Playwriter extension IDs - used for validation and Chrome flag commands
|
||||||
|
export const EXTENSION_IDS = [
|
||||||
|
'jfeammnjpkecdekppnclgkkffahnhfhe', // Production (Chrome Web Store)
|
||||||
|
'elnnakgjclnapgflmidlpobefkdmapdm', // Dev (loaded unpacked)
|
||||||
|
]
|
||||||
|
|
||||||
export function getCdpUrl({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) {
|
export function getCdpUrl({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) {
|
||||||
const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}`
|
const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}`
|
||||||
const queryString = token ? `?token=${token}` : ''
|
const queryString = token ? `?token=${token}` : ''
|
||||||
|
|||||||
Reference in New Issue
Block a user