Merge pull request #868 from webadderallorg/fix/hud-capture-protection

Keep the HUD out of recordings across capture backends
This commit is contained in:
webadderall
2026-09-03 11:55:47 +10:00
committed by GitHub
10 changed files with 147 additions and 18 deletions
+21
View File
@@ -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([]);
});
});
+19 -1
View File
@@ -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;
}
+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)
+41 -14
View File
@@ -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<string, string> {
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.
+3 -2
View File
@@ -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() {
</div>
<MorePopover
supportsHudCaptureProtection={supportsHudCaptureProtection}
supportsHudCaptureProtection={hudCaptureProtectionSupported}
hideHudFromCapture={hideHudFromCapture}
onToggleHudCaptureProtection={() => {
void toggleHudCaptureProtection();
+33
View File
@@ -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([]);
});
});
+15
View File
@@ -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];
}