diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 32ed313e0..6f6ff2ab4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; /** 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; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index a65fd9070..7af983b08 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -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,10 @@ export function registerProjectHandlers() { hideOverlayCursorByDefault: normalizeBoolean(options?.hideOverlayCursorByDefault) || normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), + nativeCaptureUnavailable: + normalizeBoolean( + options?.nativeCaptureUnavailable ?? resolvedSession.nativeCaptureUnavailable, + ), } setCurrentRecordingSession(nextSession) @@ -573,7 +577,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 +585,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) diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index e84f63171..5b4498a87 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -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() }; diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 58f5425bd..9680da73b 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -48,6 +48,7 @@ export type RecordingSessionData = { webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; }; export type PauseSegment = { diff --git a/electron/preload.ts b/electron/preload.ts index 932db07b0..2cfb155b7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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 }, ) => { diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index cff898a53..4ddaafd7b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -3295,6 +3295,7 @@ export function SettingsPanel({ @@ -3303,11 +3304,20 @@ export function SettingsPanel({ + {nativeCaptureUnavailableSession ? ( +
+ {tSettings( + "effects.cursorOverlayUnavailable", + "Cursor overlay is unavailable for this recording because the captured video already contains the system cursor.", + )} +
+ ) : null}
{ - 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); diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 85811467e..509b049bd 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -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", () => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 30ed7c623..b6f34be96 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -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([ @@ -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(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 {