diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f4a030d4..a2500fb5 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -223,6 +223,8 @@ interface Window { hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; hudOverlayHide: () => void; hudOverlayClose: () => void; + getEditorMode: () => Promise; + onEditorModeChanged: (callback: (inEditor: boolean) => void) => () => void; hudOverlayRendererReady: () => void; hudOverlaySetWebcamPreviewVisible: (visible: boolean) => void; getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; diff --git a/electron/preload.ts b/electron/preload.ts index 02273b23..3e977e0a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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"); }, diff --git a/electron/windows.ts b/electron/windows.ts index 06083afa..6527d296 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -446,6 +446,14 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) }; }); +const editorWindows = new Set(); +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", { diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 680e3dac..3f914405 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -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) { diff --git a/src/hooks/useVideoDevices.ts b/src/hooks/useVideoDevices.ts index 6f7862ff..f20c0904 100644 --- a/src/hooks/useVideoDevices.ts +++ b/src/hooks/useVideoDevices.ts @@ -6,8 +6,6 @@ export interface VideoDevice { groupId: string; } -let hasRequestedVideoLabels = false; - export function useVideoDevices(enabled: boolean = true) { const [devices, setDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceId] = useState("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); } diff --git a/tests/ui/bridge.ts b/tests/ui/bridge.ts index 563b5529..e5a3b7fc 100644 --- a/tests/ui/bridge.ts +++ b/tests/ui/bridge.ts @@ -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 () => { diff --git a/tests/ui/camera-permissions.spec.ts b/tests/ui/camera-permissions.spec.ts new file mode 100644 index 00000000..c8432ab4 --- /dev/null +++ b/tests/ui/camera-permissions.spec.ts @@ -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"); +});