fix: stop live camera access while in editor mode

This commit is contained in:
webadderall
2026-09-21 21:29:30 +10:00
parent 10afa9ad0d
commit 40272c8bc0
7 changed files with 102 additions and 26 deletions
+2
View File
@@ -223,6 +223,8 @@ interface Window {
hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
getEditorMode: () => Promise<boolean>;
onEditorModeChanged: (callback: (inEditor: boolean) => void) => () => void;
hudOverlayRendererReady: () => void;
hudOverlaySetWebcamPreviewVisible: (visible: boolean) => void;
getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>;
+6
View File
@@ -199,6 +199,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
getEditorMode: () => ipcRenderer.invoke("get-editor-mode"),
onEditorModeChanged: (callback: (inEditor: boolean) => void) => {
const listener = (_event: Electron.IpcRendererEvent, inEditor: boolean) => callback(inEditor);
ipcRenderer.on("editor-mode-changed", listener);
return () => ipcRenderer.removeListener("editor-mode-changed", listener);
},
hudOverlayRendererReady: () => {
ipcRenderer.send("hud-overlay-renderer-ready");
},
+15
View File
@@ -446,6 +446,14 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean)
};
});
const editorWindows = new Set<BrowserWindow>();
function notifyEditorMode() {
if (hudOverlayWindow && !hudOverlayWindow.webContents.isDestroyed()) {
hudOverlayWindow.webContents.send("editor-mode-changed", editorWindows.size > 0);
}
}
ipcMain.handle("get-editor-mode", () => editorWindows.size > 0);
export function createHudOverlayWindow(): BrowserWindow {
const perfStart = Date.now();
loadHudOverlayCaptureProtectionSetting();
@@ -938,6 +946,13 @@ export function createEditorWindow(): BrowserWindow {
},
});
editorWindows.add(win);
notifyEditorMode();
win.once("closed", () => {
editorWindows.delete(win);
notifyEditorMode();
});
const publishWindowChrome = () => {
if (!win.isDestroyed())
win.webContents.send("window-chrome-changed", {
@@ -17,6 +17,27 @@ export function useWebcamPreviewOverlay({
webcamPopoverOpen: boolean;
hudOverlayMousePassthroughSupported: boolean | null;
}) {
const [editorMode, setEditorMode] = useState(true);
useEffect(() => {
let active = true;
let receivedEvent = false;
const unsubscribe = window.electronAPI.onEditorModeChanged((inEditor) => {
receivedEvent = true;
if (active) setEditorMode(inEditor);
});
void window.electronAPI
.getEditorMode()
.then((inEditor) => {
if (active && !receivedEvent) setEditorMode(inEditor);
})
.catch(() => {
/* Keep the camera off if editor mode is unknown. */
});
return () => {
active = false;
unsubscribe();
};
}, []);
const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true);
const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET);
const webcamPreviewOffsetRef = useRef(DEFAULT_WEBCAM_PREVIEW_OFFSET);
@@ -46,7 +67,9 @@ export function useWebcamPreviewOverlay({
hudOverlayMousePassthroughSupported,
);
const shouldStreamWebcamPreview =
webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen));
!editorMode &&
webcamEnabled &&
(showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen));
useEffect(() => {
if (!webcamEnabled) {
+2 -25
View File
@@ -6,8 +6,6 @@ export interface VideoDevice {
groupId: string;
}
let hasRequestedVideoLabels = false;
export function useVideoDevices(enabled: boolean = true) {
const [devices, setDevices] = useState<VideoDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("default");
@@ -24,7 +22,6 @@ export function useVideoDevices(enabled: boolean = true) {
const loadDevices = async () => {
const loadId = ++activeLoadId;
let permissionStream: MediaStream | null = null;
try {
if (mounted && loadId === activeLoadId) {
@@ -32,8 +29,8 @@ export function useVideoDevices(enabled: boolean = true) {
setError(null);
}
let allDevices = await navigator.mediaDevices.enumerateDevices();
let videoInputs = allDevices
const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = allDevices
.filter((device) => device.kind === "videoinput")
.map((device, index) => ({
deviceId: device.deviceId,
@@ -41,25 +38,6 @@ export function useVideoDevices(enabled: boolean = true) {
groupId: device.groupId,
}));
const needsLabelPermission =
videoInputs.length > 0 && videoInputs.every((device) => !device.label.trim());
if (needsLabelPermission && !hasRequestedVideoLabels) {
permissionStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
allDevices = await navigator.mediaDevices.enumerateDevices();
videoInputs = allDevices
.filter((device) => device.kind === "videoinput")
.map((device, index) => ({
deviceId: device.deviceId,
label: device.label || `Camera ${index + 1}`,
groupId: device.groupId,
}));
hasRequestedVideoLabels = true;
}
if (mounted && loadId === activeLoadId) {
setDevices(videoInputs);
setSelectedDeviceId((currentDeviceId) => {
@@ -87,7 +65,6 @@ export function useVideoDevices(enabled: boolean = true) {
console.error("Error loading video devices:", error);
}
} finally {
permissionStream?.getTracks().forEach((track) => track.stop());
if (mounted && loadId === activeLoadId) {
setIsLoading(false);
}
+2
View File
@@ -80,6 +80,8 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m
hudOverlayRendererReady: success,
hudOverlaySetIgnoreMouse: success,
hudOverlaySetWebcamPreviewVisible: success,
getEditorMode: async () => false,
onEditorModeChanged: subscribe,
getActiveCountdown: async () => ({ success: true, seconds: 3 }),
onCountdownTick: subscribe,
cancelCountdown: async () => {
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
test("editor mode never opens the camera and releases an active recorder preview", async ({
page,
}) => {
await installDesktopBridge(page);
await installDesktopBridgeOverrides(page, () => {
let visible: ((value: boolean) => void) | undefined;
window.electronAPI.getRecordingPreferences = async () => ({
success: true,
webcamEnabled: true,
microphoneEnabled: false,
systemAudioEnabled: false,
});
window.electronAPI.getEditorMode = async () => true;
window.electronAPI.onEditorModeChanged = (callback) => {
visible = callback;
return () => {
visible = undefined;
};
};
window.addEventListener("test-leave-editor", () => visible?.(false));
window.addEventListener("test-enter-editor", () => visible?.(true));
let requests = 0;
Object.defineProperty(navigator.mediaDevices, "getUserMedia", {
value: async () => {
document.documentElement.dataset.cameraRequests = String(++requests);
const canvas = document.createElement("canvas");
const stream = canvas.captureStream();
for (const track of stream.getTracks()) {
const stop = track.stop.bind(track);
track.stop = () => {
document.documentElement.dataset.cameraStopped = "true";
stop();
};
}
return stream;
},
});
});
await page.goto("/?windowType=hud-overlay");
await expect(page.getByRole("button", { name: "Disable webcam" })).toBeVisible();
expect(await page.locator("html").getAttribute("data-camera-requests")).toBeNull();
await page.evaluate(() => window.dispatchEvent(new Event("test-leave-editor")));
await expect(page.locator("html")).toHaveAttribute("data-camera-requests", "1");
await page.evaluate(() => window.dispatchEvent(new Event("test-enter-editor")));
await expect(page.locator("html")).toHaveAttribute("data-camera-stopped", "true");
await page.evaluate(() => navigator.mediaDevices.dispatchEvent(new Event("devicechange")));
await expect(page.locator("html")).toHaveAttribute("data-camera-requests", "1");
});