From f387302de6bc10bca12698e15259823d575264f6 Mon Sep 17 00:00:00 2001 From: abdo Date: Wed, 30 Sep 2026 19:58:52 +0300 Subject: [PATCH 1/3] feat(capture): support separate cursor recording on Hyprland via IPC fallback --- .../capture/src/screen/linux/capabilities.rs | 17 ++++++-- .../capture/src/screen/linux/diagnostics.rs | 12 ++++-- packages/capture/src/screen/linux/hyprland.rs | 39 +++++++++++++++++++ packages/capture/src/screen/linux/mod.rs | 1 + .../src/screen/linux/pipewire/process.rs | 15 ++++++- packages/capture/src/screen/linux/portal.rs | 23 +++++++++-- packages/capture/src/screen/mod.rs | 3 +- packages/capture/tests/linux_portal.rs | 15 +++++++ 8 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 packages/capture/src/screen/linux/hyprland.rs diff --git a/packages/capture/src/screen/linux/capabilities.rs b/packages/capture/src/screen/linux/capabilities.rs index cd4e9756f..56692be1e 100644 --- a/packages/capture/src/screen/linux/capabilities.rs +++ b/packages/capture/src/screen/linux/capabilities.rs @@ -39,10 +39,20 @@ pub fn evaluate_capabilities( portal: PortalProperties, pipewire_available: bool, ffmpeg_available: bool, +) -> LinuxNativeCapabilities { + evaluate_capabilities_with_compositor(portal, pipewire_available, ffmpeg_available, false) +} + +pub fn evaluate_capabilities_with_compositor( + portal: PortalProperties, + pipewire_available: bool, + ffmpeg_available: bool, + hyprland_available: bool, ) -> LinuxNativeCapabilities { let portal_selection = portal.version >= MIN_PORTAL_VERSION && (portal.monitor || portal.window) && pipewire_available; + let separate_cursor = portal_selection && (portal.metadata_cursor || (portal.hidden_cursor && hyprland_available)); LinuxNativeCapabilities { backend: "xdg-portal-pipewire".into(), portal_version: portal.version, @@ -51,9 +61,9 @@ pub fn evaluate_capabilities( portal_selection, hidden_cursor: portal.hidden_cursor, embedded_cursor: portal.embedded_cursor, - separate_cursor: portal_selection && portal.metadata_cursor, + separate_cursor, cursor_clicks: super::input_helper_supported(), - cursor_shapes: portal_selection && portal.metadata_cursor, + cursor_shapes: separate_cursor, pipewire_available, recording_available: portal_selection && ffmpeg_available, } @@ -63,10 +73,11 @@ pub fn probe_native_capabilities( timeout: Duration, ) -> Result { let portal = probe_portal_properties(timeout)?; - Ok(evaluate_capabilities( + Ok(evaluate_capabilities_with_compositor( portal, probe_pipewire(), super::probe_ffmpeg().is_ok(), + super::hyprland::is_hyprland(), )) } diff --git a/packages/capture/src/screen/linux/diagnostics.rs b/packages/capture/src/screen/linux/diagnostics.rs index 71c63b444..10bebd699 100644 --- a/packages/capture/src/screen/linux/diagnostics.rs +++ b/packages/capture/src/screen/linux/diagnostics.rs @@ -6,8 +6,8 @@ use crate::model::{ }; use super::{ - FfmpegCapabilities, LinuxNativeCapabilities, PortalProperties, evaluate_capabilities, - probe_ffmpeg, probe_pipewire, probe_portal_properties, + FfmpegCapabilities, LinuxNativeCapabilities, PortalProperties, + evaluate_capabilities_with_compositor, probe_ffmpeg, probe_pipewire, probe_portal_properties, }; pub(crate) struct LinuxCaptureProbe { @@ -21,8 +21,12 @@ pub(crate) fn probe_capture_environment(timeout: Duration) -> LinuxCaptureProbe let pipewire_available = probe_pipewire(); let ffmpeg_result = probe_ffmpeg(); let portal_properties = portal_result.as_ref().copied().unwrap_or_default(); - let capabilities = - evaluate_capabilities(portal_properties, pipewire_available, ffmpeg_result.is_ok()); + let capabilities = evaluate_capabilities_with_compositor( + portal_properties, + pipewire_available, + ffmpeg_result.is_ok(), + super::hyprland::is_hyprland(), + ); let recording_available = capabilities.recording_available; let portal = match portal_result { Ok(properties) => portal_diagnostic(properties), diff --git a/packages/capture/src/screen/linux/hyprland.rs b/packages/capture/src/screen/linux/hyprland.rs new file mode 100644 index 000000000..15cb1f824 --- /dev/null +++ b/packages/capture/src/screen/linux/hyprland.rs @@ -0,0 +1,39 @@ +use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + path::PathBuf, + time::Duration, +}; + +fn socket_path() -> Option { + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")?; + let sig = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE")?; + let mut path = PathBuf::from(runtime_dir); + path.push("hypr"); + path.push(sig); + path.push(".socket.sock"); + if path.exists() { + Some(path) + } else { + None + } +} + +pub(crate) fn is_hyprland() -> bool { + socket_path().is_some() +} + +pub(crate) fn query_cursor_pos() -> Option<(i32, i32)> { + let path = socket_path()?; + let mut stream = UnixStream::connect(path).ok()?; + stream.set_read_timeout(Some(Duration::from_millis(10))).ok(); + stream.set_write_timeout(Some(Duration::from_millis(10))).ok(); + stream.write_all(b"/cursorpos").ok()?; + let mut buf = [0u8; 64]; + let n = stream.read(&mut buf).ok()?; + let s = std::str::from_utf8(&buf[..n]).ok()?; + let mut parts = s.trim().split(','); + let x = parts.next()?.trim().parse::().ok()?; + let y = parts.next()?.trim().parse::().ok()?; + Some((x, y)) +} diff --git a/packages/capture/src/screen/linux/mod.rs b/packages/capture/src/screen/linux/mod.rs index 91e839710..2e034d097 100644 --- a/packages/capture/src/screen/linux/mod.rs +++ b/packages/capture/src/screen/linux/mod.rs @@ -10,6 +10,7 @@ mod ffmpeg_process; mod ffmpeg_process_tests; mod ffmpeg_sink; mod gpu_inventory; +pub(crate) mod hyprland; mod input_helper_diagnostics; #[cfg(test)] mod input_helper_diagnostics_tests; diff --git a/packages/capture/src/screen/linux/pipewire/process.rs b/packages/capture/src/screen/linux/pipewire/process.rs index a995c39a0..47e40c3fc 100644 --- a/packages/capture/src/screen/linux/pipewire/process.rs +++ b/packages/capture/src/screen/linux/pipewire/process.rs @@ -95,7 +95,20 @@ pub(super) fn process_buffer(stream: &pw::stream::Stream, state: &Rc Result { + let modes = proxy + .available_cursor_modes() + .await + .map_err(map_portal_error)?; let request = proxy .select_sources( session, SelectSourcesOptions::default() .set_sources(source_type(kind)) .set_multiple(false) - .set_cursor_mode(cursor_mode(cursor)) + .set_cursor_mode(cursor_mode(cursor, &modes)) .set_persist_mode(PersistMode::DoNot), ) .await @@ -272,7 +276,7 @@ async fn verify_capabilities( .available_cursor_modes() .await .map_err(map_portal_error)?; - let requested_cursor = cursor_mode(cursor); + let requested_cursor = cursor_mode(cursor, &modes); if !modes.contains(requested_cursor) { return Err(CaptureError::native( NativeCaptureErrorCode::PortalCursorMetadataUnavailable, @@ -290,11 +294,22 @@ fn source_type(kind: PortalSourceKind) -> ashpd::enumflags2::BitFlags CursorMode { +fn cursor_mode( + cursor: CursorSelection, + modes: &ashpd::enumflags2::BitFlags, +) -> CursorMode { match cursor { CursorSelection::Disabled => CursorMode::Hidden, CursorSelection::Embedded => CursorMode::Embedded, - CursorSelection::Separate { .. } => CursorMode::Metadata, + CursorSelection::Separate { .. } => { + if modes.contains(CursorMode::Metadata) { + CursorMode::Metadata + } else if modes.contains(CursorMode::Hidden) { + CursorMode::Hidden + } else { + CursorMode::Embedded + } + } } } diff --git a/packages/capture/src/screen/mod.rs b/packages/capture/src/screen/mod.rs index c70ec70f8..ab4dad724 100644 --- a/packages/capture/src/screen/mod.rs +++ b/packages/capture/src/screen/mod.rs @@ -13,7 +13,8 @@ pub use recording::*; pub mod linux; #[cfg(target_os = "linux")] pub use linux::{ - LinuxNativeCapabilities, PortalProperties, evaluate_capabilities, probe_native_capabilities, + LinuxNativeCapabilities, PortalProperties, evaluate_capabilities, + evaluate_capabilities_with_compositor, probe_native_capabilities, }; #[cfg(target_os = "macos")] pub mod mac; diff --git a/packages/capture/tests/linux_portal.rs b/packages/capture/tests/linux_portal.rs index 9c41f379b..007589d82 100644 --- a/packages/capture/tests/linux_portal.rs +++ b/packages/capture/tests/linux_portal.rs @@ -201,6 +201,21 @@ fn cursor_modes_are_reported_independently_and_metadata_is_required_for_separate assert_product_gate(&metadata_only, true); } +#[test] +fn hyprland_enables_separate_cursor_when_metadata_is_absent_but_hidden_is_supported() { + let hyprland_capabilities = capture::screen::linux::evaluate_capabilities_with_compositor( + portal_properties(5, true, true, true, true, false), + true, + true, + true, + ); + assert!(hyprland_capabilities.portal_selection); + assert!(hyprland_capabilities.hidden_cursor); + assert!(hyprland_capabilities.separate_cursor); + assert!(hyprland_capabilities.cursor_shapes); + assert_product_gate(&hyprland_capabilities, true); +} + const NATIVE_ERROR_CODES: &[(NativeCaptureErrorCode, &str)] = &[ ( NativeCaptureErrorCode::PortalUnavailable, From 0d5f202724b7340898933dee4ecf62b5eb4cbf50 Mon Sep 17 00:00:00 2001 From: abdo Date: Wed, 30 Sep 2026 22:24:03 +0300 Subject: [PATCH 2/3] fix(capture): address CodeRabbit review on Hyprland IPC and portal fallback --- .../capture/src/screen/linux/capabilities.rs | 3 + packages/capture/src/screen/linux/hyprland.rs | 56 ++++++++++++++++--- packages/capture/src/screen/linux/portal.rs | 6 +- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/capture/src/screen/linux/capabilities.rs b/packages/capture/src/screen/linux/capabilities.rs index 56692be1e..03397637c 100644 --- a/packages/capture/src/screen/linux/capabilities.rs +++ b/packages/capture/src/screen/linux/capabilities.rs @@ -34,6 +34,7 @@ pub struct LinuxNativeCapabilities { pub recording_available: bool, } +/// Evaluates native Linux capture capabilities from portal properties, PipeWire, and FFmpeg availability. #[must_use] pub fn evaluate_capabilities( portal: PortalProperties, @@ -43,6 +44,7 @@ pub fn evaluate_capabilities( evaluate_capabilities_with_compositor(portal, pipewire_available, ffmpeg_available, false) } +/// Evaluates native Linux capture capabilities including compositor-specific fallbacks (such as Hyprland). pub fn evaluate_capabilities_with_compositor( portal: PortalProperties, pipewire_available: bool, @@ -69,6 +71,7 @@ pub fn evaluate_capabilities_with_compositor( } } +/// Probes the system for portal, PipeWire, FFmpeg, and compositor capabilities within the given timeout. pub fn probe_native_capabilities( timeout: Duration, ) -> Result { diff --git a/packages/capture/src/screen/linux/hyprland.rs b/packages/capture/src/screen/linux/hyprland.rs index 15cb1f824..e644ce7d5 100644 --- a/packages/capture/src/screen/linux/hyprland.rs +++ b/packages/capture/src/screen/linux/hyprland.rs @@ -5,6 +5,7 @@ use std::{ time::Duration, }; +/// Resolves the filesystem path to the Hyprland IPC UNIX domain socket. fn socket_path() -> Option { let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")?; let sig = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE")?; @@ -19,21 +20,62 @@ fn socket_path() -> Option { } } +/// Returns true if the current desktop environment is an active Hyprland session. pub(crate) fn is_hyprland() -> bool { socket_path().is_some() } +/// Parses the `X, Y` coordinate string returned by Hyprland's `/cursorpos` command. +pub(crate) fn parse_cursor_pos(response: &str) -> Option<(i32, i32)> { + let mut parts = response.trim().split(','); + let x = parts.next()?.trim().parse::().ok()?; + let y = parts.next()?.trim().parse::().ok()?; + Some((x, y)) +} + +/// Queries the current physical cursor coordinates from Hyprland IPC. pub(crate) fn query_cursor_pos() -> Option<(i32, i32)> { let path = socket_path()?; let mut stream = UnixStream::connect(path).ok()?; stream.set_read_timeout(Some(Duration::from_millis(10))).ok(); stream.set_write_timeout(Some(Duration::from_millis(10))).ok(); stream.write_all(b"/cursorpos").ok()?; - let mut buf = [0u8; 64]; - let n = stream.read(&mut buf).ok()?; - let s = std::str::from_utf8(&buf[..n]).ok()?; - let mut parts = s.trim().split(','); - let x = parts.next()?.trim().parse::().ok()?; - let y = parts.next()?.trim().parse::().ok()?; - Some((x, y)) + let mut buf = Vec::with_capacity(32); + stream.take(64).read_to_end(&mut buf).ok()?; + let s = std::str::from_utf8(&buf).ok()?; + parse_cursor_pos(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_valid_cursor_coordinates() { + assert_eq!(parse_cursor_pos("123, 456"), Some((123, 456))); + assert_eq!(parse_cursor_pos("0, 0\n"), Some((0, 0))); + assert_eq!(parse_cursor_pos(" 1920, 1080 \r\n"), Some((1920, 1080))); + assert_eq!(parse_cursor_pos("-50, -20"), Some((-50, -20))); + } + + #[test] + fn rejects_invalid_or_truncated_cursor_responses() { + assert_eq!(parse_cursor_pos(""), None); + assert_eq!(parse_cursor_pos("123"), None); + assert_eq!(parse_cursor_pos("123,"), None); + assert_eq!(parse_cursor_pos(", 456"), None); + assert_eq!(parse_cursor_pos("abc, def"), None); + } + + #[test] + fn parses_fragmented_stream_accumulation() { + // Simulates fragmented chunks arriving over a stream + let chunks: &[&[u8]] = &[b"12", b"3, ", b"45", b"6\n"]; + let mut accumulated = Vec::new(); + for chunk in chunks { + accumulated.extend_from_slice(chunk); + } + let s = std::str::from_utf8(&accumulated).unwrap_or_default(); + assert_eq!(parse_cursor_pos(s), Some((123, 456))); + } } diff --git a/packages/capture/src/screen/linux/portal.rs b/packages/capture/src/screen/linux/portal.rs index a2e4dce4b..33d366d6d 100644 --- a/packages/capture/src/screen/linux/portal.rs +++ b/packages/capture/src/screen/linux/portal.rs @@ -294,6 +294,8 @@ fn source_type(kind: PortalSourceKind) -> ashpd::enumflags2::BitFlags