diff --git a/electron/hudCaptureProtection.test.ts b/electron/hudCaptureProtection.test.ts new file mode 100644 index 00000000..30b0e2b9 --- /dev/null +++ b/electron/hudCaptureProtection.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { + getHudCaptureExcludedProcessIds, + supportsHudCaptureProtection, +} from "../src/lib/hudCaptureProtection"; + +describe("HUD capture protection lifecycle", () => { + it("uses window protection on Windows and macOS only", () => { + expect(supportsHudCaptureProtection("win32")).toBe(true); + expect(supportsHudCaptureProtection("darwin")).toBe(true); + expect(supportsHudCaptureProtection("linux")).toBe(false); + }); + + it("only builds a macOS process exclusion when protection is enabled", () => { + expect(getHudCaptureExcludedProcessIds("darwin", true, 734)).toEqual([734]); + expect(getHudCaptureExcludedProcessIds("darwin", false, 734)).toEqual([]); + expect(getHudCaptureExcludedProcessIds("win32", true, 734)).toEqual([]); + expect(getHudCaptureExcludedProcessIds("linux", true, 734)).toEqual([]); + }); +}); diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 3e03d970..a1b62504 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -12,8 +12,12 @@ import { shell, systemPreferences, } from "electron"; +import { getHudCaptureExcludedProcessIds } from "../../../src/lib/hudCaptureProtection"; import { showCursor } from "../../cursorHider"; -import { getMonitorHandles } from "../monitorResolver"; +import { + getHudOverlayCaptureProtectionEnabled, + reassertHudOverlayCaptureProtection, +} from "../../windows"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds"; import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction"; @@ -31,6 +35,7 @@ import { writeCursorTelemetry, } from "../cursor/telemetry"; import { getFfmpegBinaryPath } from "../ffmpeg/binary"; +import { getMonitorHandles } from "../monitorResolver"; import { ensureNativeCaptureHelperBinary, ensureSwiftHelperBinary, @@ -398,6 +403,10 @@ export function registerRecordingHandlers( ipcMain.handle( "start-native-screen-recording", async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { + // Capture starts before the renderer publishes its recording-state + // transition, so protect the HUD at the actual capture boundary. + reassertHudOverlayCaptureProtection(); + // Windows native capture path if (process.platform === "win32") { const windowsCaptureAvailable = await isNativeWindowsCaptureAvailable(); @@ -726,6 +735,15 @@ export function registerRecordingHandlers( capturesMicrophone, }; + const excludedProcessIds = getHudCaptureExcludedProcessIds( + process.platform, + getHudOverlayCaptureProtectionEnabled(), + process.pid, + ); + if (excludedProcessIds.length > 0) { + config.excludedProcessIds = excludedProcessIds; + } + if (options?.microphoneDeviceId) { config.microphoneDeviceId = options.microphoneDeviceId; } diff --git a/electron/main.ts b/electron/main.ts index 913ca05d..eb640251 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -52,6 +52,7 @@ import { getUpdateToastWindow, hideUpdateToastWindow, isHudOverlayMousePassthroughSupported, + reassertHudOverlayCaptureProtection, reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState, setHudOverlayRecordingActive, showUpdateToastWindow, @@ -1075,6 +1076,10 @@ app.whenReady().then(async () => { return; } + // Browser and Linux portal capture starts as soon as this callback + // resolves, before recording-state-changed is emitted. + reassertHudOverlayCaptureProtection(); + const sourceId = getSelectedSourceId(); // On Linux/Wayland, calling desktopCapturer.getSources() itself // invokes the xdg-desktop-portal picker. If we then return one of diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 29253a75..ac277aec 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -14,6 +14,7 @@ struct CaptureConfig: Codable { let microphoneDeviceId: String? let microphoneLabel: String? let microphoneOutputPath: String? + let excludedProcessIds: [Int32]? } let targetCaptureFPS = 60 @@ -140,7 +141,15 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Display not found"]) } - filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: []) + let excludedProcessIds = Set(config.excludedProcessIds ?? []) + let excludedApplications = availableContent.applications.filter { + excludedProcessIds.contains($0.processID) + } + filter = SCContentFilter( + display: display, + excludingApplications: excludedApplications, + exceptingWindows: [] + ) let displayBounds = CGDisplayBounds(display.displayID) let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) outputWidth = max(2, Int(displayBounds.width) * scaleFactor) diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index 233cfd17..933de2c2 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index 8982873f..9217c397 100755 Binary files a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ diff --git a/electron/windows.ts b/electron/windows.ts index bb3d6d07..47e90b8f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { app, BrowserWindow, ipcMain } from "electron"; +import { supportsHudCaptureProtection } from "../src/lib/hudCaptureProtection"; import { USER_DATA_PATH } from "./appPaths"; import { getHudOverlayWindowBounds, @@ -114,10 +115,6 @@ function getEditorWindowQuery(): Record { return query; } -function isHudOverlayCaptureProtectionSupported(): boolean { - return process.platform !== "linux"; -} - export function isHudOverlayMousePassthroughSupported(): boolean { return process.platform !== "linux"; } @@ -146,6 +143,34 @@ function loadHudOverlayCaptureProtectionSetting(): boolean { return hudOverlayHiddenFromCapture; } +export function getHudOverlayCaptureProtectionEnabled(): boolean { + return loadHudOverlayCaptureProtectionSetting(); +} + +function applyHudOverlayCaptureProtectionToWindow(hud: BrowserWindow, enabled: boolean): void { + if (!supportsHudCaptureProtection(process.platform)) { + return; + } + + try { + hud.setContentProtection(enabled); + } catch (error) { + console.warn("Failed to apply HUD capture protection:", error); + } +} + +export function reassertHudOverlayCaptureProtection(): boolean { + const enabled = loadHudOverlayCaptureProtectionSetting(); + const hud = getHudOverlayWindow(); + if (!hud) { + return enabled; + } + + applyHudOverlayCaptureProtectionToWindow(hud, enabled); + + return enabled; +} + function persistHudOverlayCaptureProtectionSetting(enabled: boolean): void { try { fs.writeFileSync( @@ -413,13 +438,7 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) hudOverlayHiddenFromCapture = Boolean(enabled); persistHudOverlayCaptureProtectionSetting(hudOverlayHiddenFromCapture); - if ( - isHudOverlayCaptureProtectionSupported() && - hudOverlayWindow && - !hudOverlayWindow.isDestroyed() - ) { - hudOverlayWindow.setContentProtection(hudOverlayHiddenFromCapture); - } + reassertHudOverlayCaptureProtection(); return { success: true, @@ -479,6 +498,9 @@ export function createHudOverlayWindow(): BrowserWindow { return; } hasShownHudWindow = true; + // Showing or changing native window state can recreate platform window + // flags. Reassert capture protection on both sides of the transition. + applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture); if (process.platform === "win32") { // A focusable window is required for a Windows taskbar entry, but the // always-on-top HUD must not steal focus when Recordly starts. @@ -487,6 +509,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.show(); } win.moveTop(); + applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture); if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { win.setIgnoreMouseEvents(false); setTimeout(() => { @@ -497,9 +520,12 @@ export function createHudOverlayWindow(): BrowserWindow { } }; - if (isHudOverlayCaptureProtectionSupported()) { - win.setContentProtection(hudOverlayHiddenFromCapture); - } + applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture); + win.on("show", () => { + if (!win.isDestroyed()) { + applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture); + } + }); if (isHudOverlayMousePassthroughSupported()) { if (hudOverlayRecordingActive) { @@ -654,6 +680,7 @@ export function setHudOverlayRecordingActive(recording: boolean): void { hudOverlayRecordingActive = Boolean(recording); hudOverlayFallbackExpanded = false; applyHudOverlayBounds(); + reassertHudOverlayCaptureProtection(); // Start in passthrough mode. Forwarded pointer movement lets the renderer // make the visible HUD controls interactive when the pointer reaches them, // while transparent parts never block the recorded application. diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index cb43ead1..f7583b28 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -19,6 +19,7 @@ import { useScopedT } from "../../contexts/I18nContext"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { useVideoDevices } from "../../hooks/useVideoDevices"; +import { supportsHudCaptureProtection } from "../../lib/hudCaptureProtection"; import { Button } from "../ui/button"; import { HudInteractionContext } from "./contexts/HudInteractionContext"; import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; @@ -115,7 +116,7 @@ function LaunchWindowContent() { toggleHudCaptureProtection, } = useLaunchWindowSystemState(preparePermissions); - const supportsHudCaptureProtection = platform !== "linux"; + const hudCaptureProtectionSupported = supportsHudCaptureProtection(platform ?? ""); useEffect(() => { if (!selectedDeviceId) { @@ -372,7 +373,7 @@ function LaunchWindowContent() { { void toggleHudCaptureProtection(); diff --git a/src/lib/hudCaptureProtection.test.ts b/src/lib/hudCaptureProtection.test.ts new file mode 100644 index 00000000..912dd548 --- /dev/null +++ b/src/lib/hudCaptureProtection.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { + getHudCaptureExcludedProcessIds, + supportsHudCaptureProtection, +} from "./hudCaptureProtection"; + +describe("supportsHudCaptureProtection", () => { + it.each([ + ["win32", true], + ["darwin", true], + ["linux", false], + ["freebsd", false], + ])("reports support for %s as %s", (platform, expected) => { + expect(supportsHudCaptureProtection(platform)).toBe(expected); + }); +}); + +describe("getHudCaptureExcludedProcessIds", () => { + it("passes the current process to macOS capture when protection is enabled", () => { + expect(getHudCaptureExcludedProcessIds("darwin", true, 4512)).toEqual([4512]); + }); + + it.each([ + ["darwin", false, 4512], + ["win32", true, 4512], + ["linux", true, 4512], + ["darwin", true, 0], + ["darwin", true, Number.NaN], + ])("returns no native exclusions for %s, enabled=%s, pid=%s", (platform, enabled, pid) => { + expect(getHudCaptureExcludedProcessIds(platform, enabled, pid as number)).toEqual([]); + }); +}); diff --git a/src/lib/hudCaptureProtection.ts b/src/lib/hudCaptureProtection.ts new file mode 100644 index 00000000..980bcbc6 --- /dev/null +++ b/src/lib/hudCaptureProtection.ts @@ -0,0 +1,15 @@ +export function supportsHudCaptureProtection(platform: string): boolean { + return platform === "darwin" || platform === "win32"; +} + +export function getHudCaptureExcludedProcessIds( + platform: string, + enabled: boolean, + processId: number, +): number[] { + if (platform !== "darwin" || !enabled || !Number.isSafeInteger(processId) || processId <= 0) { + return []; + } + + return [processId]; +}