Skip to content
Open
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
5 changes: 4 additions & 1 deletion electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
13 changes: 13 additions & 0 deletions electron/native-bridge/capture/linuxNativeCaptureSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
6 changes: 6 additions & 0 deletions electron/native/pipewire-capture/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ pub enum Event {
pipewire_version: Option<String>,
/// 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.
///
Expand Down Expand Up @@ -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,
);
Expand All @@ -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]
Expand Down
9 changes: 7 additions & 2 deletions electron/native/pipewire-capture/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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 {
Expand Down
56 changes: 53 additions & 3 deletions electron/native/pipewire-capture/src/portal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<bool, PortalError> {
pub async fn offered_cursor_modes() -> Result<Vec<CursorMode>, 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
Expand Down Expand Up @@ -276,3 +304,25 @@ pub async fn negotiate(cursor_mode: CursorMode) -> Result<PortalStream, PortalEr
source_kind: stream.source_type().map(SourceKind::from_portal),
})
}

#[cfg(test)]
mod tests {
use super::CursorMode::{Embedded, Hidden, Metadata};

#[test]
fn embedded_falls_back_to_metadata_when_the_portal_lacks_it() {
assert_eq!(Embedded.resolve(&[Metadata, Hidden]), Metadata);
}

#[test]
fn an_offered_mode_is_kept() {
assert_eq!(Embedded.resolve(&[Metadata, Embedded]), Embedded);
assert_eq!(Metadata.resolve(&[Metadata]), Metadata);
}

#[test]
fn a_miss_without_a_fallback_is_left_for_negotiation_to_refuse() {
assert_eq!(Embedded.resolve(&[Hidden]), Embedded);
assert_eq!(Metadata.resolve(&[Embedded]), Metadata);
}
}
127 changes: 127 additions & 0 deletions src/hooks/useScreenRecorder.nativeLinuxWebcamCursor.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ReturnType<typeof vi.fn>>;

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 }),
);
});
});
6 changes: 5 additions & 1 deletion src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading