From b96b913635f3cdec966e1a68d19ce9a16e93139a Mon Sep 17 00:00:00 2001 From: songsong Date: Sun, 25 Oct 2026 10:11:04 +0800 Subject: [PATCH 1/6] fix(recording): warn about macOS Presenter Overlay camera conflicts --- electron/electron-env.d.ts | 11 +- electron/ipc/handlers.ts | 25 ++++ .../ipc/nativeMacMidCaptureErrorWatch.test.ts | 19 +++ electron/ipc/nativeMacMidCaptureErrorWatch.ts | 14 ++ electron/main.ts | 7 + .../ScreenCaptureRecorder.swift | 16 +++ electron/preload.ts | 13 +- src/components/launch/LaunchWindow.test.tsx | 70 +++++++++- src/components/launch/LaunchWindow.tsx | 46 ++++++- ...eenRecorder.nativeMacStartWarning.test.tsx | 127 ++++++++++++++++++ src/hooks/useScreenRecorder.ts | 27 +++- src/i18n/locales/ar/common.json | 8 ++ src/i18n/locales/cs/common.json | 8 ++ src/i18n/locales/de/common.json | 8 ++ src/i18n/locales/en/common.json | 8 ++ src/i18n/locales/es/common.json | 8 ++ src/i18n/locales/fr/common.json | 8 ++ src/i18n/locales/it/common.json | 8 ++ src/i18n/locales/ja-JP/common.json | 8 ++ src/i18n/locales/ko-KR/common.json | 8 ++ src/i18n/locales/pt-BR/common.json | 8 ++ src/i18n/locales/ru/common.json | 8 ++ src/i18n/locales/tr/common.json | 8 ++ src/i18n/locales/vi/common.json | 8 ++ src/i18n/locales/zh-CN/common.json | 8 ++ src/i18n/locales/zh-TW/common.json | 8 ++ src/lib/nativeMacRecording.test.ts | 42 ++++++ src/lib/nativeMacRecording.ts | 37 +++++ .../architecture/recording.md | 40 ++++++ .../testing/manual-e2e-checklist.md | 2 + 30 files changed, 606 insertions(+), 10 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fc6b5f9118..7a50390739 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -191,6 +191,11 @@ interface Window { startNativeMacRecording: ( request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest, ) => Promise; + onNativeMacPresenterOverlayChanged: ( + callback: ( + event: import("../src/lib/nativeMacRecording").NativeMacPresenterOverlayEvent, + ) => void, + ) => () => void; onNativeMacSystemAudioUnavailable: (callback: () => void) => () => void; pauseNativeMacRecording: () => Promise<{ success: boolean; @@ -465,7 +470,11 @@ interface Window { setTitleBarOverlay: (color: string, symbolColor: string) => void; getPlatform: () => string; getSystemVersion: () => string; - getAppInfo: () => Promise<{ version: string; canCheckForUpdates: boolean }>; + getAppInfo: () => Promise<{ + version: string; + canCheckForUpdates: boolean; + supportsPresenterOverlay?: boolean; + }>; checkForUpdates: () => Promise; showAbout: () => Promise; canCheckForUpdatesNow: () => Promise; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 0fcd435b1b..3b49ece22c 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -33,6 +33,7 @@ import { import { collectMacCaptureExcludedWindowIds, type NativeMacRecordingRequest, + updateMacPresenterOverlayState, } from "../../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording"; import { @@ -877,6 +878,7 @@ const nativeMacCaptureOutputs = new WeakMap void, onSystemAudioUnavailable: () => void, + onPresenterOverlayChanged: (active: boolean) => void, ) { let lineBuffer = ""; // Hooked here rather than on `nativeMacCaptureEvents`, which the start wait @@ -1639,6 +1642,7 @@ function attachNativeMacCaptureOutputDrain( () => nativeMacCaptureProcess === proc && !nativeMacStopInFlight, onTakeEnded, onSystemAudioUnavailable, + onPresenterOverlayChanged, ); const drain = (chunk: Buffer) => { const text = chunk.toString(); @@ -3254,6 +3258,7 @@ export function registerIpcHandlers( nativeMacIsPaused = false; nativeMacStopInFlight = false; nativeMacRecordingWarning = null; + nativeMacPresenterOverlay = { active: false, cameraInterrupted: false }; activeMacCaptureBounds = null; const cursorStartTimeMs = Date.now(); @@ -3288,6 +3293,20 @@ export function registerIpcHandlers( hudWindow.webContents.send("native-mac-system-audio-unavailable"); } }, + (active) => { + nativeMacPresenterOverlay = updateMacPresenterOverlayState( + nativeMacPresenterOverlay, + active, + request.webcam.enabled, + ); + const hudWindow = getMainWindow(); + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.webContents.send("native-mac-presenter-overlay-changed", { + recordingId, + ...nativeMacPresenterOverlay, + }); + } + }, ); const captureStartedAtMs = (await waitForNativeMacCaptureStart(proc)) ?? Date.now(); @@ -3329,6 +3348,7 @@ export function registerIpcHandlers( path: outputPath, helperPath, startedAtMs: captureStartedAtMs, + presenterOverlay: { ...nativeMacPresenterOverlay }, microphoneDefaulted, microphoneUnavailable, microphoneNotFound, @@ -3737,6 +3757,11 @@ export function registerIpcHandlers( truncatedBytes: salvage.truncatedBytes, }); } + if (nativeMacPresenterOverlay.cameraInterrupted) { + warning = [warning, mainT("common", "presenterOverlay.interruptedDescription")] + .filter(Boolean) + .join("\n\n"); + } nativeMacRecordingWarning = warning ? { screenVideoPath, message: warning } : null; // A recovered take most often follows a disk that filled up, and these writes diff --git a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts index 6eaec1ef30..085876502e 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts @@ -109,4 +109,23 @@ describe("createNativeMacMidCaptureErrorWatch", () => { expect(onSystemAudioUnavailable).not.toHaveBeenCalled(); }); + it("reports overlay transitions before or after start without ending the take", () => { + const onEnded = vi.fn(); + const onOverlay = vi.fn(); + const watch = createNativeMacMidCaptureErrorWatch(() => true, onEnded, vi.fn(), onOverlay); + watch({ event: "warning", code: "presenter-overlay-started" }); + watch({ event: "recording-started" }); + watch({ event: "warning", code: "presenter-overlay-started" }); + watch({ event: "warning", code: "presenter-overlay-stopped" }); + watch({ event: "warning", code: "presenter-overlay-stopped" }); + expect(onOverlay.mock.calls).toEqual([[true], [false]]); + expect(onEnded).not.toHaveBeenCalled(); + }); + + it("ignores overlay events from a previous or stopping take", () => { + const onOverlay = vi.fn(); + const watch = createNativeMacMidCaptureErrorWatch(() => false, vi.fn(), vi.fn(), onOverlay); + watch({ event: "warning", code: "presenter-overlay-started" }); + expect(onOverlay).not.toHaveBeenCalled(); + }); }); diff --git a/electron/ipc/nativeMacMidCaptureErrorWatch.ts b/electron/ipc/nativeMacMidCaptureErrorWatch.ts index 48f8628886..734b947cbb 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.ts @@ -16,8 +16,10 @@ export function createNativeMacMidCaptureErrorWatch( isLiveTake: () => boolean, onTakeEnded: () => void, onSystemAudioUnavailable: () => void, + onPresenterOverlayChanged?: (active: boolean) => void, ) { let recordingStarted = false; + let presenterOverlayActive = false; let reportedSystemAudioUnavailable = false; const watch = (event: Record) => { if (event.event === "recording-started") { @@ -32,6 +34,18 @@ export function createNativeMacMidCaptureErrorWatch( reportedSystemAudioUnavailable = true; onSystemAudioUnavailable(); } + if (event.event === "warning" && isLiveTake()) { + const active = + event.code === "presenter-overlay-started" + ? true + : event.code === "presenter-overlay-stopped" + ? false + : null; + if (active !== null && active !== presenterOverlayActive) { + presenterOverlayActive = active; + onPresenterOverlayChanged?.(active); + } + } if (event.event === "error" && recordingStarted && isLiveTake()) { onTakeEnded(); } diff --git a/electron/main.ts b/electron/main.ts index 43249d65ae..6c7f25c3e8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -12,6 +12,7 @@ import { shell, Tray, } from "electron"; +import { supportsMacPresenterOverlay } from "../src/lib/nativeMacRecording"; import { ShortcutBinding } from "../src/lib/shortcuts"; import { type AboutFacts, @@ -1340,6 +1341,12 @@ appReady?.then(async () => { ipcMain.handle("get-app-info", () => ({ version: app.getVersion(), canCheckForUpdates: channelAllowsUpdateCheck(), + supportsPresenterOverlay: supportsMacPresenterOverlay( + process.platform, + process.getSystemVersion(), + process.arch, + process.platform === "darwin" && app.runningUnderARM64Translation, + ), })); // The FULL veto, permanent and transient, for a caller that can ask again at the moment it diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 80fc7f80f2..671f8797b6 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -450,6 +450,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } } + /// The effect belongs to the screen stream, never the auxiliary audio stream. + @available(macOS 14.0, *) + func outputVideoEffectDidStart(for stream: SCStream) { + guard stream === self.stream else { return } + emit(["event": "warning", "code": "presenter-overlay-started", + "message": "macOS Presenter Overlay started."]) + } + + /// Stopping the effect does not establish that a separate camera track recovered. + @available(macOS 14.0, *) + func outputVideoEffectDidStop(for stream: SCStream) { + guard stream === self.stream else { return } + emit(["event": "warning", "code": "presenter-overlay-stopped", + "message": "macOS Presenter Overlay stopped."]) + } + func stream(_ stream: SCStream, didStopWithError error: Error) { // Losing the audio-only stream costs the take its system audio, not the take. if stream === systemAudioStream { diff --git a/electron/preload.ts b/electron/preload.ts index 23160bcaa8..f2acc8fb2c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,6 +1,9 @@ import { contextBridge, ipcRenderer, sharedTexture, webUtils } from "electron"; import type { NativeLinuxRecordingRequest } from "../src/lib/nativeLinuxRecording"; -import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; +import type { + NativeMacPresenterOverlayEvent, + NativeMacRecordingRequest, +} from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import type { ShortcutBinding } from "../src/lib/shortcuts"; @@ -295,6 +298,13 @@ contextBridge.exposeInMainWorld("electronAPI", { startNativeMacRecording: (request: NativeMacRecordingRequest) => { return ipcRenderer.invoke("start-native-mac-recording", request); }, + onNativeMacPresenterOverlayChanged: ( + callback: (event: NativeMacPresenterOverlayEvent) => void, + ) => { + const listener = (_: unknown, event: NativeMacPresenterOverlayEvent) => callback(event); + ipcRenderer.on("native-mac-presenter-overlay-changed", listener); + return () => ipcRenderer.removeListener("native-mac-presenter-overlay-changed", listener); + }, onNativeMacSystemAudioUnavailable: (callback: () => void) => { const listener = () => callback(); ipcRenderer.on("native-mac-system-audio-unavailable", listener); @@ -479,6 +489,7 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("get-app-info") as Promise<{ version: string; canCheckForUpdates: boolean; + supportsPresenterOverlay?: boolean; }>, /** Resolves once the check has a verdict. The dialogs that verdict leads to — download, * restart — are the main process's conversation, not the caller's. */ diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 8cd92de957..281f0991e3 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -74,6 +74,7 @@ const recorderState = vi.hoisted(() => ({ setSystemAudioEnabled: vi.fn(), cursorCaptureMode: "editable-overlay", setCursorCaptureMode: vi.fn(), + presenterOverlay: null as { active: boolean; cameraInterrupted: boolean } | null, softwareEncoderFallbackNoticeVisible: false, dismissSoftwareEncoderFallbackNotice: vi.fn(), recordingPrefsLoaded: true, @@ -150,7 +151,7 @@ vi.mock("@/native", () => ({ })); const appInfoState = vi.hoisted(() => ({ - value: { version: "1.9.6", canCheckForUpdates: true }, + value: { version: "1.9.6", canCheckForUpdates: true, supportsPresenterOverlay: true }, })); const updateCheckMock = vi.hoisted(() => vi.fn(async () => undefined)); @@ -361,6 +362,8 @@ function resetLaunchMocks() { recorderState.value.setCursorCaptureMode.mockClear(); recorderState.value.softwareEncoderFallbackNoticeVisible = false; recorderState.value.dismissSoftwareEncoderFallbackNotice.mockClear(); + recorderState.value.presenterOverlay = null; + appInfoState.value.supportsPresenterOverlay = true; recorderState.value.recording = false; recorderState.value.canPauseRecording = false; recorderState.value.microphoneEnabled = false; @@ -388,7 +391,11 @@ function resetLaunchMocks() { vi.mocked(nativeBridgeClient.system.getPlatform).mockImplementation( async () => platformState.value, ); - appInfoState.value = { version: "1.9.6", canCheckForUpdates: true }; + appInfoState.value = { + version: "1.9.6", + canCheckForUpdates: true, + supportsPresenterOverlay: true, + }; updateCheckMock.mockReset(); updateCheckMock.mockResolvedValue(undefined); stubElectronAPI(vi.fn(async () => null)); @@ -2033,7 +2040,11 @@ describe("LaunchWindow device settings", () => { // pointing its user at a GitHub download starts a second, parallel install that then drifts // forever. The version still shows — it is the answer to "what am I running?", not an offer. it("offers no update check where a package manager owns the update", async () => { - appInfoState.value = { version: "1.9.6", canCheckForUpdates: false }; + appInfoState.value = { + version: "1.9.6", + canCheckForUpdates: false, + supportsPresenterOverlay: true, + }; renderLaunchWindow(); @@ -2373,4 +2384,57 @@ describe("LaunchWindow toasts", () => { }); }); }); + it("shows guidance before opening the source picker only for a separate camera", async () => { + recorderState.value.webcamEnabled = true; + renderLaunchWindow(); + await waitFor(() => + expect(screen.getByTestId("presenter-overlay-notice")).toHaveTextContent( + "presenterOverlay.beforeDescription", + ), + ); + expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); + }); + + it.each([ + false, + true, + ])("does not warn before recording when camera is off (capable=%s)", async (capable) => { + appInfoState.value.supportsPresenterOverlay = capable; + renderLaunchWindow(); + await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalled()); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + }); + + it("does not warn on Macs without Presenter Overlay support", async () => { + appInfoState.value.supportsPresenterOverlay = false; + recorderState.value.webcamEnabled = true; + renderLaunchWindow(); + await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalled()); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + }); + + it("does not describe a screen-only system overlay as a camera conflict", async () => { + recorderState.value.recording = true; + recorderState.value.presenterOverlay = { active: true, cameraInterrupted: false }; + renderLaunchWindow(); + await waitFor(() => + expect(screen.getByTestId("presenter-overlay-notice")).toHaveTextContent( + "presenterOverlay.screenOnlyDescription", + ), + ); + expect(screen.queryByText("presenterOverlay.interruptedDescription")).not.toBeInTheDocument(); + }); + + it("keeps the camera warning visible after the system overlay stops", async () => { + recorderState.value.recording = true; + recorderState.value.presenterOverlay = { active: false, cameraInterrupted: true }; + renderLaunchWindow(); + await waitFor(() => + expect(screen.getByTestId("presenter-overlay-notice")).toHaveTextContent( + "presenterOverlay.interruptedDescription", + ), + ); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + expect(recorderState.value.cancelRecording).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index d4b402b841..237d5765ca 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -131,6 +131,7 @@ export function LaunchWindow() { setWebcamDeviceName, cursorCaptureMode, setCursorCaptureMode, + presenterOverlay, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, recordingPrefsLoaded, @@ -155,9 +156,11 @@ export function LaunchWindow() { // Store/Flathub/Snap/Nix install is kept current by its package manager and is offered // nothing (electron/install-channel.ts). Asked once: neither answer changes while the app // runs, and the HUD is rebuilt for every recording anyway. - const [appInfo, setAppInfo] = useState<{ version: string; canCheckForUpdates: boolean } | null>( - null, - ); + const [appInfo, setAppInfo] = useState<{ + version: string; + canCheckForUpdates: boolean; + supportsPresenterOverlay?: boolean; + } | null>(null); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); /** * Narrower than [`isLinuxHud`] on purpose: without the helper the recorder @@ -1172,7 +1175,17 @@ export function LaunchWindow() { const versionLabel = appInfo ? t("deviceSettings.version", { version: appInfo.version }) : null; - const hasNotices = Boolean(systemLocaleSuggestion) || softwareEncoderFallbackNoticeVisible; + const overlayNotice = presenterOverlay?.cameraInterrupted + ? "interrupted" + : presenterOverlay?.active + ? "screenOnly" + : webcamEnabled && getPlatform() === "darwin" && appInfo?.supportsPresenterOverlay + ? "before" + : null; + const hasNotices = + Boolean(systemLocaleSuggestion) || + softwareEncoderFallbackNoticeVisible || + overlayNotice !== null; return ( // Avoid w-screen/h-screen: 100vw can exceed the inner layout width when scrollbars @@ -1422,6 +1435,31 @@ export function LaunchWindow() { data-testid="hud-notice-column" className={styles.hudNoticeColumn} > + {overlayNotice && ( +
+
+ {overlayNotice === "interrupted" + ? tCommon("presenterOverlay.interruptedTitle") + : overlayNotice === "screenOnly" + ? tCommon("presenterOverlay.screenOnlyTitle") + : tCommon("presenterOverlay.beforeTitle")} +
+
+ {overlayNotice === "interrupted" + ? tCommon("presenterOverlay.interruptedDescription") + : overlayNotice === "screenOnly" + ? tCommon("presenterOverlay.screenOnlyDescription") + : tCommon("presenterOverlay.beforeDescription")} +
+
+ )} {systemLocaleSuggestion && ( >; +let emitPresenterOverlay: + | ((event: { recordingId: number; active: boolean; cameraInterrupted: boolean }) => void) + | undefined; +let unsubscribePresenterOverlay: ReturnType; let emitSystemAudioUnavailable: (() => void) | undefined; let openAccessibilitySettings: ReturnType; function stubElectronAPI(systemAudioEnabled = false) { emitSystemAudioUnavailable = undefined; + emitPresenterOverlay = undefined; + unsubscribePresenterOverlay = vi.fn(); openAccessibilitySettings = vi.fn(async () => undefined); api = { getRecordingPrefs: vi.fn(async () => ({ @@ -44,6 +50,12 @@ function stubElectronAPI(systemAudioEnabled = false) { recordingId: 7, microphoneDefaulted: true, })), + onNativeMacPresenterOverlayChanged: vi.fn( + (callback: NonNullable) => { + emitPresenterOverlay = callback; + return unsubscribePresenterOverlay; + }, + ), onNativeMacSystemAudioUnavailable: vi.fn((callback: () => void) => { emitSystemAudioUnavailable = callback; return vi.fn(); @@ -397,3 +409,118 @@ describe("useScreenRecorder native macOS start warnings", () => { expect(toast.error).not.toHaveBeenCalled(); }); }); + +describe("Presenter Overlay event lifetime", () => { + it("ignores other takes and never stops a recording because of an effect", async () => { + const view = renderHook(() => useScreenRecorder()); + await settle(); + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + await act(async () => { + emitPresenterOverlay?.({ recordingId: 8, active: true, cameraInterrupted: true }); + }); + expect(view.result.current.presenterOverlay).toBeNull(); + await act(async () => { + emitPresenterOverlay?.({ recordingId: 7, active: true, cameraInterrupted: false }); + }); + expect(view.result.current.presenterOverlay).toEqual({ + active: true, + cameraInterrupted: false, + }); + expect(view.result.current.recording).toBe(true); + expect(api.stopNativeMacRecording).not.toHaveBeenCalled(); + view.unmount(); + expect(unsubscribePresenterOverlay).toHaveBeenCalledOnce(); + }); + it("preserves transitions arriving before the start response instead of overwriting them", async () => { + let finishStart: + | ((value: { + success: boolean; + recordingId: number; + presenterOverlay: { active: boolean; cameraInterrupted: boolean }; + }) => void) + | undefined; + api.startNativeMacRecording.mockImplementation( + () => + new Promise((resolve) => { + finishStart = resolve; + }), + ); + const view = renderHook(() => useScreenRecorder()); + await settle(); + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + const request = api.startNativeMacRecording.mock.calls[0][0]; + expect(request.webcam.enabled).toBe(false); + await act(async () => { + emitPresenterOverlay?.({ + recordingId: request.recordingId, + active: true, + cameraInterrupted: false, + }); + finishStart?.({ + success: true, + recordingId: request.recordingId, + presenterOverlay: { active: false, cameraInterrupted: false }, + }); + }); + expect(view.result.current.presenterOverlay).toEqual({ + active: true, + cameraInterrupted: false, + }); + expect(view.result.current.recording).toBe(true); + }); + it("clears the previous take's warning on restart", async () => { + api.startNativeMacRecording.mockImplementation(async (request) => ({ + success: true, + recordingId: request.recordingId, + presenterOverlay: { active: false, cameraInterrupted: false }, + })); + const view = renderHook(() => useScreenRecorder()); + await settle(); + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + const firstId = api.startNativeMacRecording.mock.calls[0][0].recordingId; + await act(async () => { + emitPresenterOverlay?.({ recordingId: firstId, active: false, cameraInterrupted: true }); + }); + expect(view.result.current.presenterOverlay?.cameraInterrupted).toBe(true); + await act(async () => { + await view.result.current.restartRecording(); + }); + await settle(); + expect(api.startNativeMacRecording).toHaveBeenCalledTimes(2); + expect(view.result.current.presenterOverlay).toEqual({ + active: false, + cameraInterrupted: false, + }); + await act(async () => { + emitPresenterOverlay?.({ recordingId: firstId, active: true, cameraInterrupted: true }); + }); + expect(view.result.current.presenterOverlay).toEqual({ + active: false, + cameraInterrupted: false, + }); + }); + + it("accepts the start snapshot when no live event reached the renderer", async () => { + api.startNativeMacRecording.mockResolvedValue({ + success: true, + recordingId: 7, + presenterOverlay: { active: true, cameraInterrupted: true }, + }); + const view = renderHook(() => useScreenRecorder()); + await settle(); + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + expect(view.result.current.presenterOverlay).toEqual({ active: true, cameraInterrupted: true }); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8c22aa9b76..aba8fc713c 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -10,6 +10,7 @@ import { portalOwnsSourceSelection, } from "@/lib/nativeLinuxRecording"; import { + type NativeMacPresenterOverlayState, type NativeMacRecordingRequest, parseMacDisplayIdFromSourceId, parseMacWindowIdFromSourceId, @@ -117,6 +118,7 @@ type UseScreenRecorderReturn = { setWebcamEnabled: (enabled: boolean) => Promise; cursorCaptureMode: CursorCaptureMode; setCursorCaptureMode: (mode: CursorCaptureMode) => void; + presenterOverlay: NativeMacPresenterOverlayState | null; softwareEncoderFallbackNoticeVisible: boolean; dismissSoftwareEncoderFallbackNotice: (dontShowAgain?: boolean) => void; recordingPrefsLoaded: boolean; @@ -346,6 +348,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const webcamRecorder = useRef(null); const nativeWindowsRecording = useRef(null); const nativeMacRecording = useRef(null); + const startingNativeMacRecordingId = useRef(null); + const [presenterOverlay, setPresenterOverlay] = useState( + null, + ); + useEffect( + () => + window.electronAPI?.onNativeMacPresenterOverlayChanged?.((event) => { + const id = nativeMacRecording.current?.recordingId ?? startingNativeMacRecordingId.current; + if (id !== event.recordingId) return; + setPresenterOverlay({ active: event.active, cameraInterrupted: event.cameraInterrupted }); + }), + [], + ); const nativeLinuxRecording = useRef(null); const stream = useRef(null); const screenStream = useRef(null); @@ -1341,6 +1356,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const activeRecordingId = Date.now(); + startingNativeMacRecordingId.current = activeRecordingId; + setPresenterOverlay(null); const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display"; const displayId = Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id); @@ -1421,7 +1438,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, }, webcam: { - enabled: webcamEnabled, + enabled: Boolean(nativeWebcamRecorder), // Same pairing rule as the Windows path; here the stream is still // open, so the identity can be read at the point of use. ...readWebcamDeviceIdentity(), @@ -1444,6 +1461,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { await window.electronAPI.stopNativeMacRecording(true); return true; } + if (result.presenterOverlay) { + // A live transition received while start was pending is newer than this snapshot. + setPresenterOverlay((current) => current ?? result.presenterOverlay ?? null); + } if (result.microphoneDefaulted) { toast.error(t("recording.microphoneDefaulted")); } @@ -1481,8 +1502,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setElapsedSeconds(0); return true; } catch (error) { + setPresenterOverlay(null); console.error("Native macOS capture failed:", error); throw error; + } finally { + startingNativeMacRecordingId.current = null; } }; @@ -2491,6 +2515,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setWebcamEnabled, cursorCaptureMode, setCursorCaptureMode, + presenterOverlay, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, recordingPrefsLoaded, diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 65a0171cac..75e44b2f2a 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -75,5 +75,13 @@ "screen": "شاشة", "virtual": "شاشة افتراضية", "unknown": "تسجيل الشاشة" + }, + "presenterOverlay": { + "beforeTitle": "أبقِ تراكب مقدم العرض في macOS معطّلاً", + "beforeDescription": "يسجّل OpenScreen الكاميرا بشكل منفصل لتحريرها لاحقًا. قد يؤدي تراكب مقدم العرض في macOS إلى مقاطعة هذا التسجيل. استخدم تراكب الكاميرا في OpenScreen بدلاً منه.", + "interruptedTitle": "قد يكون تسجيل الكاميرا غير مكتمل", + "interruptedDescription": "تم تفعيل تراكب مقدم العرض في macOS أثناء هذا التسجيل، وقد يكون قد قاطع مسار الكاميرا المنفصل. عطّله وابدأ تسجيلًا جديدًا إذا كنت بحاجة إلى تحرير الكاميرا بشكل مستقل.", + "screenOnlyTitle": "التراكب جزء من فيديو الشاشة", + "screenOnlyDescription": "لا يجري تسجيل الكاميرا في مسار منفصل. يُضمَّن تراكب النظام في فيديو الشاشة ولا يمكن تحريره بشكل مستقل." } } diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index f1bb844221..6a653a8cba 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -75,5 +75,13 @@ "screen": "Obrazovka", "virtual": "Virtuální displej", "unknown": "Záznam obrazovky" + }, + "presenterOverlay": { + "beforeTitle": "Vypněte překryv prezentujícího v macOS", + "beforeDescription": "OpenScreen nahrává kameru samostatně pro pozdější úpravy. Překryv prezentujícího v macOS může toto nahrávání přerušit. Použijte překryv kamery v OpenScreen.", + "interruptedTitle": "Záznam kamery může být neúplný", + "interruptedDescription": "Během tohoto záznamu byl zapnut překryv prezentujícího v macOS, který mohl přerušit samostatnou stopu kamery. Vypněte jej a spusťte nový záznam, pokud potřebujete kameru upravovat samostatně.", + "screenOnlyTitle": "Překryv je součástí videa obrazovky", + "screenOnlyDescription": "Kamera se nenahrává do samostatné stopy. Systémový překryv je součástí videa obrazovky a nelze jej upravovat samostatně." } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 458bec8be1..407db99860 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -75,5 +75,13 @@ "screen": "Bildschirm", "virtual": "Virtueller Bildschirm", "unknown": "Bildschirmaufnahme" + }, + "presenterOverlay": { + "beforeTitle": "macOS-Moderatorenmaske deaktiviert lassen", + "beforeDescription": "OpenScreen zeichnet die Kamera für die Bearbeitung separat auf. Die macOS-Moderatorenmaske kann diese Aufnahme unterbrechen. Verwende stattdessen die Kameraeinblendung von OpenScreen.", + "interruptedTitle": "Die Kameraaufnahme ist möglicherweise unvollständig", + "interruptedDescription": "Die macOS-Moderatorenmaske wurde während dieser Aufnahme aktiviert und hat möglicherweise die separate Kameraspur unterbrochen. Deaktiviere sie und starte eine neue Aufnahme, wenn du die Kamera separat bearbeiten möchtest.", + "screenOnlyTitle": "Die Moderatorenmaske ist Teil des Bildschirmvideos", + "screenOnlyDescription": "Die Kamera wird nicht als separate Spur aufgezeichnet. Die Systemeinblendung ist im Bildschirmvideo enthalten und lässt sich nicht unabhängig bearbeiten." } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f04adbec75..068b1514dc 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -75,5 +75,13 @@ "screen": "Screen", "virtual": "Virtual display", "unknown": "Screen recording" + }, + "presenterOverlay": { + "beforeTitle": "Keep macOS Presenter Overlay off", + "beforeDescription": "OpenScreen records your camera separately for editing. macOS Presenter Overlay can interrupt that recording. Use OpenScreen’s camera overlay instead.", + "interruptedTitle": "Camera recording may be incomplete", + "interruptedDescription": "macOS Presenter Overlay was enabled during this take and may have interrupted the separate camera track. Turn it off and start a fresh take if you need an editable camera recording.", + "screenOnlyTitle": "Presenter Overlay is in the screen video", + "screenOnlyDescription": "Your camera is not being recorded as a separate track. The system overlay is included in the screen video and cannot be edited independently." } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index cb5f145e77..6515dff56c 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -75,5 +75,13 @@ "screen": "Pantalla", "virtual": "Pantalla virtual", "unknown": "Grabación de pantalla" + }, + "presenterOverlay": { + "beforeTitle": "Mantén desactivada la superposición del presentador de macOS", + "beforeDescription": "OpenScreen graba la cámara por separado para editarla. La superposición del presentador de macOS puede interrumpir esa grabación. Utiliza la superposición de cámara de OpenScreen.", + "interruptedTitle": "La grabación de cámara puede estar incompleta", + "interruptedDescription": "Se activó la superposición del presentador de macOS durante esta toma y puede haber interrumpido la pista de cámara independiente. Desactívala e inicia una nueva toma si necesitas editar la cámara por separado.", + "screenOnlyTitle": "La superposición forma parte del vídeo de pantalla", + "screenOnlyDescription": "La cámara no se está grabando en una pista independiente. La superposición del sistema se incluye en el vídeo de pantalla y no se puede editar por separado." } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 1f04b15f0c..0260913b56 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -75,5 +75,13 @@ "screen": "Écran", "virtual": "Écran virtuel", "unknown": "Enregistrement d'écran" + }, + "presenterOverlay": { + "beforeTitle": "Désactivez l’incrustation du présentateur de macOS", + "beforeDescription": "OpenScreen enregistre la caméra séparément pour le montage. L’incrustation du présentateur de macOS peut interrompre cet enregistrement. Utilisez plutôt l’incrustation caméra d’OpenScreen.", + "interruptedTitle": "L’enregistrement caméra peut être incomplet", + "interruptedDescription": "L’incrustation du présentateur de macOS a été activée pendant cette prise et a pu interrompre la piste caméra séparée. Désactivez-la et recommencez une prise si vous avez besoin de modifier la caméra séparément.", + "screenOnlyTitle": "L’incrustation fait partie de la vidéo de l’écran", + "screenOnlyDescription": "La caméra n’est pas enregistrée sur une piste séparée. L’incrustation système est intégrée à la vidéo de l’écran et ne peut pas être modifiée indépendamment." } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 6554344e2c..4b322160e3 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -75,5 +75,13 @@ "screen": "Schermo", "virtual": "Schermo virtuale", "unknown": "Registrazione schermo" + }, + "presenterOverlay": { + "beforeTitle": "Disattiva la sovrapposizione del presentatore di macOS", + "beforeDescription": "OpenScreen registra la videocamera separatamente per il montaggio. La sovrapposizione del presentatore di macOS può interrompere la registrazione. Usa la sovrapposizione della videocamera di OpenScreen.", + "interruptedTitle": "La registrazione della videocamera potrebbe essere incompleta", + "interruptedDescription": "La sovrapposizione del presentatore di macOS è stata attivata durante questa ripresa e potrebbe aver interrotto la traccia separata della videocamera. Disattivala e avvia una nuova ripresa se devi modificare la videocamera separatamente.", + "screenOnlyTitle": "La sovrapposizione è inclusa nel video dello schermo", + "screenOnlyDescription": "La videocamera non viene registrata su una traccia separata. La sovrapposizione di sistema è inclusa nel video dello schermo e non può essere modificata separatamente." } } diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index b36f2c5a43..7ef563171a 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -75,5 +75,13 @@ "screen": "画面", "virtual": "仮想ディスプレイ", "unknown": "画面録画" + }, + "presenterOverlay": { + "beforeTitle": "macOS のプレゼンターオーバーレイをオフにしてください", + "beforeDescription": "OpenScreen は編集用にカメラ映像を別に録画します。macOS のプレゼンターオーバーレイはその録画を中断する場合があります。OpenScreen のカメラオーバーレイをご利用ください。", + "interruptedTitle": "カメラ録画が不完全な可能性があります", + "interruptedDescription": "今回の録画中に macOS のプレゼンターオーバーレイが有効になり、カメラトラックが中断された可能性があります。カメラ映像を個別に編集する必要がある場合は、システムのオーバーレイをオフにして録画し直してください。", + "screenOnlyTitle": "プレゼンターオーバーレイは画面動画に含まれます", + "screenOnlyDescription": "カメラは別のトラックとして録画されていません。システムのオーバーレイは画面動画に含まれ、個別に編集できません。" } } diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index 2291c442a7..59e9322fbd 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -75,5 +75,13 @@ "screen": "화면", "virtual": "가상 디스플레이", "unknown": "화면 녹화" + }, + "presenterOverlay": { + "beforeTitle": "macOS 발표자 오버레이를 꺼주세요", + "beforeDescription": "OpenScreen은 편집을 위해 카메라를 별도로 녹화합니다. macOS 발표자 오버레이가 이 녹화를 중단할 수 있습니다. OpenScreen의 카메라 오버레이를 사용하세요.", + "interruptedTitle": "카메라 녹화가 불완전할 수 있습니다", + "interruptedDescription": "이번 녹화 중 macOS 발표자 오버레이가 켜져 별도의 카메라 트랙이 중단되었을 수 있습니다. 카메라 영상을 개별 편집하려면 시스템 오버레이를 끄고 새로 녹화하세요.", + "screenOnlyTitle": "발표자 오버레이는 화면 영상에 포함됩니다", + "screenOnlyDescription": "카메라가 별도 트랙으로 녹화되고 있지 않습니다. 시스템 오버레이는 화면 영상에 포함되며 개별 편집할 수 없습니다." } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 747e9ffa55..4267fd0948 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -75,5 +75,13 @@ "screen": "Tela", "virtual": "Tela virtual", "unknown": "Gravação de tela" + }, + "presenterOverlay": { + "beforeTitle": "Mantenha a Sobreposição do Apresentador do macOS desativada", + "beforeDescription": "O OpenScreen grava a câmera separadamente para edição. A Sobreposição do Apresentador do macOS pode interromper essa gravação. Use a sobreposição de câmera do OpenScreen.", + "interruptedTitle": "A gravação da câmera pode estar incompleta", + "interruptedDescription": "A Sobreposição do Apresentador do macOS foi ativada durante esta tomada e pode ter interrompido a faixa separada da câmera. Desative-a e inicie uma nova tomada se precisar editar a câmera separadamente.", + "screenOnlyTitle": "A sobreposição faz parte do vídeo da tela", + "screenOnlyDescription": "A câmera não está sendo gravada em uma faixa separada. A sobreposição do sistema está incluída no vídeo da tela e não pode ser editada separadamente." } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 523743e308..f0c364e6b4 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -75,5 +75,13 @@ "screen": "Экран", "virtual": "Виртуальный экран", "unknown": "Запись экрана" + }, + "presenterOverlay": { + "beforeTitle": "Отключите наложение докладчика в macOS", + "beforeDescription": "OpenScreen записывает камеру отдельно для редактирования. Наложение докладчика в macOS может прервать эту запись. Используйте наложение камеры OpenScreen.", + "interruptedTitle": "Запись камеры может быть неполной", + "interruptedDescription": "Во время этой записи было включено наложение докладчика macOS, которое могло прервать отдельную дорожку камеры. Отключите его и начните новую запись, если вам нужно редактировать камеру отдельно.", + "screenOnlyTitle": "Наложение включено в видео экрана", + "screenOnlyDescription": "Камера не записывается на отдельную дорожку. Системное наложение включено в видео экрана и не может редактироваться отдельно." } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 86fd7321d5..920760f6c1 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -75,5 +75,13 @@ "screen": "Ekran", "virtual": "Sanal ekran", "unknown": "Ekran kaydı" + }, + "presenterOverlay": { + "beforeTitle": "macOS Sunucu Katmanını kapalı tutun", + "beforeDescription": "OpenScreen, düzenleme için kamerayı ayrı kaydeder. macOS Sunucu Katmanı bu kaydı kesintiye uğratabilir. Bunun yerine OpenScreen’in kamera katmanını kullanın.", + "interruptedTitle": "Kamera kaydı eksik olabilir", + "interruptedDescription": "Bu çekim sırasında macOS Sunucu Katmanı açıldı ve ayrı kamera izini kesintiye uğratmış olabilir. Kamerayı ayrı düzenlemeniz gerekiyorsa sistem katmanını kapatıp yeni bir çekim başlatın.", + "screenOnlyTitle": "Sunucu Katmanı ekran videosuna dahildir", + "screenOnlyDescription": "Kamera ayrı bir iz olarak kaydedilmiyor. Sistem katmanı ekran videosuna dahildir ve bağımsız olarak düzenlenemez." } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 419188852a..7d06dfa962 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -75,5 +75,13 @@ "screen": "Màn hình", "virtual": "Màn hình ảo", "unknown": "Ghi màn hình" + }, + "presenterOverlay": { + "beforeTitle": "Tắt Lớp phủ người thuyết trình của macOS", + "beforeDescription": "OpenScreen ghi camera riêng để chỉnh sửa. Lớp phủ người thuyết trình của macOS có thể làm gián đoạn bản ghi đó. Hãy dùng lớp phủ camera của OpenScreen.", + "interruptedTitle": "Bản ghi camera có thể không đầy đủ", + "interruptedDescription": "Lớp phủ người thuyết trình của macOS đã được bật trong lần ghi này và có thể làm gián đoạn rãnh camera riêng. Hãy tắt lớp phủ hệ thống và bắt đầu lần ghi mới nếu bạn cần chỉnh sửa camera riêng.", + "screenOnlyTitle": "Lớp phủ nằm trong video màn hình", + "screenOnlyDescription": "Camera không được ghi thành rãnh riêng. Lớp phủ hệ thống được đưa vào video màn hình và không thể chỉnh sửa độc lập." } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 607199bac2..8eedc20def 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -75,5 +75,13 @@ "screen": "屏幕", "virtual": "虚拟显示器", "unknown": "屏幕录制" + }, + "presenterOverlay": { + "beforeTitle": "请关闭 macOS 演讲者叠层", + "beforeDescription": "OpenScreen 会单独录制摄像头,方便后续编辑。macOS 演讲者叠层可能中断这条录制,请使用 OpenScreen 自带的摄像头画中画。", + "interruptedTitle": "摄像头录制可能不完整", + "interruptedDescription": "本次录制期间启用了 macOS 演讲者叠层,独立摄像头轨道可能已中断。如需可单独编辑的摄像头画面,请关闭系统叠层并重新录制。", + "screenOnlyTitle": "演讲者叠层已合入屏幕视频", + "screenOnlyDescription": "当前没有单独录制摄像头轨道。系统叠层会直接录入屏幕视频,其中的人像无法单独编辑。" } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index ca72bbeeef..99de21d5a8 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -75,5 +75,13 @@ "screen": "螢幕", "virtual": "虛擬顯示器", "unknown": "螢幕錄製" + }, + "presenterOverlay": { + "beforeTitle": "請關閉 macOS 簡報者疊層", + "beforeDescription": "OpenScreen 會單獨錄製攝影機,方便後續編輯。macOS 簡報者疊層可能中斷這項錄製,請使用 OpenScreen 內建的攝影機子母畫面。", + "interruptedTitle": "攝影機錄影可能不完整", + "interruptedDescription": "本次錄製期間啟用了 macOS 簡報者疊層,獨立攝影機軌道可能已中斷。如需可單獨編輯的攝影機畫面,請關閉系統疊層並重新錄製。", + "screenOnlyTitle": "簡報者疊層已合入螢幕影片", + "screenOnlyDescription": "目前沒有單獨錄製攝影機軌道。系統疊層會直接錄入螢幕影片,其中的人像無法單獨編輯。" } } diff --git a/src/lib/nativeMacRecording.test.ts b/src/lib/nativeMacRecording.test.ts index 6a25d25260..8f349b62cf 100644 --- a/src/lib/nativeMacRecording.test.ts +++ b/src/lib/nativeMacRecording.test.ts @@ -3,6 +3,8 @@ import { collectMacCaptureExcludedWindowIds, parseMacDisplayIdFromSourceId, parseMacWindowIdFromSourceId, + supportsMacPresenterOverlay, + updateMacPresenterOverlayState, } from "./nativeMacRecording"; describe("nativeMacRecording source parsing", () => { @@ -52,3 +54,43 @@ describe("nativeMacRecording source parsing", () => { expect(parseMacDisplayIdFromSourceId(undefined)).toBeNull(); }); }); + +describe("Presenter Overlay capability", () => { + it.each([ + ["darwin", "14.0", "arm64", false, true], + ["darwin", "15.7.1", "arm64", false, true], + ["darwin", "15.7.1", "x64", true, true], + ["darwin", "15.7.1", "x64", false, false], + ["darwin", "13.7", "arm64", false, false], + ["darwin", "unknown", "arm64", false, false], + ["win32", "15", "arm64", false, false], + ["linux", "26", "arm64", false, false], + ])("%s %s %s translated=%s", (platform, version, arch, translated, expected) => { + expect(supportsMacPresenterOverlay(platform, version, arch, translated)).toBe(expected); + }); +}); + +describe("Presenter Overlay recording combinations", () => { + it.each([false, true])("does not flag camera-off takes (overlay=%s)", (active) => { + expect( + updateMacPresenterOverlayState({ active: false, cameraInterrupted: false }, active, false), + ).toEqual({ active, cameraInterrupted: false }); + }); + it("keeps normal independent-camera recording clear until an effect starts", () => { + expect( + updateMacPresenterOverlayState({ active: false, cameraInterrupted: false }, false, true), + ).toEqual({ active: false, cameraInterrupted: false }); + }); + it("retains a camera warning when the effect stops, until a new take", () => { + const affected = updateMacPresenterOverlayState( + { active: false, cameraInterrupted: false }, + true, + true, + ); + expect(affected).toEqual({ active: true, cameraInterrupted: true }); + expect(updateMacPresenterOverlayState(affected, false, true)).toEqual({ + active: false, + cameraInterrupted: true, + }); + }); +}); diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts index 15054cc8b7..09643f088f 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -89,7 +89,44 @@ export type NativeMacHelperEvent = | NativeMacHelperWarningEvent | NativeMacHelperErrorEvent; +/** Current effect and sticky sidecar damage state for one native take. */ +export type NativeMacPresenterOverlayState = { + active: boolean; + cameraInterrupted: boolean; +}; + +/** Retain the risk to earlier camera frames even after the system effect stops. */ +export function updateMacPresenterOverlayState( + previous: NativeMacPresenterOverlayState, + active: boolean, + hasIndependentCamera: boolean, +): NativeMacPresenterOverlayState { + return { + active, + cameraInterrupted: previous.cameraInterrupted || (active && hasIndependentCamera), + }; +} + +export type NativeMacPresenterOverlayEvent = NativeMacPresenterOverlayState & { + recordingId: number; +}; + +/** Presenter Overlay requires Apple Silicon (including Rosetta) and macOS 14+. */ +export function supportsMacPresenterOverlay( + platform: string, + systemVersion: string, + arch: string, + translated: boolean, +): boolean { + return ( + platform === "darwin" && + Number.parseInt(systemVersion, 10) >= 14 && + (arch === "arm64" || translated) + ); +} + export type NativeMacRecordingStartResult = { + presenterOverlay?: NativeMacPresenterOverlayState; success: boolean; recordingId?: number; path?: string; diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 2f643ec222..6c135e2a02 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -80,6 +80,46 @@ The Linux helper mixes the same two sources into one AAC track as well (`AudioMi Cursor samples are persisted as cursor telemetry rather than baked into editable-overlay recordings. The loader resolves the sidecar at `.cursor.json` or through the recording links; see [cursor.md](cursor.md) for the telemetry format and rendering path. +## macOS Presenter Overlay and independent camera recording + +Presenter Overlay is a macOS screen-sharing effect. It composites the person into the +screen video; it does not create OpenScreen's independently editable camera track. +Apple documents that enabling the effect changes the camera feed available to other +capture sessions ([ScreenCaptureKit at WWDC23](https://developer.apple.com/videos/play/wwdc2023/10136/)). + +The HUD handles the combinations separately: + +| Independent camera | System Presenter Overlay | Notice | +| --- | --- | --- | +| Off | Off | None. | +| Off | Active | Informational: the system overlay is part of the screen video and cannot be edited separately. | +| On | Off | Guidance to keep the system effect off and use OpenScreen's editable camera overlay. | +| On | Active during this take | Persistent warning that the independent camera recording may be incomplete. | + +Pre-recording guidance is restricted to Apple Silicon Macs on macOS 14 or later +(including an Intel Electron process under Rosetta). It is guidance, not a claim +that the system effect is currently active: there is no global preflight state query +in this integration. The actual effect is observed through the primary screen +stream's `SCStreamDelegate.outputVideoEffectDidStart(for:)` and +`outputVideoEffectDidStop(for:)` callbacks. The auxiliary system-audio stream is ignored. + +The helper emits additive warning events. The main process associates them with the +current take and whether an independent camera recorder actually started; the renderer +accepts only its starting or current recording ID. The start response also carries a +state snapshot so early callbacks are not lost. Duplicate transitions are coalesced. + +Once an effect has overlapped independent camera recording, disabling it does not clear +the warning: missing earlier frames cannot be recovered by switching the effect off. +The warning also travels through the existing recording-import warning path into the +editor. Starting a new take resets this history. No effect notification automatically +stops or pauses a take, deletes a track, switches modes, or promises camera recovery. +Camera-off takes are not classified as independent-camera conflicts. + +These notices do not implement simultaneous independent-camera compatibility with +Presenter Overlay. System effect availability and composition remain controlled by +macOS and the selected source. See the [desktop test results](../testing/manual-e2e-checklist.md#results-log) +for the tested combinations and outstanding physical-system checks. + ## Known gaps - A window with odd client dimensions can produce black video: H.264 encoding requires even dimensions (`electron/native/wgc-capture/src/wgc_session.cpp:38`). diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 6841efab08..63c9c28f9f 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -1002,3 +1002,5 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-19 | `v2.1.2-rc.1`, `release/v2.1.2` @ `c4ef8fd` (= `v2.1.1` + #1344), assets of the GitHub pre-release (build run 39224311283). Windows: `Openscreen.Setup.2.1.2-rc.1-x64.exe` unpacked with 7-Zip and run in place. macOS: `Openscreen-Mac-arm64-2.1.2-rc.1.zip`, run in place. Linux: the AppImage, run directly with `--no-sandbox` | Windows 11 Home 26200; macOS 27.0.1, Mac mini M1; Ubuntu 24.04 GNOME Wayland (Xwayland `:0`) | **Partial: fix slice only, no defect** | `openscreen mcp` over stdio, scripted, with a fresh `--user-data-dir` per run. On all three platforms it answered `initialize`, and `usage-ping.json` held today's day, which is saved only once PostHog accepted the event. With `DO_NOT_TRACK=1` no state file was written. stdout held only JSON-RPC on macOS and Linux. On Windows it starts with one `\r\n` that Electron 41 itself writes during browser-process startup, before `main.js` loads: a bare `app.exit(0)` script prints it too, `ELECTRON_RUN_AS_NODE` does not. It predates this change, and the MCP SDK clients skip it with one parse error. Not covered: everything else in this file, including recording, editing, export, the GUI ping and the tray toggle. These runs sent eight real `surface: mcp` events to PostHog on 19/10. | | 2026-10-22 | Dev `d8bf2d81` + issue #1260 patch (`fix/agent-stop-1260`), Electron 41.2.1; Vite `127.0.0.1:5186`, private `openscreen1260-ui-*` userData, local controlled OpenAI-compatible provider | macOS 15.8, Apple Silicon | Superseded — pre-`0cb26e07` Agent Stop check | **Superseded by `0cb26e07`:** the two-Undo observation below records the old per-tool behavior. The current implementation applies the accumulated document once on completion or Stop, with one save and one undo step per turn. Automated regressions cover normal and stopped turns with 60 tool edits; this desktop check has not been rerun on the new head. **Historical run:** Real Electron operated through computer-use: sent a first turn, observed Stop while the provider held its SSE response open, applied two `setVocabulary` edits, clicked Stop, saw the stopped status and Send return, confirmed the provider connection closed and the partial answer plus `stopped: true` reached the chat sidecar. Document bytes stayed at `Second edit`; one Undo restored `First edit`, a second restored the initial document. A new turn completed with `Next turn works.` Screenshots captured for running and continued states. Skipped: paid/remote providers, Windows/Linux desktop UI, capture, media preview and export (this patch changes Agent chat only). | | 2026-10-23 | `fix/mac-mic-silent-with-system-audio` on `main` @ `264975d`, dev build (`npm run dev`), capture helper built locally with Xcode 27 and copied from `out/Products/Release` by hand | macOS 26.6.2 (25G83), MacBook Pro M2 Pro; built-in microphone and a USB Audio CODEC (08bb:2902) | **Partial: fix slice only, pass** | #1173, Apple's picker, system audio on, output device idle (after `sudo killall coreaudiod`, or checked idle). Before the fix: the microphone delivered nothing until another app played a sound, 56 s (built-in) and 14 s (USB) into the take; without system audio it started at 0.17 s. With the tap merely started after the stream, four takes (built-in twice, USB twice) recorded the microphone from 0.17–0.18 s, but only 12–30 ms ahead of the tap. With this branch's version, the tap waiting for the first microphone buffer: one USB take recorded the microphone from 0.18 s and the tap started 27 ms after it; one take with the microphone off. In both, a sound played mid-take was in system audio. Measured with temporary per-source buffer counters in the helper, not shipped. **Not covered:** this branch's version with the built-in microphone, a legacy-picker take, macOS 15, an Intel Mac, a microphone that never delivers (the 1 s fallback), pause before the first microphone buffer, a CI-built artifact. | + +| 2026-10-25 | `fix/presenter-overlay-recording-warning`, local dev build based on `6e09c9d1`; fresh SwiftPM release helper (SHA-256 `5c1440f448fc66a052dd34863852db273d8eb46c03a6dfb536bb15d0acb371d0`) and `npm run build-vite` | macOS 15.7.1, Apple M2, Electron 41.2.1; isolated test profile, synthetic window; physical camera lens covered | **Partial — Presenter Overlay notice slice; system-effect transitions pending** | Computer-use drove the actual HUD. Chinese locale: camera off shows no compatibility warning; enabling the camera shows translated pre-recording guidance before source selection; disabling it removes that guidance. Notice wraps above the horizontal controls without covering them. Selected a synthetic test-pattern window using the legacy picker and started a real camera-off native recording; HUD entered recording state with no conflict notice. Apple's picker hid the HUD, but the driver could not access Control Centre (`noWindowsAvailable` / `timeoutReached`); the system-effect switch likewise could not be operated. **Not covered:** actual overlay on/off callbacks and composited output, camera-on capture during the effect, editor handoff of a real effect warning, vertical layout, other macOS versions, Windows/Linux, packaged builds, or the rest of this checklist. No media is attached to this report. | From db636a11edd7b978f7dcfcc5e462d4fe29481e17 Mon Sep 17 00:00:00 2001 From: songsong Date: Sun, 25 Oct 2026 10:43:13 +0800 Subject: [PATCH 2/6] fix(recording): keep Presenter Overlay notices dismissible and non-blocking Add one-time pre-recording guidance shared by the HUD and the editor recording page. During a take, a real conflict warning can be collapsed to a persistent amber status button; acknowledging guidance never suppresses a conflict alert, and a new take resets the state. --- .../ai-edition/v4/RecStage.test.tsx | 49 ++++++++++ src/components/ai-edition/v4/RecStage.tsx | 39 ++++++++ src/components/launch/HudControls.tsx | 62 ++++++++++--- src/components/launch/LaunchWindow.test.tsx | 39 ++++++++ src/components/launch/LaunchWindow.tsx | 89 +++++++++++++------ ...eenRecorder.nativeMacStartWarning.test.tsx | 18 ++++ src/hooks/useScreenRecorder.ts | 1 + src/i18n/locales/ar/common.json | 3 +- src/i18n/locales/cs/common.json | 3 +- src/i18n/locales/de/common.json | 3 +- src/i18n/locales/en/common.json | 3 +- src/i18n/locales/es/common.json | 3 +- src/i18n/locales/fr/common.json | 3 +- src/i18n/locales/it/common.json | 3 +- src/i18n/locales/ja-JP/common.json | 3 +- src/i18n/locales/ko-KR/common.json | 3 +- src/i18n/locales/pt-BR/common.json | 3 +- src/i18n/locales/ru/common.json | 3 +- src/i18n/locales/tr/common.json | 3 +- src/i18n/locales/vi/common.json | 3 +- src/i18n/locales/zh-CN/common.json | 3 +- src/i18n/locales/zh-TW/common.json | 3 +- src/lib/userPreferences.test.ts | 11 +++ src/lib/userPreferences.ts | 4 + .../architecture/recording.md | 23 ++++- .../testing/manual-e2e-checklist.md | 2 +- 26 files changed, 323 insertions(+), 59 deletions(-) diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 49230e2784..bc1f2b1739 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -848,3 +848,52 @@ describe("RecStage names and tooltips", () => { expect(screen.queryByTestId("rec-auto-zoom-button")).toBeNull(); }); }); + +describe("Presenter Overlay preflight guidance", () => { + beforeEach(() => localStorage.clear()); + it("remembers acknowledgement instead of nagging on every visit", async () => { + stubRecordingPrefs({ camEnabled: true }); + window.electronAPI.getAppInfo = vi.fn(async () => ({ + version: "2.1.2", + canCheckForUpdates: false, + supportsPresenterOverlay: true, + })); + const first = renderRecStage(); + fireEvent.click(await screen.findByRole("button", { name: "presenterOverlay.acknowledge" })); + expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument(); + first.unmount(); + renderRecStage(); + await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalledTimes(2)); + expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument(); + }); + + it.each([ + false, + true, + ])("only shows guidance for supported camera-on recording (camera=%s)", async (camEnabled) => { + stubRecordingPrefs({ camEnabled }); + window.electronAPI.getAppInfo = vi.fn(async () => ({ + version: "2.1.2", + canCheckForUpdates: false, + supportsPresenterOverlay: true, + })); + renderRecStage(); + await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalled()); + if (camEnabled) { + expect(await screen.findByText("presenterOverlay.beforeDescription")).toBeInTheDocument(); + } else { + expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument(); + } + }); + it("keeps unsupported platforms free of macOS guidance", async () => { + stubRecordingPrefs({ camEnabled: true }); + window.electronAPI.getAppInfo = vi.fn(async () => ({ + version: "2.1.2", + canCheckForUpdates: false, + supportsPresenterOverlay: false, + })); + renderRecStage(); + await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalled()); + expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 5c231f798f..7694e8ebc1 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -26,6 +26,7 @@ import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; import { usePortalOwnsSource } from "@/hooks/usePortalOwnsSource"; import { canTurnCameraOn } from "@/lib/cameraAvailability"; import { canTurnMicrophoneOn } from "@/lib/microphoneAvailability"; +import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; import styles from "./EditorShellV4.module.css"; @@ -88,6 +89,24 @@ export function RecStage({ onClose?: () => void; }) { const t = useScopedT("editor"); + const tCommon = useScopedT("common"); + const [supportsPresenterOverlay, setSupportsPresenterOverlay] = useState(false); + const [overlayGuidanceAcknowledged, setOverlayGuidanceAcknowledged] = useState( + () => loadUserPreferences().presenterOverlayGuidanceAcknowledged, + ); + useEffect(() => { + let cancelled = false; + void window.electronAPI + ?.getAppInfo?.() + .then((info) => { + if (!cancelled) setSupportsPresenterOverlay(info.supportsPresenterOverlay === true); + }) + .catch((err) => console.warn("[rec-stage] failed to read app capabilities:", err)); + return () => { + cancelled = true; + }; + }, []); + const [prefs, setPrefsState] = useState(DEFAULT_PREFS); // Bumped by every local change and every pushed snapshot, so the re-read after a // failed write can tell that something newer has landed since. @@ -404,6 +423,25 @@ export function RecStage({
+ {prefs.camEnabled && supportsPresenterOverlay && !overlayGuidanceAcknowledged && ( +
+

{tCommon("presenterOverlay.beforeTitle")}

+

+ {tCommon("presenterOverlay.beforeDescription")} +

+ +
+ )} + {/* No source row on Linux: `SelectSources` has no parameter naming a source, so this picker could not steer the capture — it only raised a second portal dialog, via `desktopCapturer.getSources()`, @@ -733,6 +771,7 @@ function SourceModal({ onClose: () => void; }) { const t = useScopedT("editor"); + return (
e.stopPropagation()}> diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index 2c5fa7df1f..60617b4a1b 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -1,4 +1,4 @@ -import { Check, Languages, NotepadText, Settings } from "lucide-react"; +import { AlertTriangle, Check, Info, Languages, NotepadText, Settings } from "lucide-react"; import { createContext, memo, @@ -711,6 +711,35 @@ export const HudLanguageMenu = memo(function HudLanguageMenu({ ); }); +export const HudPresenterOverlayButton = memo(function HudPresenterOverlayButton({ + warning, + label, + expanded, + onClick, +}: { + warning: boolean; + label: string; + expanded: boolean; + onClick: () => void; +}) { + return ( + + + + ); +}); + export const HudNotice = memo(function HudNotice({ title, description, @@ -718,20 +747,27 @@ export const HudNotice = memo(function HudNotice({ confirmLabel, onDismiss, onConfirm, + role = "status", }: { title: string; description: string; dismissLabel: string; - confirmLabel: string; + confirmLabel?: string; onDismiss: () => void; - onConfirm: () => void; + onConfirm?: () => void; + role?: "status" | "alert"; }) { return (
-
{title}
+
+ {title} +
{description}
- + {confirmLabel && onConfirm && ( + + )}
); diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 281f0991e3..af84ce9d0a 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -2417,6 +2417,8 @@ describe("LaunchWindow toasts", () => { recorderState.value.recording = true; recorderState.value.presenterOverlay = { active: true, cameraInterrupted: false }; renderLaunchWindow(); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId("presenter-overlay-status")); await waitFor(() => expect(screen.getByTestId("presenter-overlay-notice")).toHaveTextContent( "presenterOverlay.screenOnlyDescription", @@ -2425,6 +2427,43 @@ describe("LaunchWindow toasts", () => { expect(screen.queryByText("presenterOverlay.interruptedDescription")).not.toBeInTheDocument(); }); + it("does not show compatibility guidance during an unaffected recording", async () => { + recorderState.value.recording = true; + recorderState.value.webcamEnabled = true; + renderLaunchWindow(); + await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalled()); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + expect(screen.queryByTestId("presenter-overlay-status")).not.toBeInTheDocument(); + }); + + it("remembers acknowledgement of pre-recording guidance across HUD mounts", async () => { + recorderState.value.webcamEnabled = true; + const first = renderLaunchWindow(); + fireEvent.click(await screen.findByRole("button", { name: "presenterOverlay.acknowledge" })); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + first.unmount(); + renderLaunchWindow(); + await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalledTimes(2)); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + }); + + it("lets the user collapse a conflict without losing its status or stopping capture", async () => { + recorderState.value.recording = true; + recorderState.value.presenterOverlay = { active: true, cameraInterrupted: true }; + renderLaunchWindow(); + await screen.findByTestId("presenter-overlay-notice"); + fireEvent.click(screen.getByRole("button", { name: "actions.close" })); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + expect(screen.getByTestId("presenter-overlay-status")).toHaveAttribute( + "aria-expanded", + "false", + ); + fireEvent.click(screen.getByTestId("presenter-overlay-status")); + expect(screen.getByTestId("presenter-overlay-notice")).toBeInTheDocument(); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + expect(recorderState.value.cancelRecording).not.toHaveBeenCalled(); + }); + it("keeps the camera warning visible after the system overlay stops", async () => { recorderState.value.recording = true; recorderState.value.presenterOverlay = { active: false, cameraInterrupted: true }; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 237d5765ca..06c37eebac 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -30,6 +30,7 @@ import { HudMicButton, HudNotesButton, HudNotice, + HudPresenterOverlayButton, HudRecordButton, HudRecordingControls, HudSettingsButton, @@ -162,6 +163,14 @@ export function LaunchWindow() { supportsPresenterOverlay?: boolean; } | null>(null); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); + const [overlayGuidanceAcknowledged, setOverlayGuidanceAcknowledged] = useState( + () => loadUserPreferences().presenterOverlayGuidanceAcknowledged, + ); + const [overlayDetailsOpen, setOverlayDetailsOpen] = useState(false); + useEffect(() => { + setOverlayDetailsOpen(Boolean(presenterOverlay?.cameraInterrupted)); + }, [presenterOverlay?.cameraInterrupted]); + /** * Narrower than [`isLinuxHud`] on purpose: without the helper the recorder * falls back to Chromium's capture, which DOES take a source id, so the @@ -1179,13 +1188,18 @@ export function LaunchWindow() { ? "interrupted" : presenterOverlay?.active ? "screenOnly" - : webcamEnabled && getPlatform() === "darwin" && appInfo?.supportsPresenterOverlay + : !recording && + !saving && + !overlayGuidanceAcknowledged && + webcamEnabled && + getPlatform() === "darwin" && + appInfo?.supportsPresenterOverlay ? "before" : null; + const showOverlayDetails = + overlayNotice === "before" || (overlayNotice !== null && overlayDetailsOpen); const hasNotices = - Boolean(systemLocaleSuggestion) || - softwareEncoderFallbackNoticeVisible || - overlayNotice !== null; + Boolean(systemLocaleSuggestion) || softwareEncoderFallbackNoticeVisible || showOverlayDetails; return ( // Avoid w-screen/h-screen: 100vw can exceed the inner layout width when scrollbars @@ -1314,6 +1328,19 @@ export function LaunchWindow() { /> )} + {overlayNotice && overlayNotice !== "before" && ( + setOverlayDetailsOpen((open) => !open)} + /> + )} + - {overlayNotice && ( -
-
- {overlayNotice === "interrupted" - ? tCommon("presenterOverlay.interruptedTitle") - : overlayNotice === "screenOnly" - ? tCommon("presenterOverlay.screenOnlyTitle") - : tCommon("presenterOverlay.beforeTitle")} -
-
- {overlayNotice === "interrupted" - ? tCommon("presenterOverlay.interruptedDescription") - : overlayNotice === "screenOnly" - ? tCommon("presenterOverlay.screenOnlyDescription") - : tCommon("presenterOverlay.beforeDescription")} -
+ {showOverlayDetails && ( +
+ { + if (overlayNotice === "before") { + saveUserPreferences({ presenterOverlayGuidanceAcknowledged: true }); + setOverlayGuidanceAcknowledged(true); + } + setOverlayDetailsOpen(false); + }} + />
)} {systemLocaleSuggestion && ( diff --git a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx index 8e4cb6af6d..569d86a942 100644 --- a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx +++ b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx @@ -411,6 +411,24 @@ describe("useScreenRecorder native macOS start warnings", () => { }); describe("Presenter Overlay event lifetime", () => { + it("clears a discarded take's warning without waiting for another start", async () => { + const view = renderHook(() => useScreenRecorder()); + await settle(); + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + await act(async () => { + emitPresenterOverlay?.({ recordingId: 7, active: true, cameraInterrupted: true }); + }); + expect(view.result.current.presenterOverlay?.cameraInterrupted).toBe(true); + await act(async () => { + await view.result.current.cancelRecording(); + }); + expect(view.result.current.presenterOverlay).toBeNull(); + expect(view.result.current.recording).toBe(false); + }); + it("ignores other takes and never stops a recording because of an effect", async () => { const view = renderHook(() => useScreenRecorder()); await settle(); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index aba8fc713c..b09132792f 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -825,6 +825,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const clearNativeRecordingState = () => { nativeMacRecording.current = null; + setPresenterOverlay(null); setRecording(false); setPaused(false); setElapsedSeconds(0); diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 75e44b2f2a..7951a98edd 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "قد يكون تسجيل الكاميرا غير مكتمل", "interruptedDescription": "تم تفعيل تراكب مقدم العرض في macOS أثناء هذا التسجيل، وقد يكون قد قاطع مسار الكاميرا المنفصل. عطّله وابدأ تسجيلًا جديدًا إذا كنت بحاجة إلى تحرير الكاميرا بشكل مستقل.", "screenOnlyTitle": "التراكب جزء من فيديو الشاشة", - "screenOnlyDescription": "لا يجري تسجيل الكاميرا في مسار منفصل. يُضمَّن تراكب النظام في فيديو الشاشة ولا يمكن تحريره بشكل مستقل." + "screenOnlyDescription": "لا يجري تسجيل الكاميرا في مسار منفصل. يُضمَّن تراكب النظام في فيديو الشاشة ولا يمكن تحريره بشكل مستقل.", + "acknowledge": "فهمت، لا تعرض مرة أخرى" } } diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index 6a653a8cba..2368b28370 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "Záznam kamery může být neúplný", "interruptedDescription": "Během tohoto záznamu byl zapnut překryv prezentujícího v macOS, který mohl přerušit samostatnou stopu kamery. Vypněte jej a spusťte nový záznam, pokud potřebujete kameru upravovat samostatně.", "screenOnlyTitle": "Překryv je součástí videa obrazovky", - "screenOnlyDescription": "Kamera se nenahrává do samostatné stopy. Systémový překryv je součástí videa obrazovky a nelze jej upravovat samostatně." + "screenOnlyDescription": "Kamera se nenahrává do samostatné stopy. Systémový překryv je součástí videa obrazovky a nelze jej upravovat samostatně.", + "acknowledge": "Rozumím, znovu nezobrazovat" } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 407db99860..df2a9356a2 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "Die Kameraaufnahme ist möglicherweise unvollständig", "interruptedDescription": "Die macOS-Moderatorenmaske wurde während dieser Aufnahme aktiviert und hat möglicherweise die separate Kameraspur unterbrochen. Deaktiviere sie und starte eine neue Aufnahme, wenn du die Kamera separat bearbeiten möchtest.", "screenOnlyTitle": "Die Moderatorenmaske ist Teil des Bildschirmvideos", - "screenOnlyDescription": "Die Kamera wird nicht als separate Spur aufgezeichnet. Die Systemeinblendung ist im Bildschirmvideo enthalten und lässt sich nicht unabhängig bearbeiten." + "screenOnlyDescription": "Die Kamera wird nicht als separate Spur aufgezeichnet. Die Systemeinblendung ist im Bildschirmvideo enthalten und lässt sich nicht unabhängig bearbeiten.", + "acknowledge": "Verstanden, nicht mehr anzeigen" } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 068b1514dc..0529090478 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "Camera recording may be incomplete", "interruptedDescription": "macOS Presenter Overlay was enabled during this take and may have interrupted the separate camera track. Turn it off and start a fresh take if you need an editable camera recording.", "screenOnlyTitle": "Presenter Overlay is in the screen video", - "screenOnlyDescription": "Your camera is not being recorded as a separate track. The system overlay is included in the screen video and cannot be edited independently." + "screenOnlyDescription": "Your camera is not being recorded as a separate track. The system overlay is included in the screen video and cannot be edited independently.", + "acknowledge": "Got it, don't show again" } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 6515dff56c..0fd0c1d944 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "La grabación de cámara puede estar incompleta", "interruptedDescription": "Se activó la superposición del presentador de macOS durante esta toma y puede haber interrumpido la pista de cámara independiente. Desactívala e inicia una nueva toma si necesitas editar la cámara por separado.", "screenOnlyTitle": "La superposición forma parte del vídeo de pantalla", - "screenOnlyDescription": "La cámara no se está grabando en una pista independiente. La superposición del sistema se incluye en el vídeo de pantalla y no se puede editar por separado." + "screenOnlyDescription": "La cámara no se está grabando en una pista independiente. La superposición del sistema se incluye en el vídeo de pantalla y no se puede editar por separado.", + "acknowledge": "Entendido, no volver a mostrar" } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 0260913b56..84d1da2506 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "L’enregistrement caméra peut être incomplet", "interruptedDescription": "L’incrustation du présentateur de macOS a été activée pendant cette prise et a pu interrompre la piste caméra séparée. Désactivez-la et recommencez une prise si vous avez besoin de modifier la caméra séparément.", "screenOnlyTitle": "L’incrustation fait partie de la vidéo de l’écran", - "screenOnlyDescription": "La caméra n’est pas enregistrée sur une piste séparée. L’incrustation système est intégrée à la vidéo de l’écran et ne peut pas être modifiée indépendamment." + "screenOnlyDescription": "La caméra n’est pas enregistrée sur une piste séparée. L’incrustation système est intégrée à la vidéo de l’écran et ne peut pas être modifiée indépendamment.", + "acknowledge": "Compris, ne plus afficher" } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 4b322160e3..7cc4c9a1a0 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "La registrazione della videocamera potrebbe essere incompleta", "interruptedDescription": "La sovrapposizione del presentatore di macOS è stata attivata durante questa ripresa e potrebbe aver interrotto la traccia separata della videocamera. Disattivala e avvia una nuova ripresa se devi modificare la videocamera separatamente.", "screenOnlyTitle": "La sovrapposizione è inclusa nel video dello schermo", - "screenOnlyDescription": "La videocamera non viene registrata su una traccia separata. La sovrapposizione di sistema è inclusa nel video dello schermo e non può essere modificata separatamente." + "screenOnlyDescription": "La videocamera non viene registrata su una traccia separata. La sovrapposizione di sistema è inclusa nel video dello schermo e non può essere modificata separatamente.", + "acknowledge": "Capito, non mostrare più" } } diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index 7ef563171a..cb7015524a 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "カメラ録画が不完全な可能性があります", "interruptedDescription": "今回の録画中に macOS のプレゼンターオーバーレイが有効になり、カメラトラックが中断された可能性があります。カメラ映像を個別に編集する必要がある場合は、システムのオーバーレイをオフにして録画し直してください。", "screenOnlyTitle": "プレゼンターオーバーレイは画面動画に含まれます", - "screenOnlyDescription": "カメラは別のトラックとして録画されていません。システムのオーバーレイは画面動画に含まれ、個別に編集できません。" + "screenOnlyDescription": "カメラは別のトラックとして録画されていません。システムのオーバーレイは画面動画に含まれ、個別に編集できません。", + "acknowledge": "確認しました。今後は表示しない" } } diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index 59e9322fbd..aad8a5c46b 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "카메라 녹화가 불완전할 수 있습니다", "interruptedDescription": "이번 녹화 중 macOS 발표자 오버레이가 켜져 별도의 카메라 트랙이 중단되었을 수 있습니다. 카메라 영상을 개별 편집하려면 시스템 오버레이를 끄고 새로 녹화하세요.", "screenOnlyTitle": "발표자 오버레이는 화면 영상에 포함됩니다", - "screenOnlyDescription": "카메라가 별도 트랙으로 녹화되고 있지 않습니다. 시스템 오버레이는 화면 영상에 포함되며 개별 편집할 수 없습니다." + "screenOnlyDescription": "카메라가 별도 트랙으로 녹화되고 있지 않습니다. 시스템 오버레이는 화면 영상에 포함되며 개별 편집할 수 없습니다.", + "acknowledge": "확인했으며 다시 표시하지 않음" } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 4267fd0948..81e20213e8 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "A gravação da câmera pode estar incompleta", "interruptedDescription": "A Sobreposição do Apresentador do macOS foi ativada durante esta tomada e pode ter interrompido a faixa separada da câmera. Desative-a e inicie uma nova tomada se precisar editar a câmera separadamente.", "screenOnlyTitle": "A sobreposição faz parte do vídeo da tela", - "screenOnlyDescription": "A câmera não está sendo gravada em uma faixa separada. A sobreposição do sistema está incluída no vídeo da tela e não pode ser editada separadamente." + "screenOnlyDescription": "A câmera não está sendo gravada em uma faixa separada. A sobreposição do sistema está incluída no vídeo da tela e não pode ser editada separadamente.", + "acknowledge": "Entendi, não mostrar novamente" } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index f0c364e6b4..0c27e055ea 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "Запись камеры может быть неполной", "interruptedDescription": "Во время этой записи было включено наложение докладчика macOS, которое могло прервать отдельную дорожку камеры. Отключите его и начните новую запись, если вам нужно редактировать камеру отдельно.", "screenOnlyTitle": "Наложение включено в видео экрана", - "screenOnlyDescription": "Камера не записывается на отдельную дорожку. Системное наложение включено в видео экрана и не может редактироваться отдельно." + "screenOnlyDescription": "Камера не записывается на отдельную дорожку. Системное наложение включено в видео экрана и не может редактироваться отдельно.", + "acknowledge": "Понятно, больше не показывать" } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 920760f6c1..6ba12a8e4c 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "Kamera kaydı eksik olabilir", "interruptedDescription": "Bu çekim sırasında macOS Sunucu Katmanı açıldı ve ayrı kamera izini kesintiye uğratmış olabilir. Kamerayı ayrı düzenlemeniz gerekiyorsa sistem katmanını kapatıp yeni bir çekim başlatın.", "screenOnlyTitle": "Sunucu Katmanı ekran videosuna dahildir", - "screenOnlyDescription": "Kamera ayrı bir iz olarak kaydedilmiyor. Sistem katmanı ekran videosuna dahildir ve bağımsız olarak düzenlenemez." + "screenOnlyDescription": "Kamera ayrı bir iz olarak kaydedilmiyor. Sistem katmanı ekran videosuna dahildir ve bağımsız olarak düzenlenemez.", + "acknowledge": "Anladım, tekrar gösterme" } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 7d06dfa962..e8791830f5 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "Bản ghi camera có thể không đầy đủ", "interruptedDescription": "Lớp phủ người thuyết trình của macOS đã được bật trong lần ghi này và có thể làm gián đoạn rãnh camera riêng. Hãy tắt lớp phủ hệ thống và bắt đầu lần ghi mới nếu bạn cần chỉnh sửa camera riêng.", "screenOnlyTitle": "Lớp phủ nằm trong video màn hình", - "screenOnlyDescription": "Camera không được ghi thành rãnh riêng. Lớp phủ hệ thống được đưa vào video màn hình và không thể chỉnh sửa độc lập." + "screenOnlyDescription": "Camera không được ghi thành rãnh riêng. Lớp phủ hệ thống được đưa vào video màn hình và không thể chỉnh sửa độc lập.", + "acknowledge": "Đã hiểu, không hiển thị lại" } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 8eedc20def..1b928a151c 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "摄像头录制可能不完整", "interruptedDescription": "本次录制期间启用了 macOS 演讲者叠层,独立摄像头轨道可能已中断。如需可单独编辑的摄像头画面,请关闭系统叠层并重新录制。", "screenOnlyTitle": "演讲者叠层已合入屏幕视频", - "screenOnlyDescription": "当前没有单独录制摄像头轨道。系统叠层会直接录入屏幕视频,其中的人像无法单独编辑。" + "screenOnlyDescription": "当前没有单独录制摄像头轨道。系统叠层会直接录入屏幕视频,其中的人像无法单独编辑。", + "acknowledge": "知道了,不再提示" } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 99de21d5a8..ea5c1caed5 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -82,6 +82,7 @@ "interruptedTitle": "攝影機錄影可能不完整", "interruptedDescription": "本次錄製期間啟用了 macOS 簡報者疊層,獨立攝影機軌道可能已中斷。如需可單獨編輯的攝影機畫面,請關閉系統疊層並重新錄製。", "screenOnlyTitle": "簡報者疊層已合入螢幕影片", - "screenOnlyDescription": "目前沒有單獨錄製攝影機軌道。系統疊層會直接錄入螢幕影片,其中的人像無法單獨編輯。" + "screenOnlyDescription": "目前沒有單獨錄製攝影機軌道。系統疊層會直接錄入螢幕影片,其中的人像無法單獨編輯。", + "acknowledge": "知道了,不再提示" } } diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts index ca66c53d5f..fcaf61faeb 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -88,6 +88,17 @@ describe("projectFolder preference", () => { }); describe("user preferences", () => { + it("only accepts a boolean acknowledgement and preserves it alongside other preferences", () => { + localStorage.setItem( + "openscreen_user_preferences", + JSON.stringify({ presenterOverlayGuidanceAcknowledged: "true" }), + ); + expect(loadUserPreferences().presenterOverlayGuidanceAcknowledged).toBe(false); + saveUserPreferences({ presenterOverlayGuidanceAcknowledged: true }); + saveUserPreferences({ trayLayout: "vertical" }); + expect(loadUserPreferences().presenterOverlayGuidanceAcknowledged).toBe(true); + }); + beforeEach(() => { localStorage.clear(); }); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index f98fb422d3..b9f293fe95 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -26,6 +26,8 @@ export interface UserPreferences { preferSoftwareEncoder: boolean; /** Stop showing the notice that recording fell back to software encoding */ hideSoftwareEncoderFallbackNotice: boolean; + /** The user has acknowledged the one-time macOS camera compatibility guidance. */ + presenterOverlayGuidanceAcknowledged: boolean; } export const DEFAULT_PREFS: UserPreferences = { @@ -38,6 +40,7 @@ export const DEFAULT_PREFS: UserPreferences = { trayLayout: "horizontal", preferSoftwareEncoder: false, hideSoftwareEncoderFallbackNotice: false, + presenterOverlayGuidanceAcknowledged: false, }; /** Parses stored preferences without throwing on malformed JSON. */ @@ -95,6 +98,7 @@ export function loadUserPreferences(): UserPreferences { typeof raw.preferSoftwareEncoder === "boolean" ? raw.preferSoftwareEncoder : DEFAULT_PREFS.preferSoftwareEncoder, + presenterOverlayGuidanceAcknowledged: raw.presenterOverlayGuidanceAcknowledged === true, hideSoftwareEncoderFallbackNotice: typeof raw.hideSoftwareEncoderFallbackNotice === "boolean" ? raw.hideSoftwareEncoderFallbackNotice diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 6c135e2a02..1ffad0a8c8 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -87,21 +87,36 @@ screen video; it does not create OpenScreen's independently editable camera trac Apple documents that enabling the effect changes the camera feed available to other capture sessions ([ScreenCaptureKit at WWDC23](https://developer.apple.com/videos/play/wwdc2023/10136/)). +Apple makes Presenter Overlay available when an application uses the camera together +with an active screen stream. Turning off OpenScreen's camera recording therefore does +not guarantee that the system menu will offer this effect. OpenScreen does not open +the camera solely to enable Presenter Overlay. If the system does apply the effect, +the resulting screen video contains the composited image. + The HUD handles the combinations separately: | Independent camera | System Presenter Overlay | Notice | | --- | --- | --- | | Off | Off | None. | -| Off | Active | Informational: the system overlay is part of the screen video and cannot be edited separately. | -| On | Off | Guidance to keep the system effect off and use OpenScreen's editable camera overlay. | -| On | Active during this take | Persistent warning that the independent camera recording may be incomplete. | +| Off | Active | Informational status button: the system overlay is part of the screen video and cannot be edited separately. | +| On | Off | One-time pre-recording guidance; no compatibility banner during an ordinary take. | +| On | Active during this take | Warning opens once; it can be collapsed to a persistent warning button. | + +The editor recording page and HUD share a local preference remembering explicit +acknowledgement of the guidance. Acknowledgement never suppresses actual conflict +alerts. During a take, only a detected effect creates a status button. A conflict +opens details once; closing them does not clear the risk, and clicking the button +reopens them. Normal effect-free recording has no compatibility banner or status +button. No native modal interrupts capture. Pre-recording guidance is restricted to Apple Silicon Macs on macOS 14 or later (including an Intel Electron process under Rosetta). It is guidance, not a claim that the system effect is currently active: there is no global preflight state query in this integration. The actual effect is observed through the primary screen stream's `SCStreamDelegate.outputVideoEffectDidStart(for:)` and -`outputVideoEffectDidStop(for:)` callbacks. The auxiliary system-audio stream is ignored. +`outputVideoEffectDidStop(for:)` callbacks. The auxiliary system-audio stream is ignored. Effects applied to another application's +stream are not reported by these callbacks; this is not a global camera-availability +monitor. The helper emits additive warning events. The main process associates them with the current take and whether an independent camera recorder actually started; the renderer diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 63c9c28f9f..79bd6708a1 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -1003,4 +1003,4 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-22 | Dev `d8bf2d81` + issue #1260 patch (`fix/agent-stop-1260`), Electron 41.2.1; Vite `127.0.0.1:5186`, private `openscreen1260-ui-*` userData, local controlled OpenAI-compatible provider | macOS 15.8, Apple Silicon | Superseded — pre-`0cb26e07` Agent Stop check | **Superseded by `0cb26e07`:** the two-Undo observation below records the old per-tool behavior. The current implementation applies the accumulated document once on completion or Stop, with one save and one undo step per turn. Automated regressions cover normal and stopped turns with 60 tool edits; this desktop check has not been rerun on the new head. **Historical run:** Real Electron operated through computer-use: sent a first turn, observed Stop while the provider held its SSE response open, applied two `setVocabulary` edits, clicked Stop, saw the stopped status and Send return, confirmed the provider connection closed and the partial answer plus `stopped: true` reached the chat sidecar. Document bytes stayed at `Second edit`; one Undo restored `First edit`, a second restored the initial document. A new turn completed with `Next turn works.` Screenshots captured for running and continued states. Skipped: paid/remote providers, Windows/Linux desktop UI, capture, media preview and export (this patch changes Agent chat only). | | 2026-10-23 | `fix/mac-mic-silent-with-system-audio` on `main` @ `264975d`, dev build (`npm run dev`), capture helper built locally with Xcode 27 and copied from `out/Products/Release` by hand | macOS 26.6.2 (25G83), MacBook Pro M2 Pro; built-in microphone and a USB Audio CODEC (08bb:2902) | **Partial: fix slice only, pass** | #1173, Apple's picker, system audio on, output device idle (after `sudo killall coreaudiod`, or checked idle). Before the fix: the microphone delivered nothing until another app played a sound, 56 s (built-in) and 14 s (USB) into the take; without system audio it started at 0.17 s. With the tap merely started after the stream, four takes (built-in twice, USB twice) recorded the microphone from 0.17–0.18 s, but only 12–30 ms ahead of the tap. With this branch's version, the tap waiting for the first microphone buffer: one USB take recorded the microphone from 0.18 s and the tap started 27 ms after it; one take with the microphone off. In both, a sound played mid-take was in system audio. Measured with temporary per-source buffer counters in the helper, not shipped. **Not covered:** this branch's version with the built-in microphone, a legacy-picker take, macOS 15, an Intel Mac, a microphone that never delivers (the 1 s fallback), pause before the first microphone buffer, a CI-built artifact. | -| 2026-10-25 | `fix/presenter-overlay-recording-warning`, local dev build based on `6e09c9d1`; fresh SwiftPM release helper (SHA-256 `5c1440f448fc66a052dd34863852db273d8eb46c03a6dfb536bb15d0acb371d0`) and `npm run build-vite` | macOS 15.7.1, Apple M2, Electron 41.2.1; isolated test profile, synthetic window; physical camera lens covered | **Partial — Presenter Overlay notice slice; system-effect transitions pending** | Computer-use drove the actual HUD. Chinese locale: camera off shows no compatibility warning; enabling the camera shows translated pre-recording guidance before source selection; disabling it removes that guidance. Notice wraps above the horizontal controls without covering them. Selected a synthetic test-pattern window using the legacy picker and started a real camera-off native recording; HUD entered recording state with no conflict notice. Apple's picker hid the HUD, but the driver could not access Control Centre (`noWindowsAvailable` / `timeoutReached`); the system-effect switch likewise could not be operated. **Not covered:** actual overlay on/off callbacks and composited output, camera-on capture during the effect, editor handoff of a real effect warning, vertical layout, other macOS versions, Windows/Linux, packaged builds, or the rest of this checklist. No media is attached to this report. | +| 2026-10-25 | `fix/presenter-overlay-recording-warning`, dev build based on `6e09c9d1`; `npm run build-vite`, fresh SwiftPM release helper SHA-256 `5c1440f448fc66a052dd34863852db273d8eb46c03a6dfb536bb15d0acb371d0` (hash-matched to staged binaries) | macOS 15.7.1, Apple M2, Electron 41.2.1; isolated profiles, synthetic window; physical camera lens covered | **Partial — Presenter Overlay warning slice passed** | Computer-use drove HUD/editor controls; a local operator toggled Apple's otherwise inaccessible system menu. Camera off: native synthetic-window recording starts with no conflict warning; the operator found no system effect option, so camera-off overlay availability is not promised. Camera on: real Small on/off emitted both helper warning events; the HUD warned, kept recording, retained the warning after Off, and passed the translated warning into the editor. Refined UI: normal capture has no banner; one-time guidance can be acknowledged in either the HUD or editor recording page and stays acknowledged across mounts. A real conflict opens details; Close collapses them to an amber button, which reopens details; horizontal and vertical controls remain usable. Helper output was checked locally through Save Diagnostics, not injected events. **Not covered:** Large mode, initial-effect state before recording, a full-display take, Apple's source-picker interactions, other macOS versions/devices, Windows/Linux runtime, packaged builds, or the rest of this checklist. Startup/stale/duplicate events, camera-off effect classification, discard/restart reset and notice dismissal have unit coverage. No media is attached. | From 8f8b829a16215392fba81d6230be203eedeae556 Mon Sep 17 00:00:00 2001 From: songsong Date: Sun, 25 Oct 2026 10:57:50 +0800 Subject: [PATCH 3/6] fix(recording): tighten Presenter Overlay warning state and tests Accept overlay transitions that reach the helper drain while a stop is in flight, so the editor warning cannot miss an end-of-take overlap. Close on-request camera-off details when the effect goes away, make aria-controls match the mounted panel, and keep the HUD button's callback stable. Document that detection is transition-based and an effect already on at start is not classified, name the tested revision in the checklist, and add regressions for acknowledged guidance versus real conflicts and one-time auto-expansion. --- electron/ipc/handlers.ts | 3 + .../ipc/nativeMacMidCaptureErrorWatch.test.ts | 19 ++++++ electron/ipc/nativeMacMidCaptureErrorWatch.ts | 6 +- src/components/launch/HudControls.tsx | 2 +- src/components/launch/LaunchWindow.test.tsx | 58 +++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 10 +++- .../architecture/recording.md | 8 ++- .../testing/manual-e2e-checklist.md | 2 +- 8 files changed, 102 insertions(+), 6 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3b49ece22c..5d6eeef604 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1643,6 +1643,9 @@ function attachNativeMacCaptureOutputDrain( onTakeEnded, onSystemAudioUnavailable, onPresenterOverlayChanged, + // Overlay transitions keep counting until the helper process is replaced: a start + // line can reach the drain after Stop was pressed and still belongs to this take. + () => nativeMacCaptureProcess === proc, ); const drain = (chunk: Buffer) => { const text = chunk.toString(); diff --git a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts index 085876502e..0e276df1d6 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts @@ -128,4 +128,23 @@ describe("createNativeMacMidCaptureErrorWatch", () => { watch({ event: "warning", code: "presenter-overlay-started" }); expect(onOverlay).not.toHaveBeenCalled(); }); + + it("counts an overlay transition that arrives while a stop is in flight", () => { + const onEnded = vi.fn(); + const onOverlay = vi.fn(); + const onSystemAudioUnavailable = vi.fn(); + const watch = createNativeMacMidCaptureErrorWatch( + () => false, + onEnded, + onSystemAudioUnavailable, + onOverlay, + () => true, + ); + watch({ event: "warning", code: "presenter-overlay-started" }); + watch({ event: "warning", code: "system-audio-unavailable" }); + watch({ event: "error", message: "still ignored" }); + expect(onOverlay).toHaveBeenCalledWith(true); + expect(onSystemAudioUnavailable).not.toHaveBeenCalled(); + expect(onEnded).not.toHaveBeenCalled(); + }); }); diff --git a/electron/ipc/nativeMacMidCaptureErrorWatch.ts b/electron/ipc/nativeMacMidCaptureErrorWatch.ts index 734b947cbb..1c9d09466b 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.ts @@ -17,6 +17,10 @@ export function createNativeMacMidCaptureErrorWatch( onTakeEnded: () => void, onSystemAudioUnavailable: () => void, onPresenterOverlayChanged?: (active: boolean) => void, + // Presenter Overlay transitions still matter while a stop is in flight: the warning is + // written after the helper exits, so a line that arrives during the stop must update it. + // Ending the take and the system-audio notice stay restricted to a live take. + isCurrentTake: () => boolean = isLiveTake, ) { let recordingStarted = false; let presenterOverlayActive = false; @@ -34,7 +38,7 @@ export function createNativeMacMidCaptureErrorWatch( reportedSystemAudioUnavailable = true; onSystemAudioUnavailable(); } - if (event.event === "warning" && isLiveTake()) { + if (event.event === "warning" && isCurrentTake()) { const active = event.code === "presenter-overlay-started" ? true diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index 60617b4a1b..81046adecc 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -730,7 +730,7 @@ export const HudPresenterOverlayButton = memo(function HudPresenterOverlayButton data-testid="presenter-overlay-status" aria-label={label} aria-expanded={expanded} - aria-controls="presenter-overlay-details" + aria-controls={expanded ? "presenter-overlay-details" : undefined} className={`${hudIconBtnClasses} ${warning ? "!text-amber-300" : ""}`} onClick={onClick} > diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index af84ce9d0a..85e3dc98b8 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -2464,6 +2464,64 @@ describe("LaunchWindow toasts", () => { expect(recorderState.value.cancelRecording).not.toHaveBeenCalled(); }); + it("does not let acknowledged guidance hide a real conflict", async () => { + localStorage.setItem( + "openscreen_user_preferences", + JSON.stringify({ presenterOverlayGuidanceAcknowledged: true }), + ); + recorderState.value.recording = true; + recorderState.value.presenterOverlay = { active: true, cameraInterrupted: true }; + renderLaunchWindow(); + await screen.findByTestId("presenter-overlay-notice"); + expect(screen.getByTestId("presenter-overlay-notice")).toHaveTextContent( + "presenterOverlay.interruptedDescription", + ); + }); + + it("keeps dismissed conflict details closed across a re-render", async () => { + recorderState.value.recording = true; + recorderState.value.presenterOverlay = { active: true, cameraInterrupted: true }; + const view = renderLaunchWindow(); + await screen.findByTestId("presenter-overlay-notice"); + fireEvent.click(screen.getByRole("button", { name: "actions.close" })); + view.rerender( + + + , + ); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + expect(screen.getByTestId("presenter-overlay-status")).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + it("reopens camera-off details only on request after the effect restarts", async () => { + recorderState.value.recording = true; + recorderState.value.presenterOverlay = { active: true, cameraInterrupted: false }; + const view = renderLaunchWindow(); + fireEvent.click(screen.getByTestId("presenter-overlay-status")); + await screen.findByTestId("presenter-overlay-notice"); + recorderState.value.presenterOverlay = null; + view.rerender( + + + , + ); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + recorderState.value.presenterOverlay = { active: true, cameraInterrupted: false }; + view.rerender( + + + , + ); + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); + expect(screen.getByTestId("presenter-overlay-status")).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + it("keeps the camera warning visible after the system overlay stops", async () => { recorderState.value.recording = true; recorderState.value.presenterOverlay = { active: false, cameraInterrupted: true }; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 06c37eebac..9d88e28c27 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -170,6 +170,9 @@ export function LaunchWindow() { useEffect(() => { setOverlayDetailsOpen(Boolean(presenterOverlay?.cameraInterrupted)); }, [presenterOverlay?.cameraInterrupted]); + const toggleOverlayDetails = useCallback(() => { + setOverlayDetailsOpen((open) => !open); + }, []); /** * Narrower than [`isLinuxHud`] on purpose: without the helper the recorder @@ -1200,6 +1203,11 @@ export function LaunchWindow() { overlayNotice === "before" || (overlayNotice !== null && overlayDetailsOpen); const hasNotices = Boolean(systemLocaleSuggestion) || softwareEncoderFallbackNoticeVisible || showOverlayDetails; + // The camera-off details open on request; an effect that stops and starts again must not + // reopen them. Only a detected camera conflict auto-expands. + useEffect(() => { + if (overlayNotice === null) setOverlayDetailsOpen(false); + }, [overlayNotice]); return ( // Avoid w-screen/h-screen: 100vw can exceed the inner layout width when scrollbars @@ -1337,7 +1345,7 @@ export function LaunchWindow() { : tCommon("presenterOverlay.screenOnlyTitle") } expanded={showOverlayDetails} - onClick={() => setOverlayDetailsOpen((open) => !open)} + onClick={toggleOverlayDetails} /> )} diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 1ffad0a8c8..80e7ba2b16 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -100,7 +100,7 @@ The HUD handles the combinations separately: | Off | Off | None. | | Off | Active | Informational status button: the system overlay is part of the screen video and cannot be edited separately. | | On | Off | One-time pre-recording guidance; no compatibility banner during an ordinary take. | -| On | Active during this take | Warning opens once; it can be collapsed to a persistent warning button. | +| On | Active during this take | Warning opens once on a detected transition; it can be collapsed to a persistent warning button. | The editor recording page and HUD share a local preference remembering explicit acknowledgement of the guidance. Acknowledgement never suppresses actual conflict @@ -116,7 +116,11 @@ in this integration. The actual effect is observed through the primary screen stream's `SCStreamDelegate.outputVideoEffectDidStart(for:)` and `outputVideoEffectDidStop(for:)` callbacks. The auxiliary system-audio stream is ignored. Effects applied to another application's stream are not reported by these callbacks; this is not a global camera-availability -monitor. +monitor. The callbacks report transitions on the primary stream only. If the effect is +already on when a stream starts — Restart while it is on is the common case — +ScreenCaptureKit may not deliver a start callback for the new stream, so no warning is +guaranteed until the effect changes. A take that begins with the effect on is not +classified at startup. The helper emits additive warning events. The main process associates them with the current take and whether an independent camera recorder actually started; the renderer diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 79bd6708a1..05a00bce0d 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -1003,4 +1003,4 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-22 | Dev `d8bf2d81` + issue #1260 patch (`fix/agent-stop-1260`), Electron 41.2.1; Vite `127.0.0.1:5186`, private `openscreen1260-ui-*` userData, local controlled OpenAI-compatible provider | macOS 15.8, Apple Silicon | Superseded — pre-`0cb26e07` Agent Stop check | **Superseded by `0cb26e07`:** the two-Undo observation below records the old per-tool behavior. The current implementation applies the accumulated document once on completion or Stop, with one save and one undo step per turn. Automated regressions cover normal and stopped turns with 60 tool edits; this desktop check has not been rerun on the new head. **Historical run:** Real Electron operated through computer-use: sent a first turn, observed Stop while the provider held its SSE response open, applied two `setVocabulary` edits, clicked Stop, saw the stopped status and Send return, confirmed the provider connection closed and the partial answer plus `stopped: true` reached the chat sidecar. Document bytes stayed at `Second edit`; one Undo restored `First edit`, a second restored the initial document. A new turn completed with `Next turn works.` Screenshots captured for running and continued states. Skipped: paid/remote providers, Windows/Linux desktop UI, capture, media preview and export (this patch changes Agent chat only). | | 2026-10-23 | `fix/mac-mic-silent-with-system-audio` on `main` @ `264975d`, dev build (`npm run dev`), capture helper built locally with Xcode 27 and copied from `out/Products/Release` by hand | macOS 26.6.2 (25G83), MacBook Pro M2 Pro; built-in microphone and a USB Audio CODEC (08bb:2902) | **Partial: fix slice only, pass** | #1173, Apple's picker, system audio on, output device idle (after `sudo killall coreaudiod`, or checked idle). Before the fix: the microphone delivered nothing until another app played a sound, 56 s (built-in) and 14 s (USB) into the take; without system audio it started at 0.17 s. With the tap merely started after the stream, four takes (built-in twice, USB twice) recorded the microphone from 0.17–0.18 s, but only 12–30 ms ahead of the tap. With this branch's version, the tap waiting for the first microphone buffer: one USB take recorded the microphone from 0.18 s and the tap started 27 ms after it; one take with the microphone off. In both, a sound played mid-take was in system audio. Measured with temporary per-source buffer counters in the helper, not shipped. **Not covered:** this branch's version with the built-in microphone, a legacy-picker take, macOS 15, an Intel Mac, a microphone that never delivers (the 1 s fallback), pause before the first microphone buffer, a CI-built artifact. | -| 2026-10-25 | `fix/presenter-overlay-recording-warning`, dev build based on `6e09c9d1`; `npm run build-vite`, fresh SwiftPM release helper SHA-256 `5c1440f448fc66a052dd34863852db273d8eb46c03a6dfb536bb15d0acb371d0` (hash-matched to staged binaries) | macOS 15.7.1, Apple M2, Electron 41.2.1; isolated profiles, synthetic window; physical camera lens covered | **Partial — Presenter Overlay warning slice passed** | Computer-use drove HUD/editor controls; a local operator toggled Apple's otherwise inaccessible system menu. Camera off: native synthetic-window recording starts with no conflict warning; the operator found no system effect option, so camera-off overlay availability is not promised. Camera on: real Small on/off emitted both helper warning events; the HUD warned, kept recording, retained the warning after Off, and passed the translated warning into the editor. Refined UI: normal capture has no banner; one-time guidance can be acknowledged in either the HUD or editor recording page and stays acknowledged across mounts. A real conflict opens details; Close collapses them to an amber button, which reopens details; horizontal and vertical controls remain usable. Helper output was checked locally through Save Diagnostics, not injected events. **Not covered:** Large mode, initial-effect state before recording, a full-display take, Apple's source-picker interactions, other macOS versions/devices, Windows/Linux runtime, packaged builds, or the rest of this checklist. Startup/stale/duplicate events, camera-off effect classification, discard/restart reset and notice dismissal have unit coverage. No media is attached. | +| 2026-10-25 | `fix/presenter-overlay-recording-warning`, dev build from the tested working tree (committed as `db636a11` on top of `b96b9136`); `npm run build-vite`, fresh SwiftPM release helper SHA-256 `5c1440f448fc66a052dd34863852db273d8eb46c03a6dfb536bb15d0acb371d0` (hash-matched to staged binaries) | macOS 15.7.1, Apple M2, Electron 41.2.1; isolated profiles, synthetic window; physical camera lens covered | **Partial — Presenter Overlay warning slice passed** | Computer-use drove HUD/editor controls; a local operator toggled Apple's otherwise inaccessible system menu. Camera off: native synthetic-window recording starts with no conflict warning; the operator found no system effect option, so camera-off overlay availability is not promised. Camera on: real Small on/off emitted both helper warning events; the HUD warned, kept recording, retained the warning after Off, and passed the translated warning into the editor. Refined UI: normal capture has no banner; one-time guidance can be acknowledged in either the HUD or editor recording page and stays acknowledged across mounts. A real conflict opens details; Close collapses them to an amber button, which reopens details; horizontal and vertical controls remain usable. Helper output was checked locally through Save Diagnostics, not injected events. **Not covered:** Large mode, an effect already active when the take starts, a full-display take, Apple's source-picker interactions, other macOS versions/devices, Windows/Linux runtime, packaged builds, or the rest of this checklist. Startup/duplicate events, stop-time transitions, camera-off effect classification, discard/restart reset and notice dismissal have unit coverage; an effect already on before the take starts has none. No media is attached. | From 36473fccab303ea236cc5d03e392fc6c369cac99 Mon Sep 17 00:00:00 2001 From: songsong Date: Sun, 25 Oct 2026 12:19:40 +0800 Subject: [PATCH 4/6] fix(editor): inset the Presenter Overlay guidance card The card sat flush against the rounded recording panel, whose overflow clipped its top corners. Give it the same inset as the panel rows; the copy, role and acknowledgement behaviour are unchanged. --- src/components/ai-edition/v4/RecStage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 7694e8ebc1..1a8f199c99 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -424,7 +424,10 @@ export function RecStage({
{prefs.camEnabled && supportsPresenterOverlay && !overlayGuidanceAcknowledged && ( -
+

{tCommon("presenterOverlay.beforeTitle")}

{tCommon("presenterOverlay.beforeDescription")} From 0107c1398b6b05b62d457d3047b79f1e3eae73bb Mon Sep 17 00:00:00 2001 From: songsong Date: Sun, 25 Oct 2026 12:31:32 +0800 Subject: [PATCH 5/6] fix(recording): sync Presenter Overlay guidance across windows Move the one-time guidance acknowledgement into a shared hook used by the HUD and the editor record page. Both listen for cross-window storage changes, so an already mounted surface hides the guidance when the other window acknowledges it; the acknowledging window still updates locally because storage events do not fire there. Cover the hook and both surfaces with storage-event tests. --- .../ai-edition/v4/RecStage.test.tsx | 21 +++++++ src/components/ai-edition/v4/RecStage.tsx | 12 ++-- src/components/launch/LaunchWindow.test.tsx | 16 +++++ src/components/launch/LaunchWindow.tsx | 9 ++- .../usePresenterOverlayGuidance.test.tsx | 61 +++++++++++++++++++ src/hooks/usePresenterOverlayGuidance.ts | 32 ++++++++++ 6 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 src/hooks/usePresenterOverlayGuidance.test.tsx create mode 100644 src/hooks/usePresenterOverlayGuidance.ts diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index bc1f2b1739..f16f878b0a 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -867,6 +867,27 @@ describe("Presenter Overlay preflight guidance", () => { expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument(); }); + it("hides the guidance when another window acknowledges it", async () => { + stubRecordingPrefs({ camEnabled: true }); + window.electronAPI.getAppInfo = vi.fn(async () => ({ + version: "2.1.2", + canCheckForUpdates: false, + supportsPresenterOverlay: true, + })); + renderRecStage(); + await screen.findByText("presenterOverlay.beforeDescription"); + act(() => { + localStorage.setItem( + "openscreen_user_preferences", + JSON.stringify({ presenterOverlayGuidanceAcknowledged: true }), + ); + window.dispatchEvent(new StorageEvent("storage", { key: "openscreen_user_preferences" })); + }); + await waitFor(() => + expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument(), + ); + }); + it.each([ false, true, diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 1a8f199c99..54f8251f52 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -24,9 +24,9 @@ import { useCameraPreviewStream } from "@/hooks/useCameraPreviewStream"; import { useEditableCursorAvailable } from "@/hooks/useEditableCursorAvailable"; import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; import { usePortalOwnsSource } from "@/hooks/usePortalOwnsSource"; +import { usePresenterOverlayGuidance } from "@/hooks/usePresenterOverlayGuidance"; import { canTurnCameraOn } from "@/lib/cameraAvailability"; import { canTurnMicrophoneOn } from "@/lib/microphoneAvailability"; -import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; import styles from "./EditorShellV4.module.css"; @@ -91,9 +91,8 @@ export function RecStage({ const t = useScopedT("editor"); const tCommon = useScopedT("common"); const [supportsPresenterOverlay, setSupportsPresenterOverlay] = useState(false); - const [overlayGuidanceAcknowledged, setOverlayGuidanceAcknowledged] = useState( - () => loadUserPreferences().presenterOverlayGuidanceAcknowledged, - ); + const { acknowledged: overlayGuidanceAcknowledged, acknowledge: acknowledgeOverlayGuidance } = + usePresenterOverlayGuidance(); useEffect(() => { let cancelled = false; void window.electronAPI @@ -435,10 +434,7 @@ export function RecStage({ diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 85e3dc98b8..d1efe7adcd 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -2447,6 +2447,22 @@ describe("LaunchWindow toasts", () => { expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(); }); + it("hides the guidance when another window acknowledges it", async () => { + recorderState.value.webcamEnabled = true; + renderLaunchWindow(); + await screen.findByTestId("presenter-overlay-notice"); + act(() => { + localStorage.setItem( + "openscreen_user_preferences", + JSON.stringify({ presenterOverlayGuidanceAcknowledged: true }), + ); + window.dispatchEvent(new StorageEvent("storage", { key: "openscreen_user_preferences" })); + }); + await waitFor(() => + expect(screen.queryByTestId("presenter-overlay-notice")).not.toBeInTheDocument(), + ); + }); + it("lets the user collapse a conflict without losing its status or stopping capture", async () => { recorderState.value.recording = true; recorderState.value.presenterOverlay = { active: true, cameraInterrupted: true }; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 9d88e28c27..38f73a752e 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -14,6 +14,7 @@ import { useMicrophoneDevices, } from "../../hooks/useMicrophoneDevices"; import { usePortalOwnsSource } from "../../hooks/usePortalOwnsSource"; +import { usePresenterOverlayGuidance } from "../../hooks/usePresenterOverlayGuidance"; import { useRememberedSourceName } from "../../hooks/useRememberedSourceName"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import type { WebcamQualityId } from "../../hooks/webcamCaptureTarget"; @@ -163,9 +164,8 @@ export function LaunchWindow() { supportsPresenterOverlay?: boolean; } | null>(null); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); - const [overlayGuidanceAcknowledged, setOverlayGuidanceAcknowledged] = useState( - () => loadUserPreferences().presenterOverlayGuidanceAcknowledged, - ); + const { acknowledged: overlayGuidanceAcknowledged, acknowledge: acknowledgeOverlayGuidance } = + usePresenterOverlayGuidance(); const [overlayDetailsOpen, setOverlayDetailsOpen] = useState(false); useEffect(() => { setOverlayDetailsOpen(Boolean(presenterOverlay?.cameraInterrupted)); @@ -1495,8 +1495,7 @@ export function LaunchWindow() { } onDismiss={() => { if (overlayNotice === "before") { - saveUserPreferences({ presenterOverlayGuidanceAcknowledged: true }); - setOverlayGuidanceAcknowledged(true); + acknowledgeOverlayGuidance(); } setOverlayDetailsOpen(false); }} diff --git a/src/hooks/usePresenterOverlayGuidance.test.tsx b/src/hooks/usePresenterOverlayGuidance.test.tsx new file mode 100644 index 0000000000..2256a6be5a --- /dev/null +++ b/src/hooks/usePresenterOverlayGuidance.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { usePresenterOverlayGuidance } from "./usePresenterOverlayGuidance"; + +const PREFS_KEY = "openscreen_user_preferences"; + +function storePreference(acknowledged: boolean) { + localStorage.setItem( + PREFS_KEY, + JSON.stringify({ presenterOverlayGuidanceAcknowledged: acknowledged }), + ); +} + +function emitStorageEvent() { + window.dispatchEvent(new StorageEvent("storage", { key: PREFS_KEY })); +} + +describe("usePresenterOverlayGuidance", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it("starts from the stored acknowledgement", () => { + storePreference(true); + const view = renderHook(() => usePresenterOverlayGuidance()); + expect(view.result.current.acknowledged).toBe(true); + }); + + it("persists an acknowledgement and updates this window", () => { + const view = renderHook(() => usePresenterOverlayGuidance()); + expect(view.result.current.acknowledged).toBe(false); + act(() => view.result.current.acknowledge()); + expect(view.result.current.acknowledged).toBe(true); + expect(JSON.parse(localStorage.getItem(PREFS_KEY) ?? "{}")).toMatchObject({ + presenterOverlayGuidanceAcknowledged: true, + }); + }); + + it("follows an acknowledgement written by another window", () => { + const view = renderHook(() => usePresenterOverlayGuidance()); + expect(view.result.current.acknowledged).toBe(false); + act(() => { + storePreference(true); + emitStorageEvent(); + }); + expect(view.result.current.acknowledged).toBe(true); + }); + + it("stops listening once unmounted", () => { + const view = renderHook(() => usePresenterOverlayGuidance()); + view.unmount(); + act(() => { + storePreference(true); + emitStorageEvent(); + }); + expect(view.result.current.acknowledged).toBe(false); + }); +}); diff --git a/src/hooks/usePresenterOverlayGuidance.ts b/src/hooks/usePresenterOverlayGuidance.ts new file mode 100644 index 0000000000..a3c73b1f71 --- /dev/null +++ b/src/hooks/usePresenterOverlayGuidance.ts @@ -0,0 +1,32 @@ +import { useCallback, useEffect, useState } from "react"; +import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; + +/** + * The one-time Presenter Overlay guidance acknowledgement. + * + * The HUD and the editor record page share the preference through localStorage. + * A storage event is the cross-window signal: if one surface writes the + * acknowledgement, an already mounted surface updates instead of keeping stale + * guidance on screen. The write itself also updates this window's state, since + * a storage event is never delivered to the window that made the change. + */ +export function usePresenterOverlayGuidance() { + const [acknowledged, setAcknowledged] = useState( + () => loadUserPreferences().presenterOverlayGuidanceAcknowledged, + ); + + useEffect(() => { + const sync = () => { + setAcknowledged(loadUserPreferences().presenterOverlayGuidanceAcknowledged); + }; + window.addEventListener("storage", sync); + return () => window.removeEventListener("storage", sync); + }, []); + + const acknowledge = useCallback(() => { + saveUserPreferences({ presenterOverlayGuidanceAcknowledged: true }); + setAcknowledged(true); + }, []); + + return { acknowledged, acknowledge }; +} From fe7f7ecc81476460824e55672c209333185ec96c Mon Sep 17 00:00:00 2001 From: songsong Date: Sun, 25 Oct 2026 12:41:40 +0800 Subject: [PATCH 6/6] fix(recording): hide guidance only after the preference persists saveUserPreferences now reports whether localStorage accepted the write. The shared guidance hook keeps the card visible when the write fails, instead of hiding it locally and bringing it back on the next mount. Cover both the preference helper and the hook. --- src/hooks/usePresenterOverlayGuidance.test.tsx | 15 ++++++++++++++- src/hooks/usePresenterOverlayGuidance.ts | 4 +++- src/lib/userPreferences.test.ts | 14 +++++++++++++- src/lib/userPreferences.ts | 11 +++++++++-- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/hooks/usePresenterOverlayGuidance.test.tsx b/src/hooks/usePresenterOverlayGuidance.test.tsx index 2256a6be5a..8c5a146351 100644 --- a/src/hooks/usePresenterOverlayGuidance.test.tsx +++ b/src/hooks/usePresenterOverlayGuidance.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { act, cleanup, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { usePresenterOverlayGuidance } from "./usePresenterOverlayGuidance"; const PREFS_KEY = "openscreen_user_preferences"; @@ -39,6 +39,19 @@ describe("usePresenterOverlayGuidance", () => { }); }); + it("keeps the guidance when the write is rejected", () => { + const view = renderHook(() => usePresenterOverlayGuidance()); + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + try { + act(() => view.result.current.acknowledge()); + } finally { + setItem.mockRestore(); + } + expect(view.result.current.acknowledged).toBe(false); + }); + it("follows an acknowledgement written by another window", () => { const view = renderHook(() => usePresenterOverlayGuidance()); expect(view.result.current.acknowledged).toBe(false); diff --git a/src/hooks/usePresenterOverlayGuidance.ts b/src/hooks/usePresenterOverlayGuidance.ts index a3c73b1f71..61613c01cb 100644 --- a/src/hooks/usePresenterOverlayGuidance.ts +++ b/src/hooks/usePresenterOverlayGuidance.ts @@ -24,7 +24,9 @@ export function usePresenterOverlayGuidance() { }, []); const acknowledge = useCallback(() => { - saveUserPreferences({ presenterOverlayGuidanceAcknowledged: true }); + // Keep the guidance on screen if the write was rejected: hiding it locally + // would only bring it back on the next mount. + if (!saveUserPreferences({ presenterOverlayGuidanceAcknowledged: true })) return; setAcknowledged(true); }, []); diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts index fcaf61faeb..3130a12a01 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_PREFS, getProjectFolder, @@ -103,6 +103,18 @@ describe("user preferences", () => { localStorage.clear(); }); + it("reports a rejected write instead of pretending it persisted", () => { + const setItem = vi.spyOn(localStorage, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + try { + expect(saveUserPreferences({ presenterOverlayGuidanceAcknowledged: true })).toBe(false); + } finally { + setItem.mockRestore(); + } + expect(loadUserPreferences().presenterOverlayGuidanceAcknowledged).toBe(false); + }); + it("persists the tray layout preference", () => { saveUserPreferences({ trayLayout: "vertical" }); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index b9f293fe95..3ea31f5692 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -137,13 +137,20 @@ export function getProjectFolder(): string | undefined { return loadUserPreferences().projectFolder ?? undefined; } -/** Persist preferences to localStorage; only the provided fields are updated. */ -export function saveUserPreferences(partial: Partial): void { +/** + * Persist preferences to localStorage; only the provided fields are updated. + * + * Returns false when the write was rejected (private browsing, quota exceeded) so + * callers can keep in-memory state in step with what actually persisted. + */ +export function saveUserPreferences(partial: Partial): boolean { const current = loadUserPreferences(); const merged = { ...current, ...partial }; try { localStorage.setItem(PREFS_KEY, JSON.stringify(merged)); + return true; } catch { // localStorage may be unavailable (e.g. private browsing, quota exceeded) + return false; } }