mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
Merge pull request #868 from webadderallorg/fix/hud-capture-protection
Keep the HUD out of recordings across capture backends
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+41
-14
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user