return [...hardware, this.virtualDevice];
};
md.getUserMedia = async (constraints) => {
const requestedId = this.extractDeviceId(constraints);
if (requestedId === this.virtualDevice.deviceId) {
return this.streamProvider(null) as Promise<MediaStream>;
}
return this.originalGetUserMedia(constraints);
};
md.dispatchEvent(new Event('devicechange'));
}
private extractDeviceId(constraints: MediaStreamConstraints): string | undefined {
const video = constraints.video as MediaTrackConstraints | boolean;
if (typeof video === 'object' && video.deviceId) {
return typeof video.deviceId === 'string'
? video.deviceId
: video.deviceId.exact;
}
return undefined;
}
}
**Why this works:** Binding the original functions prevents infinite recursion. The `devicechange` event forces UI components that listen for hardware updates to refresh their device selectors. Extracting `deviceId` handles both string and constraint object formats used by different conferencing libraries.
### Step 2: Serverless WebRTC Signaling via Drive `appdata`
WebRTC requires an offer/answer exchange and ICE candidate routing. Instead of maintaining a signaling server, the Google Drive `appdata` folder acts as a scoped mailbox. Both the desktop extension and mobile PWA share the same OAuth token, granting read/write access only to this hidden directory.
```typescript
// drive-signaling-channel.ts
import { gapi } from 'gapi-client';
interface SignalingPayload {
type: 'offer' | 'answer';
sdp: string;
sessionId: string;
}
export class DriveSignalingChannel {
private readonly folderId = 'appDataFolder';
private readonly token: string;
constructor(authToken: string) {
this.token = authToken;
gapi.client.init({
discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'],
token: this.token
});
}
async publishOffer(sessionId: string, sdp: string): Promise<string> {
const payload: SignalingPayload = { type: 'offer', sdp, sessionId };
const response = await gapi.client.drive.files.create({
resource: {
name: `${sessionId}-offer.json`,
mimeType: 'application/json',
parents: [this.folderId]
},
media: {
mimeType: 'application/json',
body: JSON.stringify(payload)
}
});
return response.result.id as string;
}
async consumeOffer(fileId: string): Promise<SignalingPayload> {
const response = await gapi.client.drive.files.get({
fileId,
alt: 'media'
});
return JSON.parse(response.body) as SignalingPayload;
}
async publishAnswer(sessionId: string, sdp: string): Promise<void> {
const payload: SignalingPayload = { type: 'answer', sdp, sessionId };
await gapi.client.drive.files.create({
resource: {
name: `${sessionId}-answer.json`,
mimeType: 'application/json',
parents: [this.folderId]
},
media: {
mimeType: 'application/json',
body: JSON.stringify(payload)
}
});
}
async consumeAnswer(sessionId: string): Promise<SignalingPayload | null> {
const response = await gapi.client.drive.files.list({
q: `name contains '${sessionId}' and name contains 'answer'`,
spaces: this.folderId,
pageSize: 1
});
const files = response.result.files ?? [];
if (files.length === 0) return null;
const answerFile = files[0];
const raw = await gapi.client.drive.files.get({
fileId: answerFile.id!,
alt: 'media'
});
await gapi.client.drive.files.delete({ fileId: answerFile.id! });
return JSON.parse(raw.body) as SignalingPayload;
}
}
Why this works: The appdata folder is invisible to users and scoped to the extension's OAuth client ID. Embedding the token in the QR code payload eliminates separate authentication steps. Cleaning up files after consumption prevents mailbox bloat and maintains security hygiene.
Step 3: Offscreen Document for Persistent Peer Connections
Chrome service workers cannot host WebRTC connections. The chrome.offscreen API provides a hidden DOM context where RTCPeerConnection can run persistently. The offscreen document manages the long-lived connection, while the content script handles stream injection.
// offscreen-peer-manager.ts
export class OffscreenPeerManager {
private peer: RTCPeerConnection;
private streamRouter: (stream: MediaStream | null) => void;
constructor(router: (stream: MediaStream | null) => void) {
this.streamRouter = router;
this.peer = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
this.configureTransceivers();
this.attachTrackListeners();
}
private configureTransceivers(): void {
this.peer.addTransceiver('video', { direction: 'recvonly' });
this.peer.addTransceiver('audio', { direction: 'recvonly' });
}
private attachTrackListeners(): void {
this.peer.ontrack = ({ transceiver }) => {
const track = transceiver.receiver.track;
track.onmute = () => this.streamRouter(null);
track.onunmute = () => {
const stream = new MediaStream([track]);
this.streamRouter(stream);
};
track.onended = () => this.streamRouter(null);
};
}
async createOffer(): Promise<RTCSessionDescriptionInit> {
const offer = await this.peer.createOffer();
await this.peer.setLocalDescription(offer);
return offer;
}
async applyAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
await this.peer.setRemoteDescription(new RTCSessionDescription(answer));
}
async addIceCandidates(candidates: RTCIceCandidateInit[]): Promise<void> {
for (const candidate of candidates) {
await this.peer.addIceCandidate(new RTCIceCandidate(candidate));
}
}
}
Why this works: recvonly transceivers prevent the extension from accidentally capturing local media. Track events (mute, unmute, ended) map directly to stream lifecycle states, allowing the content script to swap between live video and fallback placeholders without breaking the DOM.
Step 4: Architecture Flow & Rationale
The system operates across four isolated contexts:
- Side Panel (React UI): Handles OAuth flow, QR generation, and connection status. Communicates exclusively with the service worker via
chrome.runtime.sendMessage.
- Service Worker: Acts as a stateless dispatcher. Routes messages between UI, offscreen document, and content scripts. Never touches media streams.
- Offscreen Document: Hosts
RTCPeerConnection. Manages offer/answer exchange, ICE routing, and track state. Exposes a MediaStream reference to the content script via message passing.
- Content Script: Injected into target domains. Runs
VirtualCameraBridge to patch navigator.mediaDevices. Receives the stream from the offscreen document and binds it to the virtual device ID.
This separation enforces security boundaries. The content script operates in the page's origin context and cannot access extension storage or OAuth tokens. The offscreen document runs in an extension context but lacks direct DOM access to conferencing UIs. The service worker bridges them without media exposure.
Pitfall Guide
1. Race Condition in document_start Injection
Explanation: If the content script patches navigator.mediaDevices after the page has already cached the device list, the virtual camera won't appear. Some SPAs initialize media APIs during hydration, bypassing early patches.
Fix: Use runAt: 'document_start' in the manifest. Wrap the patch in a try/catch and implement a fallback MutationObserver that reapplies the patch if navigator.mediaDevices is replaced by framework code.
2. Ignoring devicechange Event Dispatch
Explanation: Conferencing platforms often subscribe to navigator.mediaDevices.ondevicechange. Without dispatching this event, UI components won't refresh their dropdowns.
Fix: Always call dispatchEvent(new Event('devicechange')) immediately after patching. For SPAs, debounce the event to prevent UI thrashing during rapid state updates.
3. OAuth Token Exposure in QR Payload
Explanation: Embedding a raw OAuth token in a QR code creates a security surface. If the token has broad scopes, compromised scans could expose user data.
Fix: Scope tokens strictly to https://www.googleapis.com/auth/drive.appdata. Implement short-lived tokens (15-30 min) with automatic refresh. Validate the token's aud and exp claims on the mobile side before initializing the Drive client.
4. WebRTC Track State Drift
Explanation: RTCPeerConnection tracks can enter mute/unmute states independently of stream availability. Failing to handle track.onended causes memory leaks and stale stream references.
Fix: Listen to all three track events. Reconstruct MediaStream objects on unmute. Nullify references on mute or ended. Implement a heartbeat check that verifies track.readyState === 'live' before routing.
5. Offscreen Document Lifecycle Leaks
Explanation: Chrome limits offscreen documents to one per extension. Attempting to create a second document throws an error. Failing to clean up on disconnect leaves orphaned contexts.
Fix: Always check await chrome.offscreen.hasDocument() before creation. Implement a closeOffscreen() method that calls chrome.offscreen.closeDocument() on session teardown. Store the document URL in chrome.storage.local for recovery.
6. Drive API Rate Limiting & Polling Overhead
Explanation: Continuous polling for answer files triggers quota limits. The appdata folder shares rate limits with other Drive API calls.
Fix: Implement exponential backoff with jitter (initial: 500ms, max: 5s). Switch to a lightweight WebSocket or Server-Sent Events if polling exceeds 10 attempts. Cache file IDs locally to avoid redundant files.list calls.
7. CORS/Drive Download Restrictions
Explanation: gapi.client.drive.files.get without alt: 'media' returns metadata, not the file content. Some environments block blob responses due to MIME type mismatches.
Fix: Always include alt: 'media' in the request. Validate response.headers['content-type'] before parsing. Wrap JSON parsing in try/catch to handle corrupted or partially uploaded payloads.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal enterprise tool | Extension + Mobile PWA | Zero infrastructure, strict OAuth scoping, E2E encryption | $0 (uses existing Drive quota) |
| Public SaaS product | Cloud SFU (e.g., LiveKit, Twilio) | Scalable, handles NAT traversal, provides recording/transcription | $50-$500/mo based on concurrency |
| High-security regulated environment | OBS Virtual Camera + Local Capture | No network exposure, air-gapped compatible, audit-friendly | $0 (open source) |
| Cross-browser requirement (Firefox/Safari) | Native App + USB Bridge | Extension APIs are Chromium-only, USB provides universal fallback | $5k-$15k dev cost |
Configuration Template
// manifest.json
{
"manifest_version": 3,
"name": "Virtual Camera Bridge",
"version": "1.0.0",
"permissions": ["offscreen", "storage", "sidePanel"],
"host_permissions": [
"https://meet.google.com/*",
"https://teams.microsoft.com/*",
"https://zoom.us/*"
],
"background": {
"service_worker": "service-worker.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.js"],
"run_at": "document_start",
"all_frames": false
}
],
"side_panel": {
"default_path": "side-panel.html"
},
"offscreen": {
"contexts": ["WEB_RTC"]
}
}
// gapi-config.ts
export const DRIVE_API_CONFIG = {
apiKey: process.env.GOOGLE_API_KEY,
clientId: process.env.GOOGLE_CLIENT_ID,
scope: 'https://www.googleapis.com/auth/drive.appdata',
discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'],
immediate: false
};
Quick Start Guide
- Initialize the extension project: Run
npm create vite@latest virtual-camera -- --template vanilla-ts and configure the manifest.json with the template above.
- Set up Google Cloud credentials: Create a project in Google Cloud Console, enable the Drive API, configure OAuth 2.0 Client ID (Web application), and restrict scopes to
drive.appdata. Store keys in .env.
- Implement the signaling channel: Copy
DriveSignalingChannel and VirtualCameraBridge into your project. Wire the service worker to route messages between the side panel and offscreen document.
- Test locally: Load the unpacked extension in Chrome, open
chrome://extensions, enable "Developer mode", and click "Load unpacked". Navigate to a test conferencing page, scan the generated QR code with your mobile device, and verify the virtual camera appears in the device selector.