Chrome Extension Desktop Capture — Best Practices
43 min readDesktop Capture API Patterns
Overview
Chrome’s chrome.desktopCapture API enables extensions to capture screen content, windows, and tabs. This guide covers eight production-ready patterns for screen recording, screenshots, window picking, audio capture, live preview, WebRTC streaming, and privacy-safe implementation.
Required Permissions
// manifest.json
{
"permissions": ["desktopCapture"],
// Optional: for capturing specific tab audio
"permissions": ["desktopCapture", "tabs"]
}
The desktopCapture permission is required in the manifest. Note that this permission doesn’t require host permissions — the actual capture is triggered by user action via chrome.desktopCapture.chooseDesktopMedia(), which always shows a user picker.
Pattern 1: DesktopCapture API Basics
The chrome.desktopCapture.chooseDesktopMedia() method displays a system picker UI where users select what to share:
// capture/basic.ts
interface CaptureOptions {
types: Array<"screen" | "window" | "tab" | "audio">;
thumbnailSize?: { width: number; height: number };
normalizeWindowTitle?: boolean;
}
async function startBasicCapture(
types: Array<"screen" | "window" | "tab"> = ["screen", "window", "tab"]
): Promise<{ streamId: string; stream: MediaStream } | null> {
// Request capture via the desktop capture picker
// Note: chooseDesktopMedia is callback-based, not promise-based
const streamId = await new Promise<string>((resolve) => {
chrome.desktopCapture.chooseDesktopMedia(types, (id) => resolve(id));
});
// User cancelled — streamId is empty
if (!streamId) {
console.log("User cancelled capture selection");
return null;
}
// Convert streamId to actual MediaStream using getUserMedia
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
// Use the streamId from desktop capture as the media source
// The mandatory constraint is required for desktop capture
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
// Optionally constrain dimensions
minWidth: 1280,
maxWidth: 1920,
minHeight: 720,
maxHeight: 1080,
},
} as MediaTrackConstraints,
});
return { streamId, stream };
}
// Example: simple screen capture button in popup
document.getElementById("capture-screen")!.addEventListener("click", async () => {
const result = await startBasicCapture(["screen"]);
if (result) {
console.log("Capturing screen with stream ID:", result.streamId);
// Keep reference to stop later
window.currentStream = result.stream;
}
});
Key points:
chooseDesktopMedia()must be called from a user-initiated context (button click, keyboard shortcut)- Returns a
streamIdstring, not a MediaStream directly - The
streamIdis converted to aMediaStreamvianavigator.mediaDevices.getUserMedia() - The
chromeMediaSource: "desktop"andchromeMediaSourceIdconstraints are required - If the user cancels, the callback receives
nullor an empty string
Pattern 2: Screen Recording Extension
Recording screen capture to a file requires combining desktop capture with the MediaRecorder API in an offscreen document:
// capture/recorder.ts
import { createMessenger } from "@theluckystrike/webext-messaging";
type RecorderMessages = {
"recorder:start": {
request: { streamId: string; mimeType?: string };
response: { recordingId: string };
};
"recorder:stop": {
request: { recordingId: string };
response: { blob: Blob; duration: number };
};
"recorder:status": {
request: { recordingId: string };
response: { isRecording: boolean; duration: number };
};
};
const messenger = createMessenger<RecorderMessages>();
class ScreenRecorder {
private activeRecordings = new Map<string, MediaRecorder>();
private startTimes = new Map<string, number>();
async startRecording(streamId: string, mimeType = "video/webm;codecs=vp9"):
Promise<string> {
// Create media stream from streamId
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
},
} as MediaTrackConstraints,
});
const recordingId = crypto.randomUUID();
const mimeSupported = MediaRecorder.isTypeSupported(mimeType);
const actualMimeType = mimeSupported ? mimeType : "video/webm";
const mediaRecorder = new MediaRecorder(stream, {
mimeType: actualMimeType,
videoBitsPerSecond: 5000000, // 5 Mbps
});
const chunks: Blob[] = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
mediaRecorder.start(1000); // Capture in 1-second chunks
this.activeRecordings.set(recordingId, mediaRecorder);
this.startTimes.set(recordingId, Date.now());
// Auto-stop when stream ends (e.g., user stops sharing)
stream.getVideoTracks()[0].onended = () => {
this.stopRecording(recordingId);
};
return recordingId;
}
async stopRecording(recordingId: string): Promise<{ blob: Blob; duration: number }> {
const recorder = this.activeRecordings.get(recordingId);
if (!recorder) {
throw new Error(`Recording ${recordingId} not found`);
}
return new Promise((resolve) => {
recorder.onstop = () => {
const chunks = this.chunks.get(recordingId) ?? [];
const blob = new Blob(chunks, { type: recorder.mimeType });
const duration = Date.now() - (this.startTimes.get(recordingId) ?? 0);
this.activeRecordings.delete(recordingId);
this.startTimes.delete(recordingId);
this.chunks.delete(recordingId);
resolve({ blob, duration });
};
recorder.stop();
});
}
private chunks = new Map<string, Blob[]>();
}
const recorder = new ScreenRecorder();
// Offscreen document message handlers
messenger.onMessage("recorder:start", async ({ streamId, mimeType }) => {
const recordingId = await recorder.startRecording(streamId, mimeType);
return { recordingId };
});
messenger.onMessage("recorder:stop", async ({ recordingId }) => {
return recorder.stopRecording(recordingId);
});
messenger.onMessage("recorder:status", ({ recordingId }) => {
const isRecording = recorder.activeRecordings.has(recordingId);
const startTime = recorder.startTimes.get(recordingId);
const duration = startTime ? Date.now() - startTime : 0;
return { isRecording, duration };
});
Service worker integration:
// background.ts
import { offscreen } from "./offscreen-manager";
import { messenger } from "./capture/recorder";
async function startScreenRecording(): Promise<string> {
// First, get streamId from the picker
const streamId = await chrome.desktopCapture.chooseDesktopMedia([
"screen",
"window",
]);
if (!streamId) {
throw new Error("User cancelled capture");
}
// Delegate recording to offscreen document
await offscreen.ensure(
chrome.offscreen.Reason.MEDIA_RECORDING,
"Record screen capture to file"
);
const result = await messenger.send("recorder:start", {
streamId,
mimeType: "video/webm;codecs=vp9",
});
return result.recordingId;
}
async function stopScreenRecording(recordingId: string): Promise<Blob> {
const result = await messenger.send("recorder:stop", { recordingId });
return result.blob;
}
// Save recorded blob to downloads
async function saveRecording(blob: Blob, filename: string): Promise<void> {
const url = URL.createObjectURL(blob);
await chrome.downloads.download({
url,
filename,
saveAs: true,
});
URL.revokeObjectURL(url);
}
Pattern 3: Screenshot from Desktop Capture
Capture a single frame from a video stream:
// capture/screenshot.ts
async function captureScreenshot(streamId: string): Promise<Blob> {
// Create stream with higher resolution for screenshots
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
// Request maximum available resolution
minWidth: 1920,
minHeight: 1080,
maxWidth: 3840,
maxHeight: 2160,
},
} as MediaTrackConstraints,
});
const video = document.createElement("video");
video.srcObject = stream;
await video.play();
// Wait for metadata to ensure dimensions are available
await new Promise<void>((resolve) => {
if (video.videoWidth > 0) {
resolve();
} else {
video.onloadedmetadata = () => resolve();
}
});
// Create canvas at actual video dimensions
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(video, 0, 0);
// Stop all tracks to release capture
stream.getTracks().forEach((track) => track.stop());
// Export as PNG
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), "image/png");
});
}
// With crop and annotation support
interface ScreenshotOptions {
streamId: string;
crop?: { x: number; y: number; width: number; height: number };
annotations?: Array<{
type: "rect" | "text" | "arrow";
x: number;
y: number;
width?: number;
height?: number;
text?: string;
color: string;
}>;
format?: "png" | "jpeg";
quality?: number;
}
async function captureWithOptions(options: ScreenshotOptions): Promise<Blob> {
const { streamId, crop, annotations, format = "png", quality = 0.92 } = options;
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
},
} as MediaTrackConstraints,
});
const video = document.createElement("video");
video.srcObject = stream;
await video.play();
await new Promise<void>((resolve) => {
if (video.videoWidth > 0) resolve();
else video.onloadedmetadata = () => resolve();
});
// Determine dimensions (use crop or full video)
const srcWidth = video.videoWidth;
const srcHeight = video.videoHeight;
const cropX = crop?.x ?? 0;
const cropY = crop?.y ?? 0;
const cropWidth = crop?.width ?? srcWidth;
const cropHeight = crop?.height ?? srcHeight;
const canvas = document.createElement("canvas");
canvas.width = cropWidth;
canvas.height = cropHeight;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(video, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
// Add annotations
if (annotations) {
for (const ann of annotations) {
ctx.strokeStyle = ann.color;
ctx.fillStyle = ann.color;
ctx.lineWidth = 3;
if (ann.type === "rect" && ann.width && ann.height) {
ctx.strokeRect(ann.x, ann.y, ann.width, ann.height);
} else if (ann.type === "text" && ann.text) {
ctx.font = "16px sans-serif";
ctx.fillText(ann.text, ann.x, ann.y);
} else if (ann.type === "arrow") {
// Simple arrow drawing
ctx.beginPath();
ctx.moveTo(ann.x, ann.y);
ctx.lineTo(ann.x + (ann.width ?? 50), ann.y + (ann.height ?? 0));
ctx.stroke();
}
}
}
stream.getTracks().forEach((track) => track.stop());
return new Promise((resolve) => {
const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
const q = format === "jpeg" ? quality : undefined;
canvas.toBlob((blob) => resolve(blob!), mimeType, q);
});
}
Pattern 4: Window Picker UI
Configure the picker to filter to specific source types:
// capture/window-picker.ts
interface SourceFilter {
types: Array<"screen" | "window" | "tab">;
excludeSources?: Array<"screen" | "window" | "tab">;
// For tabs: require explicit user gesture via --enable-features=MediaRouter
}
class WindowPicker {
async pickWindow(): Promise<string | null> {
// Only show windows (not entire screens)
return chrome.desktopCapture.chooseDesktopMedia(["window"]);
}
async pickScreen(): Promise<string | null> {
// Only show screens (not windows)
return chrome.desktopCapture.chooseDesktopMedia(["screen"]);
}
async pickTab(): Promise<string | null> {
// Only show tabs
return chrome.desktopCapture.chooseDesktopMedia(["tab"]);
}
async pickAny(): Promise<string | null> {
// Show all options: screen, window, tab
return chrome.desktopCapture.chooseDesktopMedia(["screen", "window", "tab"]);
}
// Pick with custom constraints
async pickWithConstraints(
types: Array<"screen" | "window" | "tab">,
options: {
thumbnailSize?: { width: number; height: number };
normalizeWindowTitle?: boolean;
} = {}
): Promise<string | null> {
return chrome.desktopCapture.chooseDesktopMedia(types, undefined, options);
}
// Handle user cancellation gracefully
async safePick(types: Array<"screen" | "window" | "tab">):
Promise<{ streamId: string; cancelled: boolean }> {
try {
const streamId = await chrome.desktopCapture.chooseDesktopMedia(types);
return { streamId: streamId ?? "", cancelled: !streamId };
} catch (error) {
// API may throw on some error conditions
console.error("Desktop capture error:", error);
return { streamId: "", cancelled: true };
}
}
}
const picker = new WindowPicker();
// Usage in popup
document.getElementById("pick-window")!.addEventListener("click", async () => {
const result = await picker.safePick(["window"]);
if (result.cancelled) {
console.log("User cancelled window selection");
return;
}
console.log("Selected window stream ID:", result.streamId);
});
// Usage: filter to only windows with specific properties
document.getElementById("pick-screen-only")!.addEventListener("click", async () => {
// Using optional third parameter for thumbnail size
const streamId = await chrome.desktopCapture.chooseDesktopMedia(
["screen"],
undefined,
{
thumbnailSize: { width: 320, height: 180 },
normalizeWindowTitle: true,
}
);
if (streamId) {
// Proceed with capture
}
});
Note: The
chooseDesktopMediaAPI doesn’t directly support filtering sources client-side. To limit the capture options, pass a filtered array ofDesktopCaptureSourceTypevalues (e.g.,["screen"]or["window"]). There is nogetSourcesmethod on thechrome.desktopCaptureAPI.
Pattern 5: Audio Capture
Capturing system audio or tab audio requires specific constraints:
// capture/audio.ts
async function captureWithAudio(
source: "screen" | "window" | "tab",
captureSystemAudio: boolean = false
): Promise<MediaStream> {
const constraints: MediaStreamConstraints = {
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: "", // Will be set after picker
},
} as MediaTrackConstraints,
};
// Get streamId first
const streamId = await chrome.desktopCapture.chooseDesktopMedia([
source,
...(captureSystemAudio ? ["audio"] : []),
]);
if (!streamId) {
throw new Error("User cancelled or no sources available");
}
// Set the video source
constraints.video!.mandatory!.chromeMediaSourceId = streamId;
// Audio constraints differ based on source type
if (captureSystemAudio) {
// System audio (all sources)
constraints.audio = {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
// Echo cancellation must be off for desktop capture
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
} as MediaTrackConstraints;
} else if (source === "tab") {
// Tab audio only
constraints.audio = {
mandatory: {
chromeMediaSource: "tab",
// Specific tab ID would be needed here
},
} as MediaTrackConstraints;
}
return navigator.mediaDevices.getUserMedia(constraints);
}
// Audio-only capture for system audio
async function captureSystemAudioOnly(): Promise<MediaStream> {
const streamId = await chrome.desktopCapture.chooseDesktopMedia([
"screen",
"window",
"tab",
"audio",
]);
if (!streamId) {
throw new Error("User cancelled");
}
return navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
} as MediaTrackConstraints,
video: false,
});
}
// Audio capture with specific tab
async function captureTabAudio(tabId: number): Promise<MediaStream> {
// Using chrome.tabCapture with constraints
return chrome.tabCapture.capture({
audio: {
mandatory: {
chromeMediaSource: "tab",
chromeMediaSourceTabId: tabId,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
} as MediaTrackConstraints,
video: false,
});
}
// Note: chrome.desktopCapture does not have a getSources method.
// To capture audio, use chooseDesktopMedia with the desired source types
// and request audio in the getUserMedia constraints.
async function captureWithAudio(sourceTypes: chrome.desktopCapture.DesktopCaptureSourceType[]): Promise<string> {
return new Promise((resolve) => {
chrome.desktopCapture.chooseDesktopMedia(sourceTypes, (streamId) => {
resolve(streamId);
});
});
}
Important: System audio capture is only available on Chrome OS, Linux, and Windows. macOS does not support capturing system audio through the Desktop Capture API.
Pattern 6: Live Preview
Display captured content in the extension popup or a floating PiP window:
// capture/preview.ts
class CapturePreview {
private videoElement: HTMLVideoElement | null = null;
private pipWindow: PictureInPictureWindow | null = null;
// Show preview in a provided video element
async showInElement(
videoElement: HTMLVideoElement,
streamId: string,
options: {
muted?: boolean;
autoplay?: boolean;
} = {}
): Promise<MediaStream> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
// Control frame rate for preview performance
minFrameRate: 15,
maxFrameRate: 30,
},
} as MediaTrackConstraints,
});
videoElement.srcObject = stream;
videoElement.muted = options.muted ?? true;
videoElement.autoplay = options.autoplay ?? true;
this.videoElement = videoElement;
return stream;
}
// Request Picture-in-Picture for floating preview
async enterPictureInPicture(videoElement: HTMLVideoElement): Promise<void> {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
}
// Ensure video is playing before entering PiP
if (videoElement.paused) {
await videoElement.play();
}
await videoElement.requestPictureInPicture();
} catch (error) {
console.error("Failed to enter PiP:", error);
}
}
// Exit Picture-in-Picture
async exitPictureInPicture(): Promise<void> {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
}
}
// Create a floating preview window (for more control)
async openFloatingPreview(streamId: string): Promise<Window> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
maxFrameRate: 15, // Lower frame rate for preview
},
} as MediaTrackConstraints,
});
// Create preview in new window
const previewWindow = window.open(
"",
"Capture Preview",
"width=640,height=360,menubar=no,toolbar=no"
);
if (!previewWindow) {
throw new Error("Failed to open preview window");
}
// Write preview HTML
previewWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; background: #000; overflow: hidden; }
video { width: 100%; height: 100%; object-fit: contain; }
</style>
</head>
<body>
<video id="preview" autoplay playsinline></video>
<script>
const video = document.getElementById('preview');
const stream = new MediaStream([
...${JSON.stringify(stream.getVideoTracks().map((t) => t.id))}
.map(id => window.opener.__getTrackById(id))
].filter(Boolean));
video.srcObject = stream;
</script>
</body>
</html>
`);
return previewWindow;
}
// Clean up preview
stop(): void {
if (this.videoElement?.srcObject) {
const tracks = (this.videoElement.srcObject as MediaStream).getTracks();
tracks.forEach((track) => track.stop());
this.videoElement.srcObject = null;
}
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
}
}
}
// Usage in side panel
async function setupSidePanelPreview(sidePanel: chrome.sidePanel) {
const preview = new CapturePreview();
document.getElementById("start-preview")!.addEventListener("click", async () => {
const streamId = await chrome.desktopCapture.chooseDesktopMedia([
"screen",
"window",
"tab",
]);
if (!streamId) return;
const videoElement = document.getElementById("preview") as HTMLVideoElement;
await preview.showInElement(videoElement, streamId);
});
document.getElementById("pip-button")!.addEventListener("click", async () => {
const videoElement = document.getElementById("preview") as HTMLVideoElement;
await preview.enterPictureInPicture(videoElement);
});
// Clean up when side panel closes
sidePanel.onClose.addListener(() => {
preview.stop();
});
}
Pattern 7: Streaming to WebRTC
Broadcast captured content to remote peers using WebRTC:
// capture/webrtc.ts
interface PeerConnection {
pc: RTCPeerConnection;
dataChannel: RTCDataChannel | null;
}
class DesktopCaptureStreamer {
private connections: Map<string, PeerConnection> = new Map();
private localStream: MediaStream | null = null;
// Configuration for the peer connection
private rtcConfig: RTCConfiguration = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
};
async startCaptureAndStream(
peerId: string,
sourceTypes: Array<"screen" | "window" | "tab"> = ["screen"]
): Promise<string> {
// Get stream from desktop capture
const streamId = await chrome.desktopCapture.chooseDesktopMedia(sourceTypes);
if (!streamId) {
throw new Error("User cancelled capture selection");
}
this.localStream = await navigator.mediaDevices.getUserMedia({
audio: true, // Include audio if desired
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
maxFrameRate: 30,
},
} as MediaTrackConstraints,
});
// Create peer connection
const pc = new RTCPeerConnection(this.rtcConfig);
// Add local tracks to the connection
this.localStream.getTracks().forEach((track) => {
pc.addTrack(track, this.localStream!);
});
// Create data channel for metadata/controls
const dataChannel = pc.createDataChannel("controls", {
ordered: true,
});
dataChannel.onopen = () => {
console.log(`Data channel open for peer: ${peerId}`);
};
dataChannel.onmessage = (event) => {
this.handleControlMessage(peerId, JSON.parse(event.data));
};
// Handle incoming tracks from remote peer (for bi-directional)
pc.ontrack = (event) => {
this.onRemoteTrack?.(event.streams[0]);
};
// Create and set local description (offer)
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// In a real implementation, send offer to signaling server
// and receive answer from remote peer
const answer = await this.sendOfferToSignalingServer(pc.localDescription!, peerId);
await pc.setRemoteDescription(answer);
this.connections.set(peerId, { pc, dataChannel });
return streamId;
}
private async sendOfferToSignalingServer(
offer: RTCSessionDescriptionInit,
peerId: string
): Promise<RTCSessionDescriptionInit> {
// Placeholder for actual signaling server integration
const response = await fetch("/signaling/offer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ offer, peerId }),
});
const { answer } = await response.json();
return answer;
}
private handleControlMessage(peerId: string, message: { type: string; value?: unknown }): void {
switch (message.type) {
case "pause":
this.pauseStream(peerId);
break;
case "resume":
this.resumeStream(peerId);
break;
case "get-status":
this.sendStatus(peerId);
break;
}
}
pauseStream(peerId: string): void {
const connection = this.connections.get(peerId);
if (connection) {
this.localStream?.getTracks().forEach((track) => {
track.enabled = false;
});
connection.dataChannel?.send(JSON.stringify({ type: "paused" }));
}
}
resumeStream(peerId: string): void {
const connection = this.connections.get(peerId);
if (connection) {
this.localStream?.getTracks().forEach((track) => {
track.enabled = true;
});
connection.dataChannel?.send(JSON.stringify({ type: "resumed" }));
}
}
async stopStream(peerId: string): Promise<void> {
const connection = this.connections.get(peerId);
if (connection) {
connection.pc.close();
this.connections.delete(peerId);
}
}
stopAllStreams(): void {
this.connections.forEach((_, peerId) => this.stopStream(peerId));
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop());
this.localStream = null;
}
}
sendStatus(peerId: string): void {
const connection = this.connections.get(peerId);
if (connection?.dataChannel?.readyState === "open") {
connection.dataChannel.send(
JSON.stringify({
type: "status",
isPlaying: this.localStream?.getTracks()[0]?.enabled ?? false,
trackCount: this.localStream?.getTracks().length ?? 0,
})
);
}
}
// Callback for handling incoming remote streams
onRemoteTrack?: (stream: MediaStream) => void;
}
// Usage
const streamer = new DesktopCaptureStreamer();
document.getElementById("start-stream")!.addEventListener("click", async () => {
try {
const streamId = await streamer.startCaptureAndStream("peer-123", ["screen"]);
console.log("Started streaming:", streamId);
} catch (error) {
console.error("Failed to start streaming:", error);
}
});
document.getElementById("stop-stream")!.addEventListener("click", () => {
streamer.stopAllStreams();
});
Pattern 8: Permission and Privacy Patterns
Privacy-safe implementation with consent indicators and auto-stop:
// capture/privacy.ts
class PrivacyAwareCapture {
private activeCapture = false;
private captureStartTime: number | null = null;
// Show capture indicator (badge + notification)
async enableCaptureIndicators(tabId: number): Promise<void> {
// Set badge to indicate active capture
chrome.action.setBadgeText({ text: "REC", tabId });
chrome.action.setBadgeBackgroundColor({ color: "#FF0000", tabId });
// Show notification that capture is active
await chrome.notifications.create({
type: "basic",
iconUrl: "icons/icon48.png",
title: "Screen Capture Active",
message: "Recording or streaming is in progress",
priority: 1,
});
this.activeCapture = true;
this.captureStartTime = Date.now();
}
// Disable indicators
async disableCaptureIndicators(tabId: number): Promise<void> {
chrome.action.setBadgeText({ text: "", tabId });
if (this.captureStartTime) {
const duration = Date.now() - this.captureStartTime;
console.log(`Capture duration: ${duration}ms`);
}
this.activeCapture = false;
this.captureStartTime = null;
}
// Auto-stop on tab close
setupTabCloseProtection(tabId: number, stream: MediaStream): void {
chrome.tabs.onRemoved.addListener((removedTabId) => {
if (removedTabId === tabId) {
console.log("Captured tab closed - stopping capture");
stream.getTracks().forEach((track) => track.stop());
}
});
// Also stop on tab navigation (SPA navigation may not trigger onRemoved)
chrome.webNavigation?.onCommitted?.addListener((details) => {
if (details.tabId === tabId && details.frameId === 0) {
// Check if it's a main frame navigation (not hash change)
if (details.transitionType !== "reload" && details.transitionType !== "form_submit") {
console.log("Tab navigated - stopping capture");
stream.getTracks().forEach((track) => track.stop());
}
}
});
}
// User consent verification
async verifyUserConsent(): Promise<boolean> {
// Get current tab info
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) {
return false;
}
// Log consent for audit (in production, store in persistent storage)
console.log(`User initiated capture on tab ${tab.id} at ${new Date().toISOString()}`);
// Optionally: Show in-extension consent confirmation
return true;
}
// Privacy-safe: never capture without explicit user action
async captureWithConsent(
sourceTypes: Array<"screen" | "window" | "tab"> = ["screen", "window", "tab"]
): Promise<{ streamId: string; stream: MediaStream } | null> {
// Verify user gesture (must be called from user-initiated event)
await this.verifyUserConsent();
// Show picker (user's explicit choice)
const streamId = await chrome.desktopCapture.chooseDesktopMedia(sourceTypes);
if (!streamId) {
console.log("User cancelled - no capture");
return null;
}
// Convert to stream
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: streamId,
},
} as MediaTrackConstraints,
});
return { streamId, stream };
}
// Clean capture session
async endCaptureSession(tabId: number, stream: MediaStream): Promise<void> {
// Stop all tracks
stream.getTracks().forEach((track) => track.stop());
// Disable indicators
await this.disableCaptureIndicators(tabId);
// Log session for privacy audit
if (this.captureStartTime) {
console.log(`Capture session ended. Duration: ${Date.now() - this.captureStartTime}ms`);
}
}
isCapturing(): boolean {
return this.activeCapture;
}
getCaptureDuration(): number | null {
return this.captureStartTime ? Date.now() - this.captureStartTime : null;
}
}
// Service worker implementation with privacy patterns
// background.ts
const captureManager = new PrivacyAwareCapture();
chrome.action.onClicked.addListener(async (tab) => {
if (!tab?.id) return;
// Check if already capturing
if (captureManager.isCapturing()) {
// Toggle off - stop capture
// Would need to track stream reference in a Map
return;
}
try {
// Start capture with user consent flow
const result = await captureManager.captureWithConsent(["screen", "window"]);
if (!result) {
// User cancelled
return;
}
// Enable indicators
await captureManager.enableCaptureIndicators(tab.id);
// Set up auto-stop on tab close
captureManager.setupTabCloseProtection(tab.id, result.stream);
// Store stream reference for later stopping
activeStreams.set(tab.id, result.stream);
console.log("Capture started with consent");
} catch (error) {
console.error("Capture failed:", error);
}
});
const activeStreams = new Map<number, MediaStream>();
Privacy Note: Always ensure screen capture is initiated by explicit user action (button click, keyboard shortcut). Never programmatically start capture without user consent. Display clear indicators when capture is active, and provide easy ways for users to stop capture.
Summary
| Pattern | Use Case | Key APIs |
|---|---|---|
| Basic Capture | Simple screen/window/tab selection | chrome.desktopCapture.chooseDesktopMedia() |
| Screen Recording | Record capture to file | MediaRecorder API in offscreen document |
| Screenshot | Single frame capture with crop/annotate | Canvas API for image processing |
| Window Picker | Filter capture to specific source types | chooseDesktopMedia with source types array |
| Audio Capture | System audio or tab audio | chromeMediaSource: "desktop" with audio constraints |
| Live Preview | Show capture in popup/side panel/PiP | video element with PiP API |
| WebRTC Streaming | Broadcast to remote peers | RTCPeerConnection with captured stream |
| Privacy Patterns | User consent, indicators, auto-stop | Badge API, notifications, event listeners |
The Desktop Capture API requires the desktopCapture permission but does not require host permissions. The user must always explicitly select what to capture via the system picker — extensions cannot silently capture without user action. Always implement privacy-safe patterns: show clear indicators when capture is active, provide easy stop mechanisms, and log capture sessions for transparency.
-e
—
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.