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:
Tommy D. Rossi
2026-01-24 17:05:24 +01:00
parent 8449006e59
commit df63f418e5
7 changed files with 136 additions and 33 deletions
+2 -1
View File
@@ -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))
} }
+36 -4
View File
@@ -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
View File
@@ -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, {
+5
View File
@@ -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],
+9 -3
View File
@@ -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
+68 -15
View File
@@ -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
+6
View File
@@ -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}` : ''