mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
fix(linux): guard cursor overlay when portal embeds cursor
This commit is contained in:
Vendored
+8
-1
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -48,6 +48,7 @@ export type RecordingSessionData = {
|
||||
webcamPath?: string | null;
|
||||
timeOffsetMs?: number;
|
||||
hideOverlayCursorByDefault?: boolean;
|
||||
nativeCaptureUnavailable?: boolean;
|
||||
};
|
||||
|
||||
export type PauseSegment = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user