Fix HUD capture protection across recording backends

This commit is contained in:
young committed 2026-09-03 11:38:03 +10:00
1 parent b019824a29
commit 58faea68f1
9 files changed
+102 -17

No files matched your search

+29
View File
@@ -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\(\)/,
);
});
});
+13 -1
View File
@@ -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;
}
+5
View File
@@ -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
+10 -1
View File
@@ -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)
@@ -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");
});
});
+36 -14
View File
@@ -114,10 +114,6 @@ function getEditorWindowQuery(): Record<string, string> {
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.
+1 -1
View File
@@ -115,7 +115,7 @@ function LaunchWindowContent() {
toggleHudCaptureProtection,
} = useLaunchWindowSystemState(preparePermissions);
const supportsHudCaptureProtection = platform !== "linux";
const supportsHudCaptureProtection = true;
useEffect(() => {
if (!selectedDeviceId) {