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..5d6eeef604 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,10 @@ function attachNativeMacCaptureOutputDrain( () => nativeMacCaptureProcess === proc && !nativeMacStopInFlight, 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(); @@ -3254,6 +3261,7 @@ export function registerIpcHandlers( nativeMacIsPaused = false; nativeMacStopInFlight = false; nativeMacRecordingWarning = null; + nativeMacPresenterOverlay = { active: false, cameraInterrupted: false }; activeMacCaptureBounds = null; const cursorStartTimeMs = Date.now(); @@ -3288,6 +3296,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 +3351,7 @@ export function registerIpcHandlers( path: outputPath, helperPath, startedAtMs: captureStartedAtMs, + presenterOverlay: { ...nativeMacPresenterOverlay }, microphoneDefaulted, microphoneUnavailable, microphoneNotFound, @@ -3737,6 +3760,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..0e276df1d6 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts @@ -109,4 +109,42 @@ 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(); + }); + + 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 48f8628886..1c9d09466b 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.ts @@ -16,8 +16,14 @@ export function createNativeMacMidCaptureErrorWatch( isLiveTake: () => boolean, 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; let reportedSystemAudioUnavailable = false; const watch = (event: Record) => { if (event.event === "recording-started") { @@ -32,6 +38,18 @@ export function createNativeMacMidCaptureErrorWatch( reportedSystemAudioUnavailable = true; onSystemAudioUnavailable(); } + if (event.event === "warning" && isCurrentTake()) { + 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/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 49230e2784..f16f878b0a 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -848,3 +848,73 @@ 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("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, + ])("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..54f8251f52 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -24,6 +24,7 @@ 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 { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; @@ -88,6 +89,23 @@ export function RecStage({ onClose?: () => void; }) { const t = useScopedT("editor"); + const tCommon = useScopedT("common"); + const [supportsPresenterOverlay, setSupportsPresenterOverlay] = useState(false); + const { acknowledged: overlayGuidanceAcknowledged, acknowledge: acknowledgeOverlayGuidance } = + usePresenterOverlayGuidance(); + 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 +422,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 +770,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..81046adecc 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 8cd92de957..d1efe7adcd 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,170 @@ 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(); + 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", + ), + ); + 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("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 }; + 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("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 }; + 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..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"; @@ -30,6 +31,7 @@ import { HudMicButton, HudNotesButton, HudNotice, + HudPresenterOverlayButton, HudRecordButton, HudRecordingControls, HudSettingsButton, @@ -131,6 +133,7 @@ export function LaunchWindow() { setWebcamDeviceName, cursorCaptureMode, setCursorCaptureMode, + presenterOverlay, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, recordingPrefsLoaded, @@ -155,10 +158,22 @@ 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); + const { acknowledged: overlayGuidanceAcknowledged, acknowledge: acknowledgeOverlayGuidance } = + usePresenterOverlayGuidance(); + const [overlayDetailsOpen, setOverlayDetailsOpen] = useState(false); + useEffect(() => { + setOverlayDetailsOpen(Boolean(presenterOverlay?.cameraInterrupted)); + }, [presenterOverlay?.cameraInterrupted]); + const toggleOverlayDetails = useCallback(() => { + setOverlayDetailsOpen((open) => !open); + }, []); + /** * Narrower than [`isLinuxHud`] on purpose: without the helper the recorder * falls back to Chromium's capture, which DOES take a source id, so the @@ -1172,7 +1187,27 @@ 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" + : !recording && + !saving && + !overlayGuidanceAcknowledged && + webcamEnabled && + getPlatform() === "darwin" && + appInfo?.supportsPresenterOverlay + ? "before" + : null; + const showOverlayDetails = + 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 @@ -1301,6 +1336,19 @@ export function LaunchWindow() { /> )} + {overlayNotice && overlayNotice !== "before" && ( + + )} + + {showOverlayDetails && ( +
+ { + if (overlayNotice === "before") { + acknowledgeOverlayGuidance(); + } + setOverlayDetailsOpen(false); + }} + /> +
+ )} {systemLocaleSuggestion && ( { + 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("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); + 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..61613c01cb --- /dev/null +++ b/src/hooks/usePresenterOverlayGuidance.ts @@ -0,0 +1,34 @@ +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(() => { + // 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); + }, []); + + return { acknowledged, acknowledge }; +} diff --git a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx index 817a008f93..569d86a942 100644 --- a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx +++ b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx @@ -18,11 +18,17 @@ type ElectronAPI = Window["electronAPI"]; const SOURCE = { id: "screen:0:0", name: "Screen 1", display_id: "1", thumbnail: "" }; let api: Record>; +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,136 @@ describe("useScreenRecorder native macOS start warnings", () => { expect(toast.error).not.toHaveBeenCalled(); }); }); + +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(); + 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..b09132792f 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); @@ -810,6 +825,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const clearNativeRecordingState = () => { nativeMacRecording.current = null; + setPresenterOverlay(null); setRecording(false); setPaused(false); setElapsedSeconds(0); @@ -1341,6 +1357,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 +1439,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 +1462,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 +1503,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 +2516,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..7951a98edd 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -75,5 +75,14 @@ "screen": "شاشة", "virtual": "شاشة افتراضية", "unknown": "تسجيل الشاشة" + }, + "presenterOverlay": { + "beforeTitle": "أبقِ تراكب مقدم العرض في macOS معطّلاً", + "beforeDescription": "يسجّل OpenScreen الكاميرا بشكل منفصل لتحريرها لاحقًا. قد يؤدي تراكب مقدم العرض في macOS إلى مقاطعة هذا التسجيل. استخدم تراكب الكاميرا في OpenScreen بدلاً منه.", + "interruptedTitle": "قد يكون تسجيل الكاميرا غير مكتمل", + "interruptedDescription": "تم تفعيل تراكب مقدم العرض في macOS أثناء هذا التسجيل، وقد يكون قد قاطع مسار الكاميرا المنفصل. عطّله وابدأ تسجيلًا جديدًا إذا كنت بحاجة إلى تحرير الكاميرا بشكل مستقل.", + "screenOnlyTitle": "التراكب جزء من فيديو الشاشة", + "screenOnlyDescription": "لا يجري تسجيل الكاميرا في مسار منفصل. يُضمَّن تراكب النظام في فيديو الشاشة ولا يمكن تحريره بشكل مستقل.", + "acknowledge": "فهمت، لا تعرض مرة أخرى" } } diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index f1bb844221..2368b28370 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -75,5 +75,14 @@ "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ě.", + "acknowledge": "Rozumím, znovu nezobrazovat" } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 458bec8be1..df2a9356a2 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -75,5 +75,14 @@ "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.", + "acknowledge": "Verstanden, nicht mehr anzeigen" } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f04adbec75..0529090478 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -75,5 +75,14 @@ "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.", + "acknowledge": "Got it, don't show again" } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index cb5f145e77..0fd0c1d944 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -75,5 +75,14 @@ "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.", + "acknowledge": "Entendido, no volver a mostrar" } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 1f04b15f0c..84d1da2506 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -75,5 +75,14 @@ "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.", + "acknowledge": "Compris, ne plus afficher" } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 6554344e2c..7cc4c9a1a0 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -75,5 +75,14 @@ "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.", + "acknowledge": "Capito, non mostrare più" } } diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index b36f2c5a43..cb7015524a 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -75,5 +75,14 @@ "screen": "画面", "virtual": "仮想ディスプレイ", "unknown": "画面録画" + }, + "presenterOverlay": { + "beforeTitle": "macOS のプレゼンターオーバーレイをオフにしてください", + "beforeDescription": "OpenScreen は編集用にカメラ映像を別に録画します。macOS のプレゼンターオーバーレイはその録画を中断する場合があります。OpenScreen のカメラオーバーレイをご利用ください。", + "interruptedTitle": "カメラ録画が不完全な可能性があります", + "interruptedDescription": "今回の録画中に macOS のプレゼンターオーバーレイが有効になり、カメラトラックが中断された可能性があります。カメラ映像を個別に編集する必要がある場合は、システムのオーバーレイをオフにして録画し直してください。", + "screenOnlyTitle": "プレゼンターオーバーレイは画面動画に含まれます", + "screenOnlyDescription": "カメラは別のトラックとして録画されていません。システムのオーバーレイは画面動画に含まれ、個別に編集できません。", + "acknowledge": "確認しました。今後は表示しない" } } diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index 2291c442a7..aad8a5c46b 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -75,5 +75,14 @@ "screen": "화면", "virtual": "가상 디스플레이", "unknown": "화면 녹화" + }, + "presenterOverlay": { + "beforeTitle": "macOS 발표자 오버레이를 꺼주세요", + "beforeDescription": "OpenScreen은 편집을 위해 카메라를 별도로 녹화합니다. macOS 발표자 오버레이가 이 녹화를 중단할 수 있습니다. OpenScreen의 카메라 오버레이를 사용하세요.", + "interruptedTitle": "카메라 녹화가 불완전할 수 있습니다", + "interruptedDescription": "이번 녹화 중 macOS 발표자 오버레이가 켜져 별도의 카메라 트랙이 중단되었을 수 있습니다. 카메라 영상을 개별 편집하려면 시스템 오버레이를 끄고 새로 녹화하세요.", + "screenOnlyTitle": "발표자 오버레이는 화면 영상에 포함됩니다", + "screenOnlyDescription": "카메라가 별도 트랙으로 녹화되고 있지 않습니다. 시스템 오버레이는 화면 영상에 포함되며 개별 편집할 수 없습니다.", + "acknowledge": "확인했으며 다시 표시하지 않음" } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 747e9ffa55..81e20213e8 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -75,5 +75,14 @@ "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.", + "acknowledge": "Entendi, não mostrar novamente" } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 523743e308..0c27e055ea 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -75,5 +75,14 @@ "screen": "Экран", "virtual": "Виртуальный экран", "unknown": "Запись экрана" + }, + "presenterOverlay": { + "beforeTitle": "Отключите наложение докладчика в macOS", + "beforeDescription": "OpenScreen записывает камеру отдельно для редактирования. Наложение докладчика в macOS может прервать эту запись. Используйте наложение камеры OpenScreen.", + "interruptedTitle": "Запись камеры может быть неполной", + "interruptedDescription": "Во время этой записи было включено наложение докладчика macOS, которое могло прервать отдельную дорожку камеры. Отключите его и начните новую запись, если вам нужно редактировать камеру отдельно.", + "screenOnlyTitle": "Наложение включено в видео экрана", + "screenOnlyDescription": "Камера не записывается на отдельную дорожку. Системное наложение включено в видео экрана и не может редактироваться отдельно.", + "acknowledge": "Понятно, больше не показывать" } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 86fd7321d5..6ba12a8e4c 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -75,5 +75,14 @@ "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.", + "acknowledge": "Anladım, tekrar gösterme" } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 419188852a..e8791830f5 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -75,5 +75,14 @@ "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.", + "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 607199bac2..1b928a151c 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -75,5 +75,14 @@ "screen": "屏幕", "virtual": "虚拟显示器", "unknown": "屏幕录制" + }, + "presenterOverlay": { + "beforeTitle": "请关闭 macOS 演讲者叠层", + "beforeDescription": "OpenScreen 会单独录制摄像头,方便后续编辑。macOS 演讲者叠层可能中断这条录制,请使用 OpenScreen 自带的摄像头画中画。", + "interruptedTitle": "摄像头录制可能不完整", + "interruptedDescription": "本次录制期间启用了 macOS 演讲者叠层,独立摄像头轨道可能已中断。如需可单独编辑的摄像头画面,请关闭系统叠层并重新录制。", + "screenOnlyTitle": "演讲者叠层已合入屏幕视频", + "screenOnlyDescription": "当前没有单独录制摄像头轨道。系统叠层会直接录入屏幕视频,其中的人像无法单独编辑。", + "acknowledge": "知道了,不再提示" } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index ca72bbeeef..ea5c1caed5 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -75,5 +75,14 @@ "screen": "螢幕", "virtual": "虛擬顯示器", "unknown": "螢幕錄製" + }, + "presenterOverlay": { + "beforeTitle": "請關閉 macOS 簡報者疊層", + "beforeDescription": "OpenScreen 會單獨錄製攝影機,方便後續編輯。macOS 簡報者疊層可能中斷這項錄製,請使用 OpenScreen 內建的攝影機子母畫面。", + "interruptedTitle": "攝影機錄影可能不完整", + "interruptedDescription": "本次錄製期間啟用了 macOS 簡報者疊層,獨立攝影機軌道可能已中斷。如需可單獨編輯的攝影機畫面,請關閉系統疊層並重新錄製。", + "screenOnlyTitle": "簡報者疊層已合入螢幕影片", + "screenOnlyDescription": "目前沒有單獨錄製攝影機軌道。系統疊層會直接錄入螢幕影片,其中的人像無法單獨編輯。", + "acknowledge": "知道了,不再提示" } } 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/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts index ca66c53d5f..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, @@ -88,10 +88,33 @@ 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(); }); + 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 f98fb422d3..3ea31f5692 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 @@ -133,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; } } diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 2f643ec222..80e7ba2b16 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -80,6 +80,65 @@ 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/)). + +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 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 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 +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. Effects applied to another application's +stream are not reported by these callbacks; this is not a global camera-availability +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 +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..05a00bce0d 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`, 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. |