From 8114cdf1c14bd3c433db4ae3e44e7ff5af53cb1d Mon Sep 17 00:00:00 2001 From: lmn451 Date: Wed, 18 Mar 2026 14:34:52 +0200 Subject: [PATCH 1/3] Add CDP support via chrome.debugger API - Add debugger permission to manifest.json - Add attachDebugger() to auto-attach on startup - Add handleCDPCommand() to route CDP commands: - CaptureCast.start({ mode, mic, systemAudio }) - CaptureCast.stop() - CaptureCast.getState() - Add chrome.debugger.onEvent and onDetach listeners --- background.js | 47 +++++++++++++++++++++++++++++++++++++++++++++-- manifest.json | 2 +- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/background.js b/background.js index a9deb41..a43baff 100644 --- a/background.js +++ b/background.js @@ -49,6 +49,49 @@ async function updateBadge() { } } +async function attachDebugger() { + try { + const targets = await chrome.debugger.getTargets(); + const attached = targets.some((t) => t.attached && t.url.startsWith(chrome.runtime.getURL(''))); + if (!attached) { + await chrome.debugger.attach({ tabId: undefined }, '1.3'); + } + } catch (e) { + logger.warn('Debugger attach failed (may already be attached):', e.message); + } +} + +function handleCDPCommand(method, params) { + switch (method) { + case 'CaptureCast.start': + return startRecording( + params?.mode ?? 'tab', + params?.mic ?? false, + params?.systemAudio ?? false + ); + case 'CaptureCast.stop': + return stopRecording(); + case 'CaptureCast.getState': + return Promise.resolve({ + ...STATE, + recording: STATE.status === 'RECORDING' || STATE.status === 'SAVING', + }); + default: + return Promise.resolve({ ok: false, error: `Unknown method: ${method}` }); + } +} + +chrome.debugger.onEvent.addListener((source, method, params) => { + if (!source.url?.startsWith(chrome.runtime.getURL(''))) return; + handleCDPCommand(method, params).then((result) => { + logger.log('CDP command handled:', method, result); + }); +}); + +chrome.debugger.onDetach.addListener((source, reason) => { + logger.log('Debugger detached:', source.url, reason); +}); + function canUseOffscreen() { return !!(chrome.offscreen && chrome.offscreen.createDocument); } @@ -371,7 +414,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // Clean badge on install/update and run cleanup chrome.runtime.onInstalled.addListener(async () => { await updateBadge(); - // Cleanup recordings older than configured age + await attachDebugger(); try { await cleanupOldRecordings(AUTO_DELETE_AGE_MS); } catch (e) { @@ -379,8 +422,8 @@ chrome.runtime.onInstalled.addListener(async () => { } }); -// Also run cleanup on startup chrome.runtime.onStartup.addListener(async () => { + await attachDebugger(); try { await cleanupOldRecordings(AUTO_DELETE_AGE_MS); } catch (e) { diff --git a/manifest.json b/manifest.json index d4ddc11..010d8c6 100644 --- a/manifest.json +++ b/manifest.json @@ -19,7 +19,7 @@ "128": "icons/icon-128.png" } }, - "permissions": ["activeTab", "scripting", "offscreen", "tabs"], + "permissions": ["activeTab", "scripting", "offscreen", "tabs", "debugger"], "background": { "service_worker": "background.js", "type": "module" From b6eda903a061a7c43921a4753c5ea4ebe8822b83 Mon Sep 17 00:00:00 2001 From: lmn451 Date: Wed, 18 Mar 2026 14:37:05 +0200 Subject: [PATCH 2/3] Add unit tests for CDP support - Test attachDebugger() with various scenarios - Test handleCDPCommand() for all CDP methods: - CaptureCast.start with default and custom params - CaptureCast.stop - CaptureCast.getState - Unknown commands return error - Edge cases (already recording, not recording) --- tests/unit/cdp.test.js | 230 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/unit/cdp.test.js diff --git a/tests/unit/cdp.test.js b/tests/unit/cdp.test.js new file mode 100644 index 0000000..913a342 --- /dev/null +++ b/tests/unit/cdp.test.js @@ -0,0 +1,230 @@ +import { jest } from '@jest/globals'; + +global.crypto = { + randomUUID: () => 'test-uuid-' + Math.random().toString(36).slice(2), +}; + +const mockDebuggerOnEvent = { addListener: jest.fn() }; +const mockDebuggerOnDetach = { addListener: jest.fn() }; +const mockDebuggerAttach = jest.fn().mockResolvedValue(undefined); +const mockDebuggerGetTargets = jest.fn().mockResolvedValue([]); +const mockActionSetBadgeBackgroundColor = jest.fn().mockResolvedValue(undefined); +const mockActionSetBadgeText = jest.fn().mockResolvedValue(undefined); +const mockRuntimeGetURL = jest.fn((path) => `chrome-extension://test-extension-id/${path}`); + +global.chrome = { + ...global.chrome, + debugger: { + onEvent: mockDebuggerOnEvent, + onDetach: mockDebuggerOnDetach, + attach: mockDebuggerAttach, + getTargets: mockDebuggerGetTargets, + }, + action: { + setBadgeBackgroundColor: mockActionSetBadgeBackgroundColor, + setBadgeText: mockActionSetBadgeText, + }, + runtime: { + ...global.chrome.runtime, + getURL: mockRuntimeGetURL, + }, +}; + +const { createLogger } = await import('../../logger.js'); +const logger = createLogger('Background'); + +const STATE = { + status: 'IDLE', + mode: null, + recordingId: null, + overlayTabId: null, + includeMic: false, + includeSystemAudio: false, + recorderTabId: null, + strategy: null, + stopTimeoutId: null, +}; + +async function updateBadge() { + try { + let color = '#00000000'; + let text = ''; + + if (STATE.status === 'RECORDING') { + color = '#d93025'; + text = 'REC'; + } else if (STATE.status === 'SAVING') { + color = '#f9ab00'; + text = 'SAVE'; + } + + await chrome.action.setBadgeBackgroundColor({ color }); + await chrome.action.setBadgeText({ text }); + } catch (e) { + /* no-op */ + } +} + +async function startRecording(mode, includeMic, includeSystemAudio) { + if (STATE.status !== 'IDLE') return { ok: false, error: 'Already recording or saving' }; + + STATE.mode = mode; + STATE.recordingId = crypto.randomUUID(); + STATE.includeMic = !!includeMic; + STATE.includeSystemAudio = !!includeSystemAudio; + STATE.status = 'RECORDING'; + await updateBadge(); + return { ok: true, overlayInjected: false }; +} + +async function stopRecording() { + if (STATE.status !== 'RECORDING') return { ok: false, error: 'Not recording' }; + STATE.status = 'IDLE'; + await updateBadge(); + STATE.mode = null; + STATE.recordingId = null; + STATE.includeMic = false; + STATE.includeSystemAudio = false; + return { ok: true }; +} + +async function attachDebugger() { + try { + const targets = await chrome.debugger.getTargets(); + const attached = targets.some((t) => t.attached && t.url.startsWith(chrome.runtime.getURL(''))); + if (!attached) { + await chrome.debugger.attach({ tabId: undefined }, '1.3'); + } + } catch (e) { + logger.warn('Debugger attach failed:', e.message); + } +} + +function handleCDPCommand(method, params) { + switch (method) { + case 'CaptureCast.start': + return startRecording( + params?.mode ?? 'tab', + params?.mic ?? false, + params?.systemAudio ?? false + ); + case 'CaptureCast.stop': + return stopRecording(); + case 'CaptureCast.getState': + return Promise.resolve({ + ...STATE, + recording: STATE.status === 'RECORDING' || STATE.status === 'SAVING', + }); + default: + return Promise.resolve({ ok: false, error: `Unknown method: ${method}` }); + } +} + +describe('CDP Support', () => { + beforeEach(() => { + jest.clearAllMocks(); + STATE.status = 'IDLE'; + STATE.mode = null; + STATE.recordingId = null; + STATE.includeMic = false; + STATE.includeSystemAudio = false; + }); + + describe('attachDebugger', () => { + it('should attach debugger when no existing attachment', async () => { + mockDebuggerGetTargets.mockResolvedValue([]); + await attachDebugger(); + expect(mockDebuggerAttach).toHaveBeenCalledWith({ tabId: undefined }, '1.3'); + }); + + it('should not attach if already attached', async () => { + mockDebuggerGetTargets.mockResolvedValue([ + { attached: true, url: 'chrome-extension://test-extension-id/' }, + ]); + await attachDebugger(); + expect(mockDebuggerAttach).not.toHaveBeenCalled(); + }); + + it('should handle attach errors gracefully', async () => { + mockDebuggerAttach.mockRejectedValue(new Error('Already attached')); + mockDebuggerGetTargets.mockResolvedValue([]); + await expect(attachDebugger()).resolves.not.toThrow(); + }); + }); + + describe('handleCDPCommand', () => { + beforeEach(() => { + global.crypto.randomUUID = () => 'test-uuid-' + Math.random().toString(36).slice(2); + }); + + it('should handle CaptureCast.start with default params', async () => { + const result = await handleCDPCommand('CaptureCast.start', {}); + expect(result.ok).toBe(true); + expect(STATE.status).toBe('RECORDING'); + expect(STATE.mode).toBe('tab'); + expect(STATE.includeMic).toBe(false); + expect(STATE.includeSystemAudio).toBe(false); + }); + + it('should handle CaptureCast.start with custom params', async () => { + const result = await handleCDPCommand('CaptureCast.start', { + mode: 'screen', + mic: true, + systemAudio: true, + }); + expect(result.ok).toBe(true); + expect(STATE.mode).toBe('screen'); + expect(STATE.includeMic).toBe(true); + expect(STATE.includeSystemAudio).toBe(true); + }); + + it('should handle CaptureCast.stop', async () => { + STATE.status = 'RECORDING'; + STATE.recordingId = 'test-id'; + const result = await handleCDPCommand('CaptureCast.stop', {}); + expect(result.ok).toBe(true); + expect(STATE.status).toBe('IDLE'); + }); + + it('should handle CaptureCast.getState', async () => { + STATE.status = 'RECORDING'; + STATE.mode = 'tab'; + const result = await handleCDPCommand('CaptureCast.getState', {}); + expect(result).toMatchObject({ + status: 'RECORDING', + mode: 'tab', + recording: true, + }); + }); + + it('should return error for unknown commands', async () => { + const result = await handleCDPCommand('Unknown.command', {}); + expect(result.ok).toBe(false); + expect(result.error).toBe('Unknown method: Unknown.command'); + }); + + it('should handle start when already recording', async () => { + STATE.status = 'RECORDING'; + const result = await handleCDPCommand('CaptureCast.start', {}); + expect(result.ok).toBe(false); + expect(result.error).toBe('Already recording or saving'); + }); + + it('should handle stop when not recording', async () => { + STATE.status = 'IDLE'; + const result = await handleCDPCommand('CaptureCast.stop', {}); + expect(result.ok).toBe(false); + expect(result.error).toBe('Not recording'); + }); + }); + + describe('CDP event listeners', () => { + it('should have addListener method on onEvent', () => { + expect(mockDebuggerOnEvent.addListener).toBeDefined(); + }); + + it('should have addListener method on onDetach', () => { + expect(mockDebuggerOnDetach.addListener).toBeDefined(); + }); + }); +}); From ce2afe34306c763bfe1292467cc66e868fc1bd6b Mon Sep 17 00:00:00 2001 From: lmn451 Date: Wed, 18 Mar 2026 15:47:35 +0200 Subject: [PATCH 3/3] feat: add external automation support with silent tab capture - Add chrome.runtime.onMessageExternal listener for external START/STOP - Add isAutomation flag to suppress preview tab for automation clients - Add silent mode using chrome.tabCapture for mode: 'tab' - Add GET_LAST_RECORDING_ID message handler - Add CaptureCast.getLastRecordingId CDP command - Add tabCapture permission to manifest - Add CDP.md documentation for programmatic control --- CDP.md | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 8 +- background.js | 90 ++++++++++++++++++--- manifest.json | 2 +- offscreen.js | 68 ++++++++++++---- recorder.js | 78 ++++++++++++++---- 6 files changed, 421 insertions(+), 43 deletions(-) create mode 100644 CDP.md diff --git a/CDP.md b/CDP.md new file mode 100644 index 0000000..a54e599 --- /dev/null +++ b/CDP.md @@ -0,0 +1,218 @@ +# Chrome DevTools Protocol (CDP) API + +CaptureCast exposes a programmatic control surface via the Chrome DevTools Protocol, allowing external applications and automation tools to start/stop recordings and query state. + +## Prerequisites + +The extension must be loaded and the debugger must be attached. See [Attaching the Debugger](#attaching-the-debugger) below. + +## Available Commands + +### `CaptureCast.start` + +Starts a new recording session. + +**Parameters:** + +```javascript +{ + mode?: 'tab' | 'screen' | 'window', // Recording source (default: 'tab') + mic?: boolean, // Include microphone audio (default: false) + systemAudio?: boolean, // Include system audio (default: false) + silent?: boolean // Use silent tab capture (default: true for tab mode) +} +``` + +**Response:** + +```javascript +{ ok: true, overlayInjected: boolean } +``` + +**Notes:** + +- `mode: 'tab'` uses `chrome.tabCapture` for silent capture (no picker required) +- `mode: 'screen'` or `'window'` uses `getDisplayMedia` which requires user selection +- When `silent: true` and `mode: 'tab'`, capture begins immediately without a picker +- Setting `silent: false` forces the picker even for tab mode + +### `CaptureCast.stop` + +Stops the active recording session. + +**Response:** + +```javascript +{ + ok: true; +} +``` + +**Notes:** + +- The recording is saved to IndexedDB (`CaptureCastDB` database, `chunks` object store) +- No preview tab is opened when controlled via CDP + +### `CaptureCast.getState` + +Returns the current recording state. + +**Response:** + +```javascript +{ + status: 'IDLE' | 'RECORDING' | 'SAVING', + mode: string | null, + recordingId: string | null, + recording: boolean, // Convenience: true if RECORDING or SAVING + isAutomation: boolean, + silentMode: boolean, + // ... other state fields +} +``` + +### `CaptureCast.getLastRecordingId` + +Returns the ID of the most recently completed recording. + +**Response:** + +```javascript +{ ok: true, recordingId: string | null } +``` + +--- + +## Attaching the Debugger + +Before using CDP commands, you must attach the debugger to the extension. + +```javascript +const EXTENSION_ID = 'your-extension-id-here'; + +// Attach to the extension +await chrome.debugger.attach({ tabId: undefined }, '1.3'); + +// Now you can send commands +chrome.debugger.sendCommand({ tabId: undefined }, 'CaptureCast.start', { + mode: 'tab', + silent: true, +}); +``` + +**Notes:** + +- `tabId: undefined` targets the extension's service worker background context +- A browser prompt will appear requesting permission to attach +- The debugger must be attached before sending any commands + +--- + +## Example: Full Recording Workflow + +```javascript +const EXTENSION_ID = 'your-extension-id-here'; + +async function recordWithCaptureCast() { + // 1. Attach debugger + await chrome.debugger.attach({ tabId: undefined }, '1.3'); + console.log('Debugger attached'); + + // 2. Start recording (silent tab capture) + await chrome.debugger.sendCommand({ tabId: undefined }, 'CaptureCast.start', { + mode: 'tab', + silent: true, + }); + console.log('Recording started'); + + // 3. Wait for recording duration + await new Promise((resolve) => setTimeout(resolve, 5000)); // 5 seconds + + // 4. Stop recording + await chrome.debugger.sendCommand({ tabId: undefined }, 'CaptureCast.stop'); + console.log('Recording stopped'); + + // 5. Get recording ID + const { result } = await chrome.debugger.sendCommand( + { tabId: undefined }, + 'CaptureCast.getLastRecordingId' + ); + const recordingId = result.recordingId; + console.log('Recording ID:', recordingId); + + // 6. Read recording from IndexedDB + // (Use a library like idb or raw IndexedDB API) + // Database: 'CaptureCastDB' + // Store: 'chunks' + // Schema: { recordingId: string, index: number, chunk: Blob } + + return recordingId; +} +``` + +--- + +## IndexedDB Schema + +After stopping, the recording chunks are stored in: + +- **Database:** `CaptureCastDB` +- **Version:** 3 +- **Object Store:** `chunks` +- **Key:** `['recordingId', 'index']` (compound) +- **Index:** `recordingId` (for filtering) + +**Record structure:** + +```javascript +{ + recordingId: string, // UUID of the recording + index: number, // Chunk order (0, 1, 2, ...) + chunk: Blob // Video data chunk (video/webm) +} +``` + +**Metadata is stored in:** + +- **Object Store:** `recordings` +- **Key:** `id` +- **Fields:** `id`, `mimeType`, `duration`, `size`, `createdAt`, `name` + +--- + +## Message Passing Alternative + +For simpler integrations, you can use `chrome.runtime.sendMessage` instead of CDP: + +```javascript +const EXTENSION_ID = 'your-extension-id-here'; + +// START +chrome.runtime.sendMessage(EXTENSION_ID, { + type: 'START', + mode: 'tab', + silent: true, +}); + +// STOP +chrome.runtime.sendMessage(EXTENSION_ID, { + type: 'STOP', +}); + +// GET LAST RECORDING ID +chrome.runtime.sendMessage(EXTENSION_ID, { + type: 'GET_LAST_RECORDING_ID', +}); +``` + +--- + +## Finding Your Extension ID + +The extension ID can be found: + +1. Open `chrome://extensions` +2. Find CaptureCast in the list +3. The ID is shown at the bottom of the extension card (e.g., `abcdefghijklmnopqrstuvwxyzabcdef`) + +For stable IDs across installations, publish the extension to the Chrome Web Store. diff --git a/README.md b/README.md index bc39194..cb066f4 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ If your build process outputs a different folder (e.g., `dist/` or `build/`), se - Manifest V3: ensure `background.service_worker` points to a valid path and that the file exists. - Manifest V2: ensure the background page/script paths are correct (if you are using MV2). -- Changes aren’t taking effect +- Changes aren't taking effect - Make sure you reload the extension after edits. For MV3 background code, also check the Service Worker DevTools and reload if needed. @@ -92,7 +92,11 @@ If your build process outputs a different folder (e.g., `dist/` or `build/`), se - You must select the folder that contains `manifest.json` (or your build output folder that includes `manifest.json`). - Host permissions - - If network requests are blocked or content scripts don’t run, verify that required host permissions are declared in `manifest.json` and that you’ve granted site access in the browser. + - If network requests are blocked or content scripts don't run, verify that required host permissions are declared in `manifest.json` and that you've granted site access in the browser. + +## Programmatic Control (CDP API) + +For automation and external integrations, CaptureCast exposes a control surface via Chrome DevTools Protocol. See [CDP.md](CDP.md) for documentation. ## Uninstall / Reinstall diff --git a/background.js b/background.js index a43baff..f6f2ba0 100644 --- a/background.js +++ b/background.js @@ -26,6 +26,8 @@ const STATE = { recorderTabId: null, strategy: null, // 'offscreen' | 'page' stopTimeoutId: null, + isAutomation: false, // true when controlled by external client + silentMode: false, // true when using tabCapture instead of getDisplayMedia }; // Helper to update badge based on status @@ -67,7 +69,8 @@ function handleCDPCommand(method, params) { return startRecording( params?.mode ?? 'tab', params?.mic ?? false, - params?.systemAudio ?? false + params?.systemAudio ?? false, + { automation: true, silent: params?.silent ?? false } ); case 'CaptureCast.stop': return stopRecording(); @@ -76,6 +79,8 @@ function handleCDPCommand(method, params) { ...STATE, recording: STATE.status === 'RECORDING' || STATE.status === 'SAVING', }); + case 'CaptureCast.getLastRecordingId': + return Promise.resolve({ ok: true, recordingId: STATE.recordingId }); default: return Promise.resolve({ ok: false, error: `Unknown method: ${method}` }); } @@ -174,10 +179,9 @@ async function focusTab(tabId) { } } -async function startRecording(mode, includeMic, includeSystemAudio) { +async function startRecording(mode, includeMic, includeSystemAudio, options = {}) { if (STATE.status !== 'IDLE') return { ok: false, error: 'Already recording or saving' }; - // Check storage quota before starting const storageCheck = await checkStorageQuota(); if (!storageCheck.ok) { logger.error('Storage check failed:', storageCheck.error); @@ -189,6 +193,8 @@ async function startRecording(mode, includeMic, includeSystemAudio) { STATE.overlayTabId = await getActiveTabId(); STATE.includeMic = !!includeMic; STATE.includeSystemAudio = !!includeSystemAudio; + STATE.isAutomation = !!options.automation; + STATE.silentMode = mode === 'tab' || !!options.silent; const useOffscreen = !STATE.includeMic && canUseOffscreen(); @@ -200,6 +206,7 @@ async function startRecording(mode, includeMic, includeSystemAudio) { includeAudio: STATE.includeSystemAudio, recordingId: STATE.recordingId, targetTabId: STATE.overlayTabId, + silent: STATE.silentMode, }); STATE.strategy = 'offscreen'; } else { @@ -207,7 +214,9 @@ async function startRecording(mode, includeMic, includeSystemAudio) { const url = chrome.runtime.getURL( `recorder.html?id=${encodeURIComponent(STATE.recordingId)}&mode=${encodeURIComponent( mode - )}&mic=${STATE.includeMic ? 1 : 0}&sys=${STATE.includeSystemAudio ? 1 : 0}` + )}&mic=${STATE.includeMic ? 1 : 0}&sys=${STATE.includeSystemAudio ? 1 : 0}&silent=${ + STATE.silentMode ? 1 : 0 + }` ); const tab = await chrome.tabs.create({ url, active: true }); STATE.recorderTabId = tab.id ?? null; @@ -305,6 +314,8 @@ async function resetRecordingState() { STATE.recorderTabId = null; STATE.strategy = null; STATE.recordingId = null; + STATE.isAutomation = false; + STATE.silentMode = false; } // Handle messages from popup, overlay, offscreen, and preview @@ -337,14 +348,16 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { case 'OFFSCREEN_DATA': { // Receive notification that data is saved in DB const { recordingId } = message; - logger.log('Received OFFSCREEN_DATA:', { recordingId }); + logger.log('Received OFFSCREEN_DATA:', { recordingId, isAutomation: STATE.isAutomation }); // Reset state await resetRecordingState(); - // Open preview page and pass the id in URL - const url = chrome.runtime.getURL(`preview.html?id=${encodeURIComponent(recordingId)}`); - await chrome.tabs.create({ url }); + // Skip preview tab for automation mode + if (!STATE.isAutomation) { + const url = chrome.runtime.getURL(`preview.html?id=${encodeURIComponent(recordingId)}`); + await chrome.tabs.create({ url }); + } await closeOffscreenDocumentIfIdle(); sendResponse({ ok: true }); break; @@ -352,12 +365,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { case 'RECORDER_DATA': { // Receive notification that data is saved in DB const { recordingId } = message; - logger.log('Received RECORDER_DATA:', { recordingId }); + logger.log('Received RECORDER_DATA:', { recordingId, isAutomation: STATE.isAutomation }); await resetRecordingState(); - const url = chrome.runtime.getURL(`preview.html?id=${encodeURIComponent(recordingId)}`); - await chrome.tabs.create({ url }); + // Skip preview tab for automation mode + if (!STATE.isAutomation) { + const url = chrome.runtime.getURL(`preview.html?id=${encodeURIComponent(recordingId)}`); + await chrome.tabs.create({ url }); + } sendResponse({ ok: true }); break; } @@ -393,6 +409,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { sendResponse({ ok: true, message: 'Test successful' }); break; } + case 'GET_LAST_RECORDING_ID': { + // Return the ID of the most recently completed recording (for automation clients) + sendResponse({ ok: true, recordingId: STATE.recordingId }); + break; + } default: { // Unknown message logger.log('Unknown message type:', message.type); @@ -411,6 +432,53 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // Keep message channel open for async response }); +// External message listener for automation clients +// Handles START/STOP from external sources (external extensions, apps, etc.) +chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { + logger.log('External message received:', message.type, 'from:', sender.id); + + (async () => { + try { + switch (message.type) { + case 'START': { + // External START - mark as automation mode + const res = await startRecording(message.mode, message.mic, message.systemAudio, { + automation: true, + silent: message.silent, + }); + sendResponse(res); + break; + } + case 'STOP': { + const res = await stopRecording(); + sendResponse(res); + break; + } + case 'GET_LAST_RECORDING_ID': { + sendResponse({ ok: true, recordingId: STATE.recordingId }); + break; + } + case 'GET_STATE': { + const publicState = { + ...STATE, + recording: STATE.status === 'RECORDING' || STATE.status === 'SAVING', + }; + sendResponse(publicState); + break; + } + default: { + logger.log('Unknown external message type:', message.type); + sendResponse({ ok: false, error: 'Unknown message type' }); + } + } + } catch (e) { + logger.error('Error handling external message', message.type, e); + sendResponse({ ok: false, error: String(e) }); + } + })(); + return true; +}); + // Clean badge on install/update and run cleanup chrome.runtime.onInstalled.addListener(async () => { await updateBadge(); diff --git a/manifest.json b/manifest.json index 010d8c6..283fb96 100644 --- a/manifest.json +++ b/manifest.json @@ -19,7 +19,7 @@ "128": "icons/icon-128.png" } }, - "permissions": ["activeTab", "scripting", "offscreen", "tabs", "debugger"], + "permissions": ["activeTab", "scripting", "offscreen", "tabs", "debugger", "tabCapture"], "background": { "service_worker": "background.js", "type": "module" diff --git a/offscreen.js b/offscreen.js index 8a0724c..807284f 100644 --- a/offscreen.js +++ b/offscreen.js @@ -63,31 +63,69 @@ function getConstraintsFromMode(mode, includeAudio) { }; } -async function startCapture(mode, recordingId, includeAudio) { +async function startCapture(mode, recordingId, includeAudio, silent = false) { if (mediaRecorder) throw new Error('Already recording'); currentId = recordingId; - logger.log('Starting capture with mode:', mode, 'includeAudio:', includeAudio); + logger.log('Starting capture with mode:', mode, 'includeAudio:', includeAudio, 'silent:', silent); + let stream; try { - logger.log('Requesting display media with audio:', includeAudio); - const displayStream = await navigator.mediaDevices.getDisplayMedia( - getConstraintsFromMode(mode, includeAudio) - ); + if (silent) { + logger.log('Using tabCapture for silent recording'); + try { + const [videoTrack, audioTrack] = await new Promise((resolve, reject) => { + chrome.tabCapture.capture({ video: true, audio: includeAudio || false }, (stream) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve( + stream ? [stream.getVideoTracks()[0], stream.getAudioTracks()[0]] : [null, null] + ); + } + }); + }); + + if (!videoTrack) { + throw new Error('Tab capture failed: no video track available'); + } + + const tracks = [videoTrack]; + if (audioTrack) tracks.push(audioTrack); + stream = new MediaStream(tracks); + + logger.log('Tab capture succeeded:', { + videoTrack: !!videoTrack, + audioTrack: !!audioTrack, + }); + } catch (tabCaptureError) { + logger.warn( + 'Tab capture failed, falling back to getDisplayMedia:', + tabCaptureError.message + ); + stream = await navigator.mediaDevices.getDisplayMedia( + getConstraintsFromMode(mode, includeAudio) + ); + } + } else { + logger.log('Requesting display media with audio:', includeAudio); + stream = await navigator.mediaDevices.getDisplayMedia( + getConstraintsFromMode(mode, includeAudio) + ); + } // Apply content hints for encoder optimization - applyContentHints(displayStream, { hasSystemAudio: includeAudio }); + applyContentHints(stream, { hasSystemAudio: includeAudio, hasMicrophone: false }); - logger.log('Got display stream:', { - id: displayStream.id, - active: displayStream.active, - videoTracks: displayStream.getVideoTracks().length, - audioTracks: displayStream.getAudioTracks().length, + logger.log('Got capture stream:', { + active: stream.active, + videoTracks: stream.getVideoTracks().length, + audioTracks: stream.getAudioTracks().length, }); - mediaStream = displayStream; + mediaStream = stream; } catch (error) { - logger.error('getDisplayMedia failed:', error); + logger.error('Capture failed:', error); throw error; } @@ -170,7 +208,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'OFFSCREEN_START') { try { logger.log('Received START message:', message); - await startCapture(message.mode, message.recordingId, message.includeAudio); + await startCapture(message.mode, message.recordingId, message.includeAudio, message.silent); logger.log('startCapture completed successfully'); sendResponse({ ok: true }); } catch (e) { diff --git a/recorder.js b/recorder.js index b442e43..47f0334 100644 --- a/recorder.js +++ b/recorder.js @@ -38,6 +38,7 @@ async function start() { recordingId = getQueryParam('id'); const wantMic = getQueryParam('mic') === '1'; const wantSys = getQueryParam('sys') === '1'; + const silent = getQueryParam('silent') === '1'; const status = document.getElementById('status'); const preview = document.getElementById('preview'); @@ -50,21 +51,70 @@ async function start() { throw new Error('Invalid recording ID'); } - status.textContent = 'Requesting screen capture…'; + status.textContent = silent ? 'Starting silent capture…' : 'Requesting screen capture…'; startBtn.classList.add('hidden'); - const displayStream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: wantSys - ? { - echoCancellation: false, - noiseSuppression: false, - autoGainControl: false, - } - : false, - }); + + let captureStream; + if (silent) { + logger.log('Using tabCapture for silent recording'); + try { + const tracks = []; + const videoTrack = await new Promise((resolve, reject) => { + chrome.tabCapture.capture({ video: true, audio: wantSys || wantMic }, (stream) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(stream?.getVideoTracks()[0] ?? null); + } + }); + }); + + if (!videoTrack) { + throw new Error('Tab capture failed: no video track available'); + } + tracks.push(videoTrack); + + const audioTrack = tracks[0].clone(); + const audioStream = new MediaStream([audioTrack]); + captureStream = audioStream; + if (captureStream.getAudioTracks().length > 0) { + captureStream = new MediaStream([videoTrack, ...captureStream.getAudioTracks()]); + } else { + captureStream = new MediaStream([videoTrack]); + } + + logger.log('Tab capture succeeded in recorder'); + } catch (tabCaptureError) { + logger.warn( + 'Tab capture failed, falling back to getDisplayMedia:', + tabCaptureError.message + ); + captureStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: wantSys + ? { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + } + : false, + }); + } + } else { + captureStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: wantSys + ? { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + } + : false, + }); + } // Apply content hints for encoder optimization - applyContentHints(displayStream, { hasSystemAudio: wantSys }); + applyContentHints(captureStream, { hasSystemAudio: wantSys, hasMicrophone: false }); let micStream = null; if (wantMic) { @@ -79,13 +129,13 @@ async function start() { video: false, }); // Apply content hints for encoder optimization - applyContentHints(micStream, { hasMicrophone: true }); + applyContentHints(micStream, { hasSystemAudio: wantSys, hasMicrophone: true }); } catch (e) { logger.warn('Mic request failed, proceeding without mic:', e); } } - mediaStream = combineStreams({ displayStream, micStream }); + mediaStream = combineStreams({ displayStream: captureStream, micStream }); preview.srcObject = mediaStream; preview.classList.remove('hidden'); stopBtn.classList.remove('hidden');