fix(linux): guard cursor overlay when portal embeds cursor

This commit is contained in:
wiiiii123
2026-05-28 16:41:03 +07:00
parent 338d6585f6
commit 32bb6e4025
9 changed files with 181 additions and 8 deletions
+8 -1
View File
@@ -675,6 +675,7 @@ interface Window {
options?: {
preserveProjectPath?: boolean;
hideOverlayCursorByDefault?: boolean;
nativeCaptureUnavailable?: boolean;
},
) => Promise<{ success: boolean; webcamPath: string | null }>;
setCurrentRecordingSession: (
@@ -683,6 +684,7 @@ interface Window {
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
nativeCaptureUnavailable?: boolean;
},
options?: { preserveProjectPath?: boolean },
) => Promise<{ success: boolean }>;
@@ -693,6 +695,7 @@ interface Window {
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
nativeCaptureUnavailable?: boolean;
};
}>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
@@ -838,7 +841,11 @@ interface Window {
/** Returns the app version from package.json */
getAppVersion: () => Promise<string>;
/** Hide the OS cursor before browser capture starts. */
hideOsCursor: () => Promise<{ success: boolean }>;
hideOsCursor: () => Promise<{
success: boolean;
unsupported?: boolean;
platform?: string;
}>;
/** Recording preferences (mic, system audio) */
getRecordingPreferences: () => Promise<{
success: boolean;
+6 -2
View File
@@ -533,7 +533,7 @@ export function registerProjectHandlers() {
return { success: false, error: String(error), message: 'Failed to open projects folder.' }
}
})
ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }) => {
ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean; nativeCaptureUnavailable?: boolean }) => {
setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path)
approveUserPath(currentVideoPath)
const resolvedSession = await resolveRecordingSession(currentVideoPath)
@@ -548,6 +548,9 @@ export function registerProjectHandlers() {
hideOverlayCursorByDefault:
normalizeBoolean(options?.hideOverlayCursorByDefault) ||
normalizeBoolean(resolvedSession.hideOverlayCursorByDefault),
nativeCaptureUnavailable:
normalizeBoolean(options?.nativeCaptureUnavailable) ||
normalizeBoolean(resolvedSession.nativeCaptureUnavailable),
}
setCurrentRecordingSession(nextSession)
@@ -573,7 +576,7 @@ export function registerProjectHandlers() {
return { success: true, webcamPath: nextSession.webcamPath ?? null }
})
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean }, options?: { preserveProjectPath?: boolean }) => {
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; nativeCaptureUnavailable?: boolean }, options?: { preserveProjectPath?: boolean }) => {
const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath
setCurrentVideoPath(normalizedVideoPath)
setCurrentRecordingSession({
@@ -581,6 +584,7 @@ export function registerProjectHandlers() {
webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null),
timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs),
hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault),
nativeCaptureUnavailable: normalizeBoolean(session.nativeCaptureUnavailable),
});
await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath)
await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath)
+1 -1
View File
@@ -114,7 +114,7 @@ export function registerSettingsHandlers() {
// ---------------------------------------------------------------------------
ipcMain.handle("hide-cursor", () => {
if (process.platform !== "win32") {
return { success: true };
return { success: false, unsupported: true, platform: process.platform };
}
return { success: hideCursor() };
+1
View File
@@ -48,6 +48,7 @@ export type RecordingSessionData = {
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
nativeCaptureUnavailable?: boolean;
};
export type PauseSegment = {
+2
View File
@@ -687,6 +687,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
options?: {
preserveProjectPath?: boolean;
hideOverlayCursorByDefault?: boolean;
nativeCaptureUnavailable?: boolean;
},
) => {
return ipcRenderer.invoke("set-current-video-path", path, options);
@@ -697,6 +698,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
nativeCaptureUnavailable?: boolean;
},
options?: { preserveProjectPath?: boolean },
) => {
@@ -3295,6 +3295,7 @@ export function SettingsPanel({
<Switch
checked={showCursor}
onCheckedChange={onShowCursorChange}
disabled={nativeCaptureUnavailableSession}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</label>
@@ -3303,11 +3304,20 @@ export function SettingsPanel({
<Switch
checked={loopCursor}
onCheckedChange={onLoopCursorChange}
disabled={nativeCaptureUnavailableSession}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</label>
</div>
</div>
{nativeCaptureUnavailableSession ? (
<div className="rounded-lg border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-[10px] leading-4 text-muted-foreground">
{tSettings(
"effects.cursorOverlayUnavailable",
"Cursor overlay is unavailable for this recording because the captured video already contains the system cursor.",
)}
</div>
) : null}
<div className="flex flex-col gap-1.5">
<div className="space-y-1.5">
<ToggleGroup
+12 -4
View File
@@ -1222,10 +1222,18 @@ export default function VideoEditor() {
setExportProgress(resolveSavingExportProgress);
}, []);
const handleShowCursorChange = useCallback((nextShowCursor: boolean) => {
setSessionShowCursorOverride(null);
setShowCursor(nextShowCursor);
}, []);
const handleShowCursorChange = useCallback(
(nextShowCursor: boolean) => {
if (nextShowCursor && sessionNativeCaptureUnavailable) {
setNativeCaptureUnavailableModalOpen(true);
return;
}
setSessionShowCursorOverride(null);
setShowCursor(nextShowCursor);
},
[sessionNativeCaptureUnavailable],
);
const remountPreview = useCallback(() => {
setIsPreviewReady(false);
+59
View File
@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createBrowserRecordingOptions,
createProcessedMicrophoneConstraints,
getScreenCaptureCursorSetting,
normalizeBrowserMicrophoneProfile,
resolveBrowserCaptureCursorPolicy,
resolveLinuxPortalCursorPresentation,
shouldUseNativeWindowsCaptureForSource,
} from "./useScreenRecorder";
@@ -145,6 +147,7 @@ describe("resolveBrowserCaptureCursorPolicy", () => {
streamCursor: "never",
hideOsCursorBeforeRecording: true,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: false,
});
});
@@ -155,8 +158,64 @@ describe("resolveBrowserCaptureCursorPolicy", () => {
streamCursor: "always",
hideOsCursorBeforeRecording: false,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
});
});
it("does not fake OS cursor hiding on Linux portal capture", () => {
expect(resolveBrowserCaptureCursorPolicy({ platform: "linux" })).toEqual({
streamCursor: "never",
hideOsCursorBeforeRecording: false,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
});
});
});
describe("resolveLinuxPortalCursorPresentation", () => {
it("enables the Recordly overlay only when the portal confirms cursor-hidden capture", () => {
expect(
resolveLinuxPortalCursorPresentation({
requestedCursor: "never",
actualCursor: "never",
}),
).toEqual({
hideEditorOverlayCursorByDefault: false,
nativeCaptureUnavailable: false,
});
});
it("keeps the overlay disabled when the portal embeds or omits cursor settings", () => {
expect(
resolveLinuxPortalCursorPresentation({
requestedCursor: "never",
actualCursor: "always",
}),
).toEqual({
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
});
expect(
resolveLinuxPortalCursorPresentation({
requestedCursor: "never",
actualCursor: null,
}),
).toEqual({
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
});
});
});
describe("getScreenCaptureCursorSetting", () => {
it("normalizes only supported screen-capture cursor settings", () => {
expect(getScreenCaptureCursorSetting({ cursor: "motion" } as MediaTrackSettings)).toBe(
"motion",
);
expect(
getScreenCaptureCursorSetting({ cursor: "hidden" } as MediaTrackSettings),
).toBeNull();
});
});
describe("shouldUseNativeWindowsCaptureForSource", () => {
+82
View File
@@ -46,10 +46,12 @@ export type BrowserMicrophoneProfile =
| "no-noise-suppression"
| "raw";
type BrowserCaptureCursorMode = "always" | "never";
type BrowserCaptureCursorSetting = BrowserCaptureCursorMode | "motion";
export type BrowserCaptureCursorPolicy = {
streamCursor: BrowserCaptureCursorMode;
hideOsCursorBeforeRecording: boolean;
hideEditorOverlayCursorByDefault: boolean;
nativeCaptureUnavailable: boolean;
};
const DEFAULT_BROWSER_MICROPHONE_PROFILE: BrowserMicrophoneProfile = "processed";
const BROWSER_MICROPHONE_PROFILES = new Set<BrowserMicrophoneProfile>([
@@ -190,8 +192,10 @@ export function normalizeBrowserMicrophoneProfile(value?: string | null): Browse
export function resolveBrowserCaptureCursorPolicy({
nativeWindowsCaptureStartFailed = false,
platform,
}: {
nativeWindowsCaptureStartFailed?: boolean;
platform?: string;
} = {}): BrowserCaptureCursorPolicy {
if (nativeWindowsCaptureStartFailed) {
// If WGC already failed, avoid the telemetry overlay path that can lag on
@@ -200,6 +204,19 @@ export function resolveBrowserCaptureCursorPolicy({
streamCursor: "always",
hideOsCursorBeforeRecording: false,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
};
}
if (platform === "linux") {
// Linux screen capture runs through xdg-desktop-portal/PipeWire. Ask the
// portal to omit the cursor, but do not pretend we can globally hide the
// OS cursor from Electron when the portal/compositor ignores that request.
return {
streamCursor: "never",
hideOsCursorBeforeRecording: false,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
};
}
@@ -207,6 +224,37 @@ export function resolveBrowserCaptureCursorPolicy({
streamCursor: "never",
hideOsCursorBeforeRecording: true,
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: false,
};
}
export function getScreenCaptureCursorSetting(
settings: MediaTrackSettings | null | undefined,
): BrowserCaptureCursorSetting | null {
const cursor = (settings as { cursor?: unknown } | null | undefined)?.cursor;
return cursor === "always" || cursor === "never" || cursor === "motion" ? cursor : null;
}
export function resolveLinuxPortalCursorPresentation({
actualCursor,
requestedCursor,
}: {
actualCursor: BrowserCaptureCursorSetting | null;
requestedCursor: BrowserCaptureCursorMode;
}): Pick<
BrowserCaptureCursorPolicy,
"hideEditorOverlayCursorByDefault" | "nativeCaptureUnavailable"
> {
if (requestedCursor === "never" && actualCursor === "never") {
return {
hideEditorOverlayCursorByDefault: false,
nativeCaptureUnavailable: false,
};
}
return {
hideEditorOverlayCursorByDefault: true,
nativeCaptureUnavailable: true,
};
}
@@ -372,6 +420,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
const requestedBrowserMicrophoneProfile = useRef<string | null>(null);
const hideEditorOverlayCursorByDefault = useRef(false);
const nativeCaptureUnavailableForCursorOverlay = useRef(false);
const notifyRecordingFinalizationFailure = useCallback(async (message: string) => {
setFinalizing(false);
@@ -680,6 +729,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const start = performance.now();
console.log("[PERF:RENDERER] Finalize Session & Switch to Editor: STARTED");
const shouldHideOverlayCursor = hideEditorOverlayCursorByDefault.current;
const nativeCaptureUnavailable = nativeCaptureUnavailableForCursorOverlay.current;
try {
if (webcamPath) {
await window.electronAPI.setCurrentRecordingSession({
@@ -687,10 +737,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamPath,
timeOffsetMs: webcamTimeOffsetMs.current,
hideOverlayCursorByDefault: shouldHideOverlayCursor,
nativeCaptureUnavailable,
});
} else {
await window.electronAPI.setCurrentVideoPath(videoPath, {
hideOverlayCursorByDefault: shouldHideOverlayCursor,
nativeCaptureUnavailable,
});
}
} catch (error) {
@@ -699,6 +751,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
await window.electronAPI.setCurrentVideoPath(videoPath, {
hideOverlayCursorByDefault: shouldHideOverlayCursor,
nativeCaptureUnavailable,
});
} catch (fallbackError) {
console.error("Failed to persist fallback video path:", fallbackError);
@@ -1166,6 +1219,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamPath,
timeOffsetMs: webcamTimeOffsetMs.current,
hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current,
nativeCaptureUnavailable:
nativeCaptureUnavailableForCursorOverlay.current,
});
console.log(
@@ -1364,6 +1419,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const platform = await window.electronAPI.getPlatform();
hideEditorOverlayCursorByDefault.current = false;
nativeCaptureUnavailableForCursorOverlay.current = false;
const existingSource = await window.electronAPI.getSelectedSource();
const selectedSource =
existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null);
@@ -1563,9 +1619,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const browserCursorPolicy = resolveBrowserCaptureCursorPolicy({
nativeWindowsCaptureStartFailed,
platform,
});
hideEditorOverlayCursorByDefault.current =
browserCursorPolicy.hideEditorOverlayCursorByDefault;
nativeCaptureUnavailableForCursorOverlay.current =
browserCursorPolicy.nativeCaptureUnavailable;
const wantsAudioCapture = microphoneEnabled || systemAudioEnabled;
const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource);
@@ -1749,6 +1808,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
throw new Error("Media stream is not available.");
}
if (useLinuxPortal) {
const actualCursor = getScreenCaptureCursorSetting(videoTrack.getSettings());
const cursorPresentation = resolveLinuxPortalCursorPresentation({
actualCursor,
requestedCursor: browserCursorPolicy.streamCursor,
});
hideEditorOverlayCursorByDefault.current =
cursorPresentation.hideEditorOverlayCursorByDefault;
nativeCaptureUnavailableForCursorOverlay.current =
cursorPresentation.nativeCaptureUnavailable;
if (cursorPresentation.nativeCaptureUnavailable) {
console.warn(
"Linux portal did not confirm cursor-hidden capture; disabling Recordly cursor overlay for this recording.",
{
actualCursor,
requestedCursor: browserCursorPolicy.streamCursor,
},
);
}
}
try {
await videoTrack.applyConstraints({
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
@@ -1854,6 +1934,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
timeOffsetMs: webcamTimeOffsetMs.current,
hideOverlayCursorByDefault:
hideEditorOverlayCursorByDefault.current,
nativeCaptureUnavailable:
nativeCaptureUnavailableForCursorOverlay.current,
});
}
} finally {