Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions electron/hudCaptureProtection.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
20 changes: 19 additions & 1 deletion electron/ipc/register/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,6 +35,7 @@ import {
writeCursorTelemetry,
} from "../cursor/telemetry";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import { getMonitorHandles } from "../monitorResolver";
import {
ensureNativeCaptureHelperBinary,
ensureSwiftHelperBinary,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
getUpdateToastWindow,
hideUpdateToastWindow,
isHudOverlayMousePassthroughSupported,
reassertHudOverlayCaptureProtection,
reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState,
setHudOverlayRecordingActive,
showUpdateToastWindow,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion electron/native/ScreenCaptureKitRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct CaptureConfig: Codable {
let microphoneDeviceId: String?
let microphoneLabel: String?
let microphoneOutputPath: String?
let excludedProcessIds: [Int32]?
}

let targetCaptureFPS = 60
Expand Down Expand Up @@ -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)
Expand Down
Binary file not shown.
Binary file modified electron/native/bin/darwin-x64/recordly-screencapturekit-helper
Binary file not shown.
55 changes: 41 additions & 14 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
}
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -487,6 +509,7 @@ export function createHudOverlayWindow(): BrowserWindow {
win.show();
}
win.moveTop();
applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture);
if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) {
win.setIgnoreMouseEvents(false);
setTimeout(() => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -115,7 +116,7 @@ function LaunchWindowContent() {
toggleHudCaptureProtection,
} = useLaunchWindowSystemState(preparePermissions);

const supportsHudCaptureProtection = platform !== "linux";
const hudCaptureProtectionSupported = supportsHudCaptureProtection(platform ?? "");

useEffect(() => {
if (!selectedDeviceId) {
Expand Down Expand Up @@ -372,7 +373,7 @@ function LaunchWindowContent() {
</div>

<MorePopover
supportsHudCaptureProtection={supportsHudCaptureProtection}
supportsHudCaptureProtection={hudCaptureProtectionSupported}
hideHudFromCapture={hideHudFromCapture}
onToggleHudCaptureProtection={() => {
void toggleHudCaptureProtection();
Expand Down
33 changes: 33 additions & 0 deletions src/lib/hudCaptureProtection.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions src/lib/hudCaptureProtection.ts
Original file line number Diff line number Diff line change
@@ -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];
}