diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f089eb71..d8cea4944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ User-facing changes to Beam are documented in this file. ### Added +- Hyprland can record a separate cursor for monitor and region captures through compositor IPC when its portal provides no cursor metadata. + - Recording and editor settings offer a shared project location preserving `projects/studio`, `projects/instant` and `projects/screenshot`. New captures use the chosen root immediately; the project picker retains previous locations and refreshes when they change. - Video and image exports share a separate folder preference, with a fixed destination or the last successfully used folder and a searchable list of recent folders. - Safari frames offer Auto, Light and Dark browser chrome independently of the frame color, with matching previews, screenshots and video exports. diff --git a/apps/desktop/electron/lifecycle/hyprland-window-rules.cjs b/apps/desktop/electron/lifecycle/hyprland-window-rules.cjs index a5f4da894..2e9eab96c 100644 --- a/apps/desktop/electron/lifecycle/hyprland-window-rules.cjs +++ b/apps/desktop/electron/lifecycle/hyprland-window-rules.cjs @@ -3,6 +3,7 @@ const path = require('node:path'); const APP_CLASS = '^com\\.beam\\.app$'; const OVERLAY_TITLE = '^Beam (Recorder|Countdown|Quick Snip|Region Selection|Recording Region|Teleprompter)$'; +const MATCH_RULE = `/keyword windowrule border_size 0, no_shadow on, match:class ${APP_CLASS}, match:title ${OVERLAY_TITLE}`; function hyprlandRequest(socketPath, command, timeoutMs) { return new Promise((resolve, reject) => { @@ -35,7 +36,7 @@ function overlayCommands(tag) { ]; } if (minor >= 53) { - return [`/keyword windowrule border_size 0, no_shadow on, match:class ${APP_CLASS}, match:title ${OVERLAY_TITLE}`]; + return [MATCH_RULE]; } return ['noborder', 'noshadow'].map( (rule) => `/keyword windowrulev2 ${rule}, class:${APP_CLASS}, title:${OVERLAY_TITLE}`, @@ -51,7 +52,10 @@ async function applyHyprlandWindowRules(env = process.env, platform = process.pl const commands = overlayCommands(version.tag); if (!commands.length) return false; for (const command of commands) { - if ((await hyprlandRequest(socketPath, command, timeoutMs)) !== 'ok') return false; + const response = await hyprlandRequest(socketPath, command, timeoutMs); + if (command.startsWith('/eval ') && response === 'eval is only supported with the lua config manager') { + if ((await hyprlandRequest(socketPath, MATCH_RULE, timeoutMs)) !== 'ok') return false; + } else if (response !== 'ok') return false; } return true; } catch { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 278f534c3..792789be7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -255,3 +255,5 @@ Architecture changes must explain the affected boundary, the data contract, fail ## Background catalogue lifetime Desktop preferences own hidden background identities and one persisted deletion receipt. The narrow `preferences:background-catalog` IPC reads and patches the latest state synchronously before notification, and undo/redo matches the receipt identity so stale windows cannot undo a different deletion. Preset drafts do not overwrite these metadata keys. Removing an item from the catalogue retains imported source files, authored project background values and custom gradient array indices; rendering and CLI resource resolution remain valid. This catalogue history is independent of engine document history. + +On Hyprland without Portal cursor metadata, Rust requests Hidden mode for monitor capture and owns a bounded compositor IPC sampler. Portal mapping IDs and typed monitor geometry anchor positions to the selected output. The PipeWire callback reads only a recent cached position, using the existing crop and transform mapping; Electron never polls global cursor coordinates for capture. Native cursor shapes and window cursor metadata remain Portal features. diff --git a/docs/dev/linux.md b/docs/dev/linux.md index 7270c8156..2730e59cd 100644 --- a/docs/dev/linux.md +++ b/docs/dev/linux.md @@ -6,6 +6,10 @@ Electron windows use X11 (`--ozone-platform=x11`) on Linux, including XWayland o Video editor export uses WebCodecs, independently of the native capture backend. See [GPU drivers and WebCodecs export](gpu-drivers.md) for driver packages, codec/buffer checks and measured Intel results. +### Hyprland + +When the portal cannot supply cursor metadata, monitor and region recording use Hidden cursor mode plus Hyprland IPC position sampling. The portal mapping ID selects the captured output, including fractional scaling, offsets and rotation. Older portals without a mapping ID require one monitor or a uniquely sized output; ambiguous outputs fail with a diagnostic rather than guessing from focus. IPC sampling runs outside the PipeWire frame callback and stops with the capture; stale positions are discarded. Native cursor shapes and separate-cursor window capture still require portal metadata. Clicks and shortcuts use the protected input helper independently. + ### Cinnamon on X11 Linux Mint's default Cinnamon/X11 session uses the XApp portal backend, which does not implement the ScreenCast interface. Installing or restarting `xdg-desktop-portal` or `xdg-desktop-portal-xapp` alone cannot make Beam record in that session. Beam currently has no direct X11 capture backend. diff --git a/packages/capture/src/screen/linux/capabilities.rs b/packages/capture/src/screen/linux/capabilities.rs index cd4e9756f..98fbf981c 100644 --- a/packages/capture/src/screen/linux/capabilities.rs +++ b/packages/capture/src/screen/linux/capabilities.rs @@ -34,15 +34,28 @@ 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, pipewire_available: bool, ffmpeg_available: bool, +) -> LinuxNativeCapabilities { + 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, + 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,7 +64,7 @@ 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, pipewire_available, @@ -59,14 +72,16 @@ pub fn evaluate_capabilities( } } +/// Probes the system for portal, PipeWire, FFmpeg, and compositor capabilities within the given timeout. 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..b1b745292 --- /dev/null +++ b/packages/capture/src/screen/linux/hyprland.rs @@ -0,0 +1,210 @@ +use std::{ + io::{Read, Write}, + os::unix::{fs::FileTypeExt, net::UnixStream}, + path::{Path, PathBuf}, + sync::{Arc, Mutex, mpsc}, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use super::{ + pipewire::{CursorMetadata, NegotiatedFormat, VideoTransform}, + portal::PreparedPortal, +}; +use crate::{CaptureError, NativeCaptureErrorCode}; + +#[path = "hyprland_types.rs"] +mod types; +use types::{CursorSnapshot, HyprlandMonitor}; + +const IPC_TIMEOUT: Duration = Duration::from_millis(20); +const SAMPLE_INTERVAL: Duration = Duration::from_millis(16); +const MAX_SAMPLE_AGE: Duration = Duration::from_millis(250); + +fn socket_path() -> Option { + let runtime_dir = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?); + let signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + if !runtime_dir.is_absolute() + || signature.is_empty() + || !signature + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return None; + } + let path = runtime_dir + .join("hypr") + .join(signature) + .join(".socket.sock"); + path.metadata() + .ok()? + .file_type() + .is_socket() + .then_some(path) +} + +pub(crate) fn is_hyprland() -> bool { + socket_path().is_some() +} + +fn query(path: &Path, command: &[u8]) -> Option> { + let mut stream = UnixStream::connect(path).ok()?; + let deadline = Instant::now() + IPC_TIMEOUT; + stream.set_write_timeout(Some(IPC_TIMEOUT)).ok()?; + stream.write_all(command).ok()?; + let mut response = Vec::new(); + let mut chunk = [0; 1024]; + loop { + let remaining = deadline.checked_duration_since(Instant::now())?; + stream.set_read_timeout(Some(remaining)).ok()?; + let read = stream.read(&mut chunk).ok()?; + if read == 0 { + return Some(response); + } + response.extend_from_slice(&chunk[..read]); + if response.len() > 32768 { + return None; + } + } +} + +fn parse_cursor_pos(response: &[u8]) -> Option<(i32, i32)> { + let text = std::str::from_utf8(response).ok()?; + let (x, y) = text.trim().split_once(',')?; + Some((x.trim().parse().ok()?, y.trim().parse().ok()?)) +} + +fn query_monitors(path: &Path) -> Option> { + serde_json::from_slice(&query(path, b"j/monitors")?).ok() +} + +fn select_monitor( + monitors: &[HyprlandMonitor], + mapping_id: Option<&str>, + size: Option<(i32, i32)>, +) -> Option { + let candidates: Vec<_> = monitors + .iter() + .filter(|monitor| { + if !monitor.valid() { + return false; + } + if let Some(name) = mapping_id { + return monitor.name == name; + } + monitors.len() == 1 || size.is_some_and(|size| monitor.matches_size(size)) + }) + .collect(); + match candidates.as_slice() { + [monitor] => Some((**monitor).clone()), + _ => None, + } +} + +pub(crate) struct HyprlandCursor { + snapshot: Arc>>, + stop: mpsc::Sender<()>, + worker: Option>, +} + +impl HyprlandCursor { + pub(super) fn for_portal(portal: &PreparedPortal) -> Result, CaptureError> { + if !portal.hyprland_cursor { + return Ok(None); + } + let path = socket_path().ok_or_else(|| cursor_error("Hyprland IPC is unavailable"))?; + let monitors = + query_monitors(&path).ok_or_else(|| cursor_error("Cannot query Hyprland monitors"))?; + let monitor = select_monitor(&monitors, portal.mapping_id.as_deref(), portal.size) + .ok_or_else(|| { + cursor_error("Cannot identify the selected monitor for separate cursor capture") + })?; + Self::start(path, monitor).map(Some) + } + + fn start(path: PathBuf, mut monitor: HyprlandMonitor) -> Result { + let point = query(&path, b"/cursorpos") + .and_then(|response| parse_cursor_pos(&response)) + .ok_or_else(|| cursor_error("Cannot query the Hyprland cursor position"))?; + let snapshot = Arc::new(Mutex::new(Some(CursorSnapshot { + point, + monitor: monitor.clone(), + received: Instant::now(), + }))); + let output = snapshot.clone(); + let (stop, receiver) = mpsc::channel(); + let worker = thread::Builder::new() + .name("beam-hyprland-cursor".into()) + .spawn(move || { + let mut refreshed = Instant::now(); + while matches!( + receiver.recv_timeout(SAMPLE_INTERVAL), + Err(mpsc::RecvTimeoutError::Timeout) + ) { + if refreshed.elapsed() >= Duration::from_secs(1) { + let updated = query_monitors(&path).and_then(|monitors| { + select_monitor(&monitors, Some(&monitor.name), None) + }); + let Some(updated) = updated else { + if let Ok(mut output) = output.lock() { + *output = None; + } + break; + }; + monitor = updated; + refreshed = Instant::now(); + } + let point = query(&path, b"/cursorpos") + .and_then(|response| parse_cursor_pos(&response)); + if let Ok(mut output) = output.lock() { + *output = point.map(|point| CursorSnapshot { + point, + monitor: monitor.clone(), + received: Instant::now(), + }); + } + } + }) + .map_err(|error| { + cursor_error(&format!("Cannot start Hyprland cursor sampling: {error}")) + })?; + Ok(Self { + snapshot, + stop, + worker: Some(worker), + }) + } + + pub(super) fn metadata( + &self, + format: NegotiatedFormat, + transform: VideoTransform, + ) -> Option { + let snapshot = self.snapshot.lock().ok()?; + let snapshot = snapshot.as_ref()?; + if snapshot.received.elapsed() > MAX_SAMPLE_AGE { + return None; + } + snapshot.metadata(format, transform) + } +} + +impl Drop for HyprlandCursor { + fn drop(&mut self) { + let _ = self.stop.send(()); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +fn cursor_error(message: &str) -> CaptureError { + CaptureError::native( + NativeCaptureErrorCode::PortalCursorMetadataUnavailable, + message, + ) +} + +#[cfg(test)] +#[path = "hyprland_tests.rs"] +mod tests; diff --git a/packages/capture/src/screen/linux/hyprland_tests.rs b/packages/capture/src/screen/linux/hyprland_tests.rs new file mode 100644 index 000000000..f0a762372 --- /dev/null +++ b/packages/capture/src/screen/linux/hyprland_tests.rs @@ -0,0 +1,244 @@ +#![allow(clippy::expect_used)] + +use super::super::pipewire::{NativePixelFormat, map_cursor_metadata}; +use super::*; +use std::os::unix::net::UnixListener; + +fn monitors() -> Vec { + serde_json::from_str( + r#"[ + {"name":"DP-1","x":-1536,"y":0,"width":1920,"height":1080,"scale":1.25,"focused":false}, + {"name":"DP-2","x":0,"y":100,"width":1920,"height":1080,"scale":2.0,"focused":true} + ]"#, + ) + .expect("typed monitors") +} + +fn cursor(snapshot: Option) -> HyprlandCursor { + let (stop, _) = mpsc::channel(); + HyprlandCursor { + snapshot: Arc::new(Mutex::new(snapshot)), + stop, + worker: None, + } +} + +#[test] +fn parses_signed_and_fragmented_cursor_responses_and_rejects_invalid_data() { + assert_eq!(parse_cursor_pos(b"123, 456\n"), Some((123, 456))); + assert_eq!(parse_cursor_pos(b" -50, -20 \r\n"), Some((-50, -20))); + let chunks: &[&[u8]] = &[b"12", b"3, ", b"45", b"6\n"]; + assert_eq!(parse_cursor_pos(&chunks.concat()), Some((123, 456))); + for response in [ + b"".as_slice(), + b"1", + b"1,", + b"1,2,3", + b"abc, 2", + b"2147483648,0", + b"\xff,0", + ] { + assert_eq!(parse_cursor_pos(response), None); + } +} + +#[test] +fn selects_the_portal_monitor_instead_of_the_focused_monitor() { + let monitors = monitors(); + let selected = select_monitor(&monitors, Some("DP-1"), Some((1920, 1080))).expect("mapping id"); + assert_eq!(selected.name, "DP-1"); + assert_eq!(selected.scale, 1.25); + assert!(select_monitor(&monitors, Some("missing"), Some((1920, 1080))).is_none()); +} + +#[test] +fn unidentified_same_sized_outputs_are_rejected_instead_of_guessing() { + let monitors = monitors(); + assert!(select_monitor(&monitors, None, Some((1920, 1080))).is_none()); + assert!(select_monitor(&monitors, None, None).is_none()); + assert!(select_monitor(&[], None, None).is_none()); + assert_eq!( + select_monitor(&monitors[..1], None, None) + .expect("single monitor") + .name, + "DP-1" + ); +} + +#[test] +fn unique_physical_stream_size_identifies_older_portal_outputs() { + let mut monitors = monitors(); + monitors[1].width = 2560; + monitors[1].height = 1440; + assert_eq!( + select_monitor(&monitors, None, Some((2560, 1440))) + .expect("unique dimensions") + .name, + "DP-2" + ); + monitors[1].transform = 1; + assert_eq!( + select_monitor(&monitors, None, Some((1440, 2560))) + .expect("rotated dimensions") + .name, + "DP-2" + ); + assert!(select_monitor(&monitors, None, Some((-1, 0))).is_none()); +} + +#[test] +fn monitor_json_and_geometry_require_real_valid_fields() { + for json in ["invalid", "{}", "[{\"name\":\"DP-1\"}]"] { + assert!(serde_json::from_str::>(json).is_err()); + } + for scale in [0.0, -1.0, f64::INFINITY, f64::NAN] { + let mut monitors = monitors(); + monitors[0].scale = scale; + assert!(select_monitor(&monitors, Some("DP-1"), None).is_none()); + } + let mut monitor = monitors().remove(0); + monitor.width = 0; + assert!(!monitor.valid()); + monitor.width = 1920; + monitor.transform = 8; + assert!(!monitor.valid()); +} + +#[test] +fn fractional_scale_negative_offsets_and_outside_positions_remain_relative_to_capture() { + let monitor = monitors().remove(0); + let snapshot = CursorSnapshot { + point: (-768, 432), + monitor, + received: Instant::now(), + }; + let format = NegotiatedFormat::new(1920, 1080, NativePixelFormat::Bgra).expect("format"); + let cursor = cursor(Some(snapshot)); + let metadata = cursor + .metadata(format, VideoTransform::None) + .expect("fresh snapshot"); + assert_eq!((metadata.x, metadata.y), (960, 540)); + cursor + .snapshot + .lock() + .expect("snapshot") + .as_mut() + .expect("point") + .point = (0, 432); + assert_eq!( + cursor + .metadata(format, VideoTransform::None) + .expect("outside position") + .x, + 1920 + ); +} + +#[test] +fn every_spa_transform_is_applied_once_to_compositor_coordinates() { + let monitor = monitors().remove(0); + let format = NegotiatedFormat::new(1920, 1080, NativePixelFormat::Bgra).expect("format"); + for transform in [ + VideoTransform::None, + VideoTransform::Rotated90, + VideoTransform::Rotated180, + VideoTransform::Rotated270, + VideoTransform::Flipped, + VideoTransform::Flipped90, + VideoTransform::Flipped180, + VideoTransform::Flipped270, + ] { + let cursor = cursor(Some(CursorSnapshot { + point: (-1152, 216), + monitor: monitor.clone(), + received: Instant::now(), + })); + let raw = cursor.metadata(format, transform); + let mapped = map_cursor_metadata(raw, format.width, format.height, None, transform) + .expect("mapped cursor"); + let swapped = matches!( + transform, + VideoTransform::Rotated90 + | VideoTransform::Rotated270 + | VideoTransform::Flipped90 + | VideoTransform::Flipped270 + ); + assert_eq!( + (mapped.x, mapped.y), + if swapped { (270, 480) } else { (480, 270) } + ); + } +} + +#[test] +fn missing_or_stale_ipc_samples_are_not_reused_as_live_cursor_positions() { + let format = NegotiatedFormat::new(1920, 1080, NativePixelFormat::Bgra).expect("format"); + assert!( + cursor(None) + .metadata(format, VideoTransform::None) + .is_none() + ); + let cursor = cursor(Some(CursorSnapshot { + point: (1, 1), + monitor: monitors().remove(0), + received: Instant::now() - Duration::from_secs(1), + })); + assert!(cursor.metadata(format, VideoTransform::None).is_none()); +} + +#[test] +fn ipc_reads_fragmented_responses_and_bounds_oversized_or_silent_peers() { + for mode in 0..3 { + let directory = tempfile::tempdir().expect("temporary socket"); + let path = directory.path().join("socket"); + let listener = UnixListener::bind(&path).expect("listener"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("connection"); + let mut request = [0; 32]; + let _ = stream.read(&mut request); + match mode { + 0 => { + let _ = stream.write_all(b"12"); + let _ = stream.write_all(b"3, 456\n"); + } + 1 => { + let _ = stream.write_all(&vec![b'x'; 32769]); + } + _ => thread::sleep(Duration::from_millis(60)), + } + }); + let response = query(&path, b"/cursorpos"); + if mode == 0 { + assert_eq!( + parse_cursor_pos(&response.expect("complete response")), + Some((123, 456)) + ); + } else { + assert!(response.is_none()); + } + server.join().expect("server finished"); + } +} + +#[test] +fn sampling_failure_clears_the_snapshot_and_worker_stops_on_drop() { + let directory = tempfile::tempdir().expect("temporary socket"); + let path = directory.path().join("socket"); + let listener = UnixListener::bind(&path).expect("listener"); + let server = thread::spawn(move || { + for response in [b"100, 100".as_slice(), b"invalid"] { + let (mut stream, _) = listener.accept().expect("connection"); + let mut request = [0; 32]; + let _ = stream.read(&mut request); + let _ = stream.write_all(response); + } + }); + let cursor = HyprlandCursor::start(path, monitors().remove(0)).expect("start sampler"); + let deadline = Instant::now() + Duration::from_secs(1); + while cursor.snapshot.lock().expect("snapshot").is_some() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + assert!(cursor.snapshot.lock().expect("snapshot").is_none()); + drop(cursor); + server.join().expect("server finished"); +} diff --git a/packages/capture/src/screen/linux/hyprland_types.rs b/packages/capture/src/screen/linux/hyprland_types.rs new file mode 100644 index 000000000..7d2a0bde2 --- /dev/null +++ b/packages/capture/src/screen/linux/hyprland_types.rs @@ -0,0 +1,103 @@ +use super::super::pipewire::{CursorMetadata, NegotiatedFormat, VideoTransform}; +use crate::cursor::{CursorKind, Hotspot}; +use serde::Deserialize; +use std::time::Instant; + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct HyprlandMonitor { + pub name: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub scale: f64, + #[serde(default)] + pub transform: u32, +} + +impl HyprlandMonitor { + pub(super) fn valid(&self) -> bool { + !self.name.is_empty() + && self.width > 0 + && self.height > 0 + && self.scale.is_finite() + && self.scale > 0.0 + && self.transform <= 7 + } + + fn upright_size(&self) -> (u32, u32) { + if self.transform % 2 == 1 { + (self.height, self.width) + } else { + (self.width, self.height) + } + } + + pub(super) fn matches_size(&self, size: (i32, i32)) -> bool { + let Ok(width) = u32::try_from(size.0) else { + return false; + }; + let Ok(height) = u32::try_from(size.1) else { + return false; + }; + (width, height) == self.upright_size() || (width, height) == (self.width, self.height) + } +} + +pub(super) struct CursorSnapshot { + pub point: (i32, i32), + pub monitor: HyprlandMonitor, + pub received: Instant, +} + +impl CursorSnapshot { + pub(super) fn metadata( + &self, + format: NegotiatedFormat, + transform: VideoTransform, + ) -> Option { + if !self.monitor.valid() || format.width == 0 || format.height == 0 { + return None; + } + let (upright_width, upright_height) = self.monitor.upright_size(); + let (output_width, output_height) = match transform { + VideoTransform::Rotated90 + | VideoTransform::Rotated270 + | VideoTransform::Flipped90 + | VideoTransform::Flipped270 => (format.height, format.width), + _ => (format.width, format.height), + }; + let x = ((f64::from(self.point.0) - f64::from(self.monitor.x)) + * self.monitor.scale + * f64::from(output_width) + / f64::from(upright_width)) + .round() as i32; + let y = ((f64::from(self.point.1) - f64::from(self.monitor.y)) + * self.monitor.scale + * f64::from(output_height) + / f64::from(upright_height)) + .round() as i32; + let (width, height) = (i64::from(format.width), i64::from(format.height)); + let (x, y) = (i64::from(x), i64::from(y)); + // SPA applies this transform later; convert compositor coordinates back + // to the raw buffer so the existing crop/rotation mapping runs once. + let (x, y) = match transform { + VideoTransform::None => (x, y), + VideoTransform::Rotated90 => (width - 1 - y, x), + VideoTransform::Rotated180 => (width - 1 - x, height - 1 - y), + VideoTransform::Rotated270 => (y, height - 1 - x), + VideoTransform::Flipped => (width - 1 - x, y), + VideoTransform::Flipped90 => (y, x), + VideoTransform::Flipped180 => (x, height - 1 - y), + VideoTransform::Flipped270 => (width - 1 - y, height - 1 - x), + }; + Some(CursorMetadata { + id: 1, + shape_id: Some(1), + x: i32::try_from(x).ok()?, + y: i32::try_from(y).ok()?, + hotspot: Some(Hotspot { x: 0, y: 0 }), + cursor_kind: Some(CursorKind::Default), + }) + } +} diff --git a/packages/capture/src/screen/linux/mod.rs b/packages/capture/src/screen/linux/mod.rs index 5f49ff0d8..fdcb07141 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 01b2cf5d9..6ffaf6c19 100644 --- a/packages/capture/src/screen/linux/pipewire/process.rs +++ b/packages/capture/src/screen/linux/pipewire/process.rs @@ -63,6 +63,7 @@ pub(super) struct ProcessState { pub repair_window_crop: bool, pub region: Option, pub dmabuf_importer: DmaBufImporter, + pub hyprland_cursor: Option, } pub(super) fn should_defer_timestamp_origin( @@ -96,7 +97,9 @@ pub(super) fn process_buffer(stream: &pw::stream::Stream, state: &Rc Fixture { repair_window_crop: false, region: None, dmabuf_importer: DmaBufImporter::new(), + hyprland_cursor: None, }; Fixture { state, diff --git a/packages/capture/src/screen/linux/pipewire/thread.rs b/packages/capture/src/screen/linux/pipewire/thread.rs index 89372e9c8..00c2277d8 100644 --- a/packages/capture/src/screen/linux/pipewire/thread.rs +++ b/packages/capture/src/screen/linux/pipewire/thread.rs @@ -55,6 +55,7 @@ pub(crate) struct PipewireCaptureRequest { pub(crate) metrics: Arc, pub(crate) repair_window_crop: bool, pub(crate) region: Option, + pub(crate) hyprland_cursor: Option, pub(crate) show_real_cursor: bool, pub(crate) target_fps: u32, } @@ -72,6 +73,7 @@ impl PipewireCapture { metrics, repair_window_crop, region, + hyprland_cursor, show_real_cursor, target_fps, } = request; @@ -115,6 +117,7 @@ impl PipewireCapture { region, show_real_cursor, target_fps, + hyprland_cursor, ); let _ = finish_sender.send(SinkMessage::Finish); result diff --git a/packages/capture/src/screen/linux/pipewire/worker.rs b/packages/capture/src/screen/linux/pipewire/worker.rs index 263ed2613..2ea1de90d 100644 --- a/packages/capture/src/screen/linux/pipewire/worker.rs +++ b/packages/capture/src/screen/linux/pipewire/worker.rs @@ -40,6 +40,7 @@ pub(super) fn pipewire_worker( region: Option, show_real_cursor: bool, target_fps: u32, + hyprland_cursor: Option, ) -> Result<(), CaptureError> { pw::init(); let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(pipewire_error)?; @@ -78,6 +79,7 @@ pub(super) fn pipewire_worker( repair_window_crop, region, dmabuf_importer: DmaBufImporter::new(), + hyprland_cursor, })); let ready = Rc::new(RefCell::new(Some(ready))); let negotiation_stopped = Rc::new(Cell::new(false)); diff --git a/packages/capture/src/screen/linux/portal.rs b/packages/capture/src/screen/linux/portal.rs index 0334f59fa..df5ad7d91 100644 --- a/packages/capture/src/screen/linux/portal.rs +++ b/packages/capture/src/screen/linux/portal.rs @@ -25,6 +25,8 @@ pub(super) struct PreparedPortal { pub source_type: Option, pub position: Option<(i32, i32)>, pub size: Option<(i32, i32)>, + pub mapping_id: Option, + pub hyprland_cursor: bool, control: PortalControl, } @@ -89,6 +91,8 @@ struct PortalReady { source_type: Option, position: Option<(i32, i32)>, size: Option<(i32, i32)>, + mapping_id: Option, + hyprland_cursor: bool, } struct PortalControl { @@ -161,6 +165,8 @@ pub(super) fn prepare_portal( source_type: ready.source_type, position: ready.position, size: ready.size, + mapping_id: ready.mapping_id, + hyprland_cursor: ready.hyprland_cursor, control: PortalControl { commands: Some(commands), thread: Some(thread), @@ -252,13 +258,31 @@ async fn prepare_session( kind: PortalSourceKind, cursor: CursorSelection, ) -> Result { + let modes = proxy + .available_cursor_modes() + .await + .map_err(map_portal_error)?; + let selected_mode = cursor_mode(cursor, &modes); + let hyprland_cursor = + matches!(cursor, CursorSelection::Separate { .. }) && selected_mode == CursorMode::Hidden; + if hyprland_cursor && matches!(kind, PortalSourceKind::Window) { + return Err(CaptureError::native( + NativeCaptureErrorCode::PortalCursorMetadataUnavailable, + "Select a monitor to record a separate cursor on Hyprland", + )); + } + let sources = if hyprland_cursor { + SourceType::Monitor.into() + } else { + source_type(kind) + }; let request = proxy .select_sources( session, SelectSourcesOptions::default() - .set_sources(source_type(kind)) + .set_sources(sources) .set_multiple(false) - .set_cursor_mode(cursor_mode(cursor)) + .set_cursor_mode(selected_mode) .set_persist_mode(PersistMode::DoNot), ) .await @@ -289,6 +313,8 @@ async fn prepare_session( source_type: stream.source_type(), position: stream.position(), size: stream.size(), + mapping_id: stream.mapping_id().map(ToOwned::to_owned), + hyprland_cursor, }) } @@ -312,7 +338,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, @@ -330,14 +356,39 @@ fn source_type(kind: PortalSourceKind) -> ashpd::enumflags2::BitFlags CursorMode { +/// Selects the portal cursor mode for the requested cursor capture configuration, +/// falling back to Hidden mode on Hyprland when Metadata mode is not supported. +fn cursor_mode( + cursor: CursorSelection, + modes: &ashpd::enumflags2::BitFlags, +) -> CursorMode { + select_cursor_mode(cursor, modes, super::hyprland::is_hyprland()) +} + +fn select_cursor_mode( + cursor: CursorSelection, + modes: &ashpd::enumflags2::BitFlags, + hyprland_available: bool, +) -> 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) && hyprland_available { + CursorMode::Hidden + } else { + CursorMode::Metadata + } + } } } +#[cfg(test)] +#[path = "portal_cursor_tests.rs"] +mod cursor_tests; + fn map_portal_error(error: ashpd::Error) -> CaptureError { let code = match error { ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled) diff --git a/packages/capture/src/screen/linux/portal_cursor_tests.rs b/packages/capture/src/screen/linux/portal_cursor_tests.rs new file mode 100644 index 000000000..3f98fface --- /dev/null +++ b/packages/capture/src/screen/linux/portal_cursor_tests.rs @@ -0,0 +1,54 @@ +use super::*; + +fn separate() -> CursorSelection { + CursorSelection::Separate { + capture_clicks: false, + capture_shape: true, + capture_shortcuts: false, + } +} + +#[test] +fn metadata_is_preferred_even_when_hyprland_ipc_is_available() { + let modes = CursorMode::Metadata | CursorMode::Hidden; + assert_eq!( + select_cursor_mode(separate(), &modes, true), + CursorMode::Metadata + ); + assert_eq!( + select_cursor_mode(separate(), &modes, false), + CursorMode::Metadata + ); +} + +#[test] +fn hidden_capture_is_only_selected_for_hyprland_without_metadata() { + let modes = CursorMode::Hidden | CursorMode::Embedded; + assert_eq!( + select_cursor_mode(separate(), &modes, true), + CursorMode::Hidden + ); + assert_eq!( + select_cursor_mode(separate(), &modes, false), + CursorMode::Metadata + ); + assert_eq!( + select_cursor_mode(separate(), &CursorMode::Embedded.into(), true), + CursorMode::Metadata + ); +} + +#[test] +fn disabled_and_embedded_capture_never_enable_separate_cursor_sampling() { + let modes = CursorMode::Hidden | CursorMode::Embedded; + for hyprland in [false, true] { + assert_eq!( + select_cursor_mode(CursorSelection::Disabled, &modes, hyprland), + CursorMode::Hidden + ); + assert_eq!( + select_cursor_mode(CursorSelection::Embedded, &modes, hyprland), + CursorMode::Embedded + ); + } +} diff --git a/packages/capture/src/screen/linux/recording.rs b/packages/capture/src/screen/linux/recording.rs index d063cfa23..811dff002 100644 --- a/packages/capture/src/screen/linux/recording.rs +++ b/packages/capture/src/screen/linux/recording.rs @@ -107,6 +107,7 @@ impl LinuxRecording { metrics: metrics.clone(), repair_window_crop, region: request.region, + hyprland_cursor: super::hyprland::HyprlandCursor::for_portal(&portal)?, target_fps: request.recording.target_fps, show_real_cursor: request.show_real_cursor && matches!(request.cursor, CursorSelection::Separate { .. }), diff --git a/packages/capture/src/screen/linux/region_selection.rs b/packages/capture/src/screen/linux/region_selection.rs index 4da1c8705..0e614b4b2 100644 --- a/packages/capture/src/screen/linux/region_selection.rs +++ b/packages/capture/src/screen/linux/region_selection.rs @@ -86,6 +86,7 @@ impl LinuxRegionSelection { region: None, show_real_cursor: false, target_fps: 60, + hyprland_cursor: None, })?; let frame = (|| { gate.release(0)?; diff --git a/packages/capture/src/screen/mod.rs b/packages/capture/src/screen/mod.rs index 5675caaae..20b62c80b 100644 --- a/packages/capture/src/screen/mod.rs +++ b/packages/capture/src/screen/mod.rs @@ -18,7 +18,8 @@ pub use selection::{SelectionBounds, WindowSelectionPreview, preview_window_sele 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..ef7c2bbbd 100644 --- a/packages/capture/tests/linux_portal.rs +++ b/packages/capture/tests/linux_portal.rs @@ -201,6 +201,57 @@ 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); +} + +#[test] +fn hyprland_does_not_enable_separate_cursor_without_hidden_mode_or_pipewire() { + for (hidden, pipewire) in [(false, true), (true, false), (false, false)] { + let capabilities = capture::screen::linux::evaluate_capabilities_with_compositor( + portal_properties(5, true, false, hidden, true, false), + pipewire, + true, + true, + ); + assert!(!capabilities.separate_cursor); + assert!(!capabilities.cursor_shapes); + } +} + +#[test] +fn compositor_fallback_does_not_change_native_metadata_or_non_hyprland_support() { + let properties = portal_properties(5, true, true, true, true, false); + assert!( + !capture::screen::linux::evaluate_capabilities_with_compositor( + properties, true, true, false + ) + .separate_cursor + ); + let native = capture::screen::linux::evaluate_capabilities_with_compositor( + PortalProperties { + metadata_cursor: true, + ..properties + }, + true, + true, + true, + ); + assert!(native.separate_cursor); + assert!(native.cursor_shapes); +} + const NATIVE_ERROR_CODES: &[(NativeCaptureErrorCode, &str)] = &[ ( NativeCaptureErrorCode::PortalUnavailable, diff --git a/test/capture-config.test.cjs b/test/capture-config.test.cjs index dde2b625b..c8abaf2a7 100644 --- a/test/capture-config.test.cjs +++ b/test/capture-config.test.cjs @@ -196,6 +196,24 @@ test('enables clicks and shortcuts on Linux only when interaction recording is o }); }); +test('Hyprland cursor positions remain separate without requesting unavailable native shapes', () => { + const hyprland = { + capabilities: { separateCursor: true, cursorShapes: false, cursorClicks: true, inputShortcuts: true }, + sources: [{ id: 'portal:monitor', kind: 'display', isDefault: true, selectionMode: 'portal' }], + }; + for (const recordInteractions of [false, true]) { + const config = buildDefaultCaptureConfig(hyprland, { recordInteractions }, { ...environment, platform: 'linux' }); + assert.deepEqual(config.cursor, { + mode: 'separate', + captureShape: false, + captureClicks: recordInteractions, + captureShortcuts: recordInteractions, + }); + } + const hidden = buildDefaultCaptureConfig(hyprland, { cursor: false }, { ...environment, platform: 'linux' }); + assert.equal(hidden.cursor.mode, 'disabled'); +}); + test('keeps a region for Linux Portal monitors and rejects it for windows', () => { const portalCatalog = { capabilities: { portalSelection: true }, diff --git a/test/hyprland-window-rules.test.cjs b/test/hyprland-window-rules.test.cjs index ec7488463..18610619d 100644 --- a/test/hyprland-window-rules.test.cjs +++ b/test/hyprland-window-rules.test.cjs @@ -114,3 +114,18 @@ test('silent IPC is bounded and cannot hold application startup', async (t) => { assert.equal(await applyHyprlandWindowRules(f.env, 'linux', 20), false); assert.equal(f.calls.length, 1); }); + +test('modern Hyprland using hyprlang selects match rules after the explicit config-manager response', async (t) => { + const f = await fixture(t, (client, command) => { + client.end( + command === 'j/version' + ? '{"tag":"v0.56.2"}' + : command.startsWith('/eval ') + ? 'eval is only supported with the lua config manager' + : 'ok', + ); + }); + assert.equal(await applyHyprlandWindowRules(f.env, 'linux'), true); + assert.equal(f.calls.length, 3); + assert.match(f.calls[2], /^\/keyword windowrule border_size 0, no_shadow on, match:class/); +});