From 5d003dd34da6708f6c90d60ac05f1bc57b81a5dd Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 21 Oct 2026 11:59:06 +0200 Subject: [PATCH 1/2] fix(linux): fall back to metadata cursor when the portal lacks embedded Some ScreenCast portals (niri's) offer no EMBEDDED cursor mode, so the system-cursor setting refused to record. The helper now negotiates METADATA there and reports the mode in ready; the app then records the cursor as editable, so the editor draws it. Refs #1357 --- electron/ipc/handlers.ts | 5 +- .../capture/linuxNativeCaptureSession.test.ts | 24 ++++++++ .../capture/linuxNativeCaptureSession.ts | 13 +++++ .../recording/pipeWireCursorAccumulator.ts | 8 ++- .../native/pipewire-capture/src/events.rs | 6 ++ electron/native/pipewire-capture/src/main.rs | 9 ++- .../native/pipewire-capture/src/portal.rs | 56 ++++++++++++++++++- 7 files changed, 114 insertions(+), 7 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index ee36e105f..8ac4658c7 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2753,7 +2753,10 @@ export function registerIpcHandlers( linuxNativeCaptureSession = session; linuxNativeCaptureRecordingId = recordingId; - linuxNativeCaptureCursorMode = cursorCaptureMode; + // The portal may lack EMBEDDED and get METADATA instead (#1357): the + // pointer is then not in the pixels, so the editor has to draw it. + linuxNativeCaptureCursorMode = + session.cursorMode === "metadata" ? "editable-overlay" : cursorCaptureMode; // The portal's answer, not an in-app selection — on Wayland there // is none to have. This used to read `selectedSource || { name: diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts index 96bdfe32b..0d900a7d7 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts @@ -189,6 +189,30 @@ describe("LinuxNativeCaptureSession", () => { expect(JSON.stringify(request)).not.toContain("restoreToken"); }); + it("reports the cursor mode the helper fell back to (#1357)", async () => { + const session = new LinuxNativeCaptureSession({ + outputPath: "/tmp/recording.mp4", + cursorMode: "embedded", + fps: 30, + audio: { system: { enabled: false }, microphone: { enabled: false, gain: 1 } }, + maxCursorSamples: 100, + }); + const started = session.start(); + await Promise.resolve(); + expect(session.cursorMode).toBe("embedded"); + helper.emitEvent({ event: "ready", timestampMs: 1_000, cursorMode: "metadata" }); + await started; + + expect(session.cursorMode).toBe("metadata"); + }); + + it("keeps the requested cursor mode when the helper does not report one", async () => { + const session = newSession(); + await startReady(session); + + expect(session.cursorMode).toBe("metadata"); + }); + it("reports the source kind the portal granted", async () => { const { session, result } = await record("window"); diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index 64f47e25e..19a83b5c6 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -114,6 +114,7 @@ export class LinuxNativeCaptureSession { private stoppedReject: ((error: Error) => void) | null = null; private sourceKind: LinuxCaptureSourceKind | undefined; + private negotiatedCursorMode: LinuxCaptureConfig["cursorMode"] | undefined; private videoEncoder: string | undefined; private lastError: string | null = null; private paused = false; @@ -355,6 +356,15 @@ export class LinuxNativeCaptureSession { return this.sourceKind; } + /** + * The cursor mode the helper really negotiates, known once `start()` resolves. + * A portal without EMBEDDED gets METADATA instead (#1357), so the cursor has + * to be drawn by the editor even though the system cursor was asked for. + */ + get cursorMode(): LinuxCaptureConfig["cursorMode"] { + return this.negotiatedCursorMode ?? this.config.cursorMode; + } + /** * What the helper said in this session, for Save Diagnostics' `helperOutput.linux`: * its stderr as written and every stdout line except `cursor-sample`, which comes @@ -471,6 +481,9 @@ export class LinuxNativeCaptureSession { private handleEvent(payload: PipeWireHelperEvent) { switch (payload.event) { case "ready": + if (payload.cursorMode === "metadata" || payload.cursorMode === "embedded") { + this.negotiatedCursorMode = payload.cursorMode; + } this.resolveReady(); return; diff --git a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts index 4fe7d1d8f..9a233c7d2 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts @@ -27,7 +27,13 @@ export interface PipeWireCursorAssetPayload { } export type PipeWireHelperEvent = - | { event: "ready"; timestampMs: number; pipewireVersion?: string | null } + | { + event: "ready"; + timestampMs: number; + pipewireVersion?: string | null; + /** The mode really negotiated; absent from helpers older than #1357. */ + cursorMode?: "metadata" | "embedded" | "hidden"; + } | { /** The picker has been answered. Fires before any pixel moves. */ event: "source-selected"; diff --git a/electron/native/pipewire-capture/src/events.rs b/electron/native/pipewire-capture/src/events.rs index 4c2122bbd..9bc4d4c7a 100644 --- a/electron/native/pipewire-capture/src/events.rs +++ b/electron/native/pipewire-capture/src/events.rs @@ -48,6 +48,9 @@ pub enum Event { pipewire_version: Option, /// Whether the portal advertises METADATA cursor mode at all. cursor_metadata_supported: bool, + /// The cursor mode actually negotiated, which can differ from the one + /// asked for (see `CursorMode::resolve`). + cursor_mode: &'static str, }, /// The user has answered the compositor's picker and a source was granted. /// @@ -253,6 +256,7 @@ mod tests { timestamp_ms: 42, pipewire_version: Some("1.0.5".to_owned()), cursor_metadata_supported: true, + cursor_mode: "metadata", }, false, ); @@ -275,11 +279,13 @@ mod tests { timestamp_ms: 7, pipewire_version: None, cursor_metadata_supported: false, + cursor_mode: "embedded", }); assert_eq!(value["event"], "ready"); assert_eq!(value["timestampMs"], 7); assert!(value["pipewireVersion"].is_null()); assert_eq!(value["cursorMetadataSupported"], false); + assert_eq!(value["cursorMode"], "embedded"); } #[test] diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 7b07419d0..aac59ec4d 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -283,10 +283,14 @@ fn main() { // compositor fails immediately instead of after a pointless picker. Only // fatal when METADATA is the mode actually requested — a recording that // wants the system cursor painted in has no use for it. - let cursor_metadata_supported = match pollster::block_on(portal::cursor_metadata_supported()) { - Ok(supported) => supported, + let offered_cursor_modes = match pollster::block_on(portal::offered_cursor_modes()) { + Ok(modes) => modes, Err(error) => fail(&mut emitter, error.code(), &error.message()), }; + let cursor_metadata_supported = offered_cursor_modes.contains(&portal::CursorMode::Metadata); + // Resolved before anything reads it, so the portal, the click readers and + // `ready` all see the mode that is really negotiated. + let cursor_mode = cursor_mode.resolve(&offered_cursor_modes); if !cursor_metadata_supported && cursor_mode.reports_cursor() { let error = portal::PortalError::CursorMetadataUnsupported; fail(&mut emitter, error.code(), &error.message()); @@ -296,6 +300,7 @@ fn main() { timestamp_ms: timestamp_ms(), pipewire_version: shim::library_version(), cursor_metadata_supported, + cursor_mode: cursor_mode.as_str(), }); if request.probe_only { diff --git a/electron/native/pipewire-capture/src/portal.rs b/electron/native/pipewire-capture/src/portal.rs index 73cca0fb6..68ef7156d 100644 --- a/electron/native/pipewire-capture/src/portal.rs +++ b/electron/native/pipewire-capture/src/portal.rs @@ -55,6 +55,31 @@ impl CursorMode { pub fn reports_cursor(self) -> bool { matches!(self, Self::Metadata) } + + pub fn as_str(self) -> &'static str { + match self { + Self::Metadata => "metadata", + Self::Embedded => "embedded", + Self::Hidden => "hidden", + } + } + + /// The mode to actually negotiate, given what the portal offers. + /// + /// Some portals offer no EMBEDDED at all (niri's, #1357). Refusing to record + /// there leaves the user nothing, so EMBEDDED falls back to METADATA: the + /// pointer stays out of the pixels and the editor draws it from the samples + /// instead. Any other miss is returned unchanged, for the negotiation to + /// refuse with its own error. + pub fn resolve(self, offered: &[CursorMode]) -> CursorMode { + if self == Self::Embedded + && !offered.contains(&Self::Embedded) + && offered.contains(&Self::Metadata) + { + return Self::Metadata; + } + self + } } /// Which kind of source the compositor actually handed over. @@ -167,17 +192,20 @@ fn portal_unavailable(error: impl std::fmt::Display) -> PortalError { )) } -/// Cheap, non-interactive probe: does this portal offer METADATA cursor mode? +/// Cheap, non-interactive probe: which cursor modes does this portal offer? /// /// Split out from [`negotiate`] so the helper can answer that question — and /// emit `ready` — before anything raises a dialog. -pub async fn cursor_metadata_supported() -> Result { +pub async fn offered_cursor_modes() -> Result, PortalError> { let proxy = Screencast::new().await.map_err(portal_unavailable)?; let cursor_modes = proxy .available_cursor_modes() .await .map_err(|error| failed("AvailableCursorModes", error))?; - Ok(cursor_modes.contains(PortalCursorMode::Metadata)) + Ok([CursorMode::Metadata, CursorMode::Embedded, CursorMode::Hidden] + .into_iter() + .filter(|mode| cursor_modes.contains(mode.to_portal())) + .collect()) } /// Runs the negotiation to completion. Blocks on the user for as long as the @@ -276,3 +304,25 @@ pub async fn negotiate(cursor_mode: CursorMode) -> Result Date: Wed, 21 Oct 2026 15:24:38 +0200 Subject: [PATCH 2/2] fix(linux): attach the webcam with the cursor mode main stored --- ...nRecorder.nativeLinuxWebcamCursor.test.tsx | 127 ++++++++++++++++++ src/hooks/useScreenRecorder.ts | 6 +- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx diff --git a/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx b/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx new file mode 100644 index 000000000..3bd1c2a04 --- /dev/null +++ b/src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string) => key, +})); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +vi.mock("@/lib/requestCameraAccess", () => ({ + requestCameraAccess: vi.fn(async () => ({ success: true, granted: true, status: "granted" })), +})); + +// A webcam that "streamed to disk", so finalize hands back an asset without a real +// MediaRecorder behind it. +vi.mock("./recorderHandle", () => ({ + createRecorderHandle: vi.fn(() => ({ + recorder: { state: "inactive", stop: vi.fn() }, + recordedBlobPromise: Promise.resolve(new Blob()), + isStreaming: () => true, + discard: vi.fn(async () => undefined), + })), +})); + +import { useScreenRecorder } from "./useScreenRecorder"; + +type ElectronAPI = Window["electronAPI"]; + +let api: Record>; + +function stubElectronAPI() { + api = { + getRecordingPrefs: vi.fn(async () => ({ + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: true, + camDeviceId: null, + camDeviceName: null, + camQuality: "1080p", + systemAudioEnabled: false, + cursorCaptureMode: "system", + hideDesktopIcons: false, + autoZoomEnabled: true, + })), + setRecordingPrefs: vi.fn(async () => undefined), + onRecordingPrefsChanged: vi.fn(() => () => undefined), + getPlatform: vi.fn(() => "linux"), + getSelectedSource: vi.fn(async () => null), + isNativeLinuxCaptureAvailable: vi.fn(async () => ({ success: true, available: true })), + startNativeLinuxRecording: vi.fn(async () => ({ success: true, recordingId: 7 })), + stopNativeLinuxRecording: vi.fn(), + attachNativeLinuxWebcamRecording: vi.fn(async () => ({ success: true, session: {} })), + showCountdownOverlay: vi.fn(async () => true), + setCountdownOverlayValue: vi.fn(async () => true), + hideCountdownOverlay: vi.fn(async () => true), + setCurrentRecordingSession: vi.fn(async () => undefined), + setCurrentVideoPath: vi.fn(async () => undefined), + switchToEditor: vi.fn(async () => undefined), + }; + window.electronAPI = api as unknown as ElectronAPI; + const track = { stop: vi.fn(), onended: null, getSettings: () => ({ width: 1280, height: 720 }) }; + const stream = { getTracks: () => [track], getVideoTracks: () => [track] }; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + enumerateDevices: vi.fn(async () => [ + { kind: "videoinput", deviceId: "cam-1", label: "Cam", groupId: "" }, + ]), + getUserMedia: vi.fn(async () => stream), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }); + vi.stubGlobal("MediaRecorder", { isTypeSupported: () => true }); +} + +async function settle(ms = 0) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + stubElectronAPI(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("useScreenRecorder native Linux webcam attachment", () => { + // "system" asked for; main stores what the portal granted. "editable-overlay" is the + // METADATA fallback of #1357, "system" a portal that did honour EMBEDDED. + it.each([ + "editable-overlay", + "system", + ] as const)("attaches the webcam with the stored cursor mode %s", async (stored) => { + api.stopNativeLinuxRecording.mockResolvedValue({ + success: true, + path: "/rec/a.mp4", + session: { screenVideoPath: "/rec/a.mp4", createdAt: 7, cursorCaptureMode: stored }, + }); + const view = renderHook(() => useScreenRecorder()); + await settle(); + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + expect(view.result.current.recording).toBe(true); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(); + + expect(api.attachNativeLinuxWebcamRecording).toHaveBeenCalledWith( + expect.objectContaining({ cursorCaptureMode: stored }), + ); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5156749b..8c22aa9b7 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -959,7 +959,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { screenVideoPath: result.path, recordingId: activeNativeRecording.recordingId, webcam: webcamResult.asset, - cursorCaptureMode, + // The mode main stored for the take, not the one asked for: a portal + // without EMBEDDED turns "system" into "editable-overlay" (#1357). + // This callback is also reached through the first render's + // stopRecording closure, so its own cursorCaptureMode is stale. + cursorCaptureMode: result.session?.cursorCaptureMode ?? cursorCaptureMode, durationMs: webcamDurationMs, ...(typeof activeNativeRecording.webcamOffsetMs === "number" ? { webcamOffsetMs: activeNativeRecording.webcamOffsetMs }