diff --git a/electron/hudCaptureProtection.test.ts b/electron/hudCaptureProtection.test.ts new file mode 100644 index 00000000..27bdd2cd --- /dev/null +++ b/electron/hudCaptureProtection.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const windowsSource = readFileSync(fileURLToPath(new URL("./windows.ts", import.meta.url)), "utf8"); +const mainSource = readFileSync(fileURLToPath(new URL("./main.ts", import.meta.url)), "utf8"); +const recordingSource = readFileSync( + fileURLToPath(new URL("./ipc/register/recording.ts", import.meta.url)), + "utf8", +); + +describe("HUD capture protection lifecycle", () => { + it("applies protection without disabling Linux", () => { + expect(windowsSource).not.toContain( + "function isHudOverlayCaptureProtectionSupported(): boolean", + ); + expect(windowsSource).toContain("hud.setContentProtection(enabled)"); + expect(windowsSource).toContain('win.on("show"'); + }); + + it("reasserts protection at native and browser capture boundaries", () => { + expect(recordingSource).toMatch( + /"start-native-screen-recording"[\s\S]*?reassertHudOverlayCaptureProtection\(\)/, + ); + expect(mainSource).toMatch( + /setDisplayMediaRequestHandler[\s\S]*?reassertHudOverlayCaptureProtection\(\)/, + ); + }); +}); diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 3e03d970..372d7976 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -13,7 +13,10 @@ import { systemPreferences, } from "electron"; 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 +34,7 @@ import { writeCursorTelemetry, } from "../cursor/telemetry"; import { getFfmpegBinaryPath } from "../ffmpeg/binary"; +import { getMonitorHandles } from "../monitorResolver"; import { ensureNativeCaptureHelperBinary, ensureSwiftHelperBinary, @@ -398,6 +402,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 +734,10 @@ export function registerRecordingHandlers( capturesMicrophone, }; + if (getHudOverlayCaptureProtectionEnabled()) { + config.excludedProcessIds = [process.pid]; + } + 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/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index 0956bf6f..a309de97 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -64,3 +64,11 @@ describe("ScreenCaptureKitRecorder colour metadata", () => { expect(recorderSource).toContain("AVVideoYCbCrMatrix_ITU_R_709_2"); }); }); + +describe("ScreenCaptureKitRecorder HUD capture protection", () => { + it("excludes protected Recordly applications from display capture", () => { + expect(recorderSource).toContain("let excludedProcessIds: [Int32]?"); + expect(recorderSource).toContain("excludedProcessIds.contains($0.processID)"); + expect(recorderSource).toContain("excludingApplications: excludedApplications"); + }); +}); 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..420ea93f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -114,10 +114,6 @@ function getEditorWindowQuery(): Record { return query; } -function isHudOverlayCaptureProtectionSupported(): boolean { - return process.platform !== "linux"; -} - export function isHudOverlayMousePassthroughSupported(): boolean { return process.platform !== "linux"; } @@ -146,6 +142,30 @@ function loadHudOverlayCaptureProtectionSetting(): boolean { return hudOverlayHiddenFromCapture; } +export function getHudOverlayCaptureProtectionEnabled(): boolean { + return loadHudOverlayCaptureProtectionSetting(); +} + +function applyHudOverlayCaptureProtectionToWindow(hud: BrowserWindow, enabled: boolean): void { + 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 +433,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 +493,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 +504,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.show(); } win.moveTop(); + applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture); if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { win.setIgnoreMouseEvents(false); setTimeout(() => { @@ -497,9 +515,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 +675,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..c2e2a932 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -115,7 +115,7 @@ function LaunchWindowContent() { toggleHudCaptureProtection, } = useLaunchWindowSystemState(preparePermissions); - const supportsHudCaptureProtection = platform !== "linux"; + const supportsHudCaptureProtection = true; useEffect(() => { if (!selectedDeviceId) {