cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/docs-activation-truth
goq / crates / sigil-host / src / cursor.rs
cursor.rs 36.4K
1use std::sync::Arc;2use std::sync::atomic::{AtomicBool, Ordering};34#[cfg(any(target_os = "linux", test))]5use std::time::{Duration, Instant};67#[cfg(any(target_os = "linux", test))]8use anyhow::Context;9use anyhow::Result;10use sigil_protocol::{PointerPosition, PointerSurfaceDimensions};1112#[cfg(target_os = "linux")]13const POINTER_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_micros(16_667);14#[cfg(any(target_os = "linux", test))]15const BOOTSTRAP_RECONNECT_INITIAL: Duration = Duration::from_millis(100);16#[cfg(any(target_os = "linux", test))]17const BOOTSTRAP_RECONNECT_MAXIMUM: Duration = Duration::from_secs(2);18#[cfg(any(target_os = "linux", test))]19const POINTER_ACTIVITY_VISIBLE_TIMEOUT: Duration = Duration::from_secs(3);20#[cfg(target_os = "linux")]21const FOCUS_DISPLAY_PROPERTY: &[u8] = b"GAMESCOPE_MOUSE_FOCUS_DISPLAY";22#[cfg(target_os = "linux")]23const CURSOR_VISIBLE_PROPERTY: &[u8] = b"GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";2425#[derive(Clone, Copy, Debug, PartialEq, Eq)]26pub struct PointerState {27 pub position: Option<PointerPosition>,28 pub visible: bool,29}3031#[cfg(target_os = "linux")]32impl PointerState {33 const UNAVAILABLE: Self = Self {34 position: None,35 visible: false,36 };37}3839#[cfg(any(target_os = "linux", test))]40fn available_pointer_state(position: PointerPosition, visible: Option<bool>) -> PointerState {41 PointerState {42 position: Some(position),43 // Unknown visibility fails closed without discarding the independently44 // valid compositor position.45 visible: visible.unwrap_or(false),46 }47}4849#[cfg(any(target_os = "linux", test))]50#[derive(Debug)]51struct PointerActivityVisibility {52 last_position: Option<PointerPosition>,53 visible_until: Option<Instant>,54}5556#[cfg(any(target_os = "linux", test))]57impl PointerActivityVisibility {58 fn new(initial_position: PointerPosition) -> Self {59 Self {60 last_position: Some(initial_position),61 visible_until: None,62 }63 }6465 fn reset(&mut self) {66 self.last_position = None;67 self.visible_until = None;68 }6970 fn resolve(71 &mut self,72 position: PointerPosition,73 compositor_visible: Option<bool>,74 sampled_at: Instant,75 ) -> bool {76 if self77 .last_position78 .is_some_and(|previous| previous != position)79 {80 self.visible_until = sampled_at.checked_add(POINTER_ACTIVITY_VISIBLE_TIMEOUT);81 }82 self.last_position = Some(position);8384 compositor_visible.unwrap_or(false)85 || self86 .visible_until87 .is_some_and(|deadline| sampled_at < deadline)88 }89}9091#[cfg(any(target_os = "linux", test))]92#[derive(Debug)]93struct ReconnectBackoff {94 delay: Duration,95 retry_not_before: Instant,96}9798#[cfg(any(target_os = "linux", test))]99impl ReconnectBackoff {100 fn new(now: Instant) -> Self {101 Self {102 delay: BOOTSTRAP_RECONNECT_INITIAL,103 retry_not_before: now,104 }105 }106107 fn ready(&self, now: Instant) -> bool {108 now >= self.retry_not_before109 }110111 fn failed(&mut self, now: Instant) {112 self.retry_not_before = now.checked_add(self.delay).unwrap_or(now);113 self.delay = self114 .delay115 .checked_mul(2)116 .unwrap_or(BOOTSTRAP_RECONNECT_MAXIMUM)117 .min(BOOTSTRAP_RECONNECT_MAXIMUM);118 }119120 fn recovered(&mut self, now: Instant) {121 self.delay = BOOTSTRAP_RECONNECT_INITIAL;122 self.retry_not_before = now;123 }124}125126#[derive(Clone)]127pub struct PointerPositionTracker {128 inner: Arc<TrackerInner>,129}130131impl std::fmt::Debug for PointerPositionTracker {132 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {133 formatter134 .debug_struct("PointerPositionTracker")135 .finish_non_exhaustive()136 }137}138139struct TrackerInner {140 latest: tokio::sync::watch::Sender<PointerState>,141 stop: Arc<AtomicBool>,142}143144impl Drop for TrackerInner {145 fn drop(&mut self) {146 self.stop.store(true, Ordering::Release);147 }148}149150impl PointerPositionTracker {151 /// Connect to the Gamescope Xwayland display inherited by the service.152 /// Failure is non-fatal: callers omit pointer-feedback capability rather153 /// than synthesizing a position that could diverge from the compositor.154 #[cfg(target_os = "linux")]155 pub fn try_initialize(156 configured_display: Option<&str>,157 pointer_surface_dimensions: PointerSurfaceDimensions,158 ) -> Result<Self> {159 let bootstrap_display = match configured_display {160 Some(display) => display.to_owned(),161 None => std::env::var("DISPLAY")162 .context("neither gamescope_pipewire.xwayland_display nor DISPLAY is configured")?,163 };164 validate_display_name(&bootstrap_display)?;165 let mut bootstrap = connect_bootstrap_display(&bootstrap_display)?;166 anyhow::ensure!(167 bootstrap.pointer_surface_dimensions == pointer_surface_dimensions,168 "bootstrap Gamescope pointer surface is {}x{}, but capture preflight resolved {}x{}",169 bootstrap.pointer_surface_dimensions.width,170 bootstrap.pointer_surface_dimensions.height,171 pointer_surface_dimensions.width,172 pointer_surface_dimensions.height173 );174 let focus_display = bootstrap175 .query_focus_display()176 .context("querying initial Gamescope mouse-focus display")?;177 let mut active = connect_active_display(&focus_display)178 .context("connecting to active Gamescope Xwayland")?;179 let initial_position = query_pointer_position(180 &active.connection,181 active.root,182 bootstrap.pointer_surface_dimensions,183 )184 .context("querying initial Gamescope Xwayland pointer position")?;185 let initial =186 available_pointer_state(initial_position, active.query_cursor_visible_bounded());187 let (latest, _) = tokio::sync::watch::channel(initial);188 let stop = Arc::new(AtomicBool::new(false));189 let thread_latest = latest.clone();190 let thread_stop = Arc::clone(&stop);191 std::thread::Builder::new()192 .name("sigil-xwayland-pointer".into())193 .spawn(move || {194 let mut bootstrap = Some(bootstrap);195 let mut active_display = focus_display;196 let mut active_connection = Some(active);197 let mut activity_visibility = PointerActivityVisibility::new(initial_position);198 let mut reconnect_backoff = ReconnectBackoff::new(Instant::now());199 while !thread_stop.load(Ordering::Acquire) {200 std::thread::sleep(POINTER_POLL_INTERVAL);201 if bootstrap.is_none() {202 let now = Instant::now();203 if !reconnect_backoff.ready(now) {204 continue;205 }206 match connect_bootstrap_display(&bootstrap_display) {207 Ok(mut reconnected) => {208 let focus_display = reconnected.query_focus_display();209 let recovered = focus_display.and_then(|focus_display| {210 let mut active = connect_active_display(&focus_display)?;211 let position = query_pointer_position(212 &active.connection,213 active.root,214 reconnected.pointer_surface_dimensions,215 )?;216 let visible = active.query_cursor_visible_bounded();217 Ok((focus_display, active, position, visible))218 });219 match recovered {220 Ok((focus_display, active, position, visible)) => {221 let pointer_surface_dimensions =222 reconnected.pointer_surface_dimensions;223 bootstrap = Some(reconnected);224 active_display = focus_display;225 active_connection = Some(active);226 activity_visibility =227 PointerActivityVisibility::new(position);228 reconnect_backoff.recovered(now);229 let state = available_pointer_state(position, visible);230 thread_latest.send_if_modified(|latest| {231 if *latest == state {232 false233 } else {234 *latest = state;235 true236 }237 });238 tracing::info!(239 display = %bootstrap_display,240 pointer_surface_width = pointer_surface_dimensions.width,241 pointer_surface_height = pointer_surface_dimensions.height,242 "Gamescope Xwayland pointer feedback reconnected"243 );244 }245 Err(error) => {246 reconnect_backoff.failed(now);247 tracing::warn!(248 display = %bootstrap_display,249 error = %error,250 "Gamescope Xwayland pointer feedback reconnect incomplete"251 );252 }253 }254 }255 Err(error) => {256 reconnect_backoff.failed(now);257 tracing::warn!(258 display = %bootstrap_display,259 error = %error,260 "Gamescope Xwayland pointer feedback reconnect failed"261 );262 }263 }264 continue;265 }266 let bootstrap_connection = bootstrap267 .as_mut()268 .expect("bootstrap connection was checked above");269 let focus_display = match bootstrap_connection.query_focus_display() {270 Ok(display) => display,271 Err(error) => {272 tracing::warn!(273 display = %bootstrap_display,274 error = %error,275 "Gamescope bootstrap Xwayland connection was lost"276 );277 bootstrap = None;278 active_connection = None;279 activity_visibility.reset();280 reconnect_backoff.failed(Instant::now());281 publish_unavailable(&thread_latest);282 continue;283 }284 };285 if focus_display != active_display || active_connection.is_none() {286 active_display = focus_display;287 active_connection = connect_active_display(&active_display).ok();288 activity_visibility.reset();289 }290 let Some(active) = active_connection.as_mut() else {291 publish_unavailable(&thread_latest);292 continue;293 };294 match query_pointer_position(295 &active.connection,296 active.root,297 bootstrap_connection.pointer_surface_dimensions,298 ) {299 Ok(position) => {300 let visible = activity_visibility.resolve(301 position,302 active.query_cursor_visible_bounded(),303 Instant::now(),304 );305 let state = available_pointer_state(position, Some(visible));306 thread_latest.send_if_modified(|latest| {307 if *latest == state {308 false309 } else {310 *latest = state;311 true312 }313 });314 }315 Err(_) => {316 active_connection = None;317 activity_visibility.reset();318 publish_unavailable(&thread_latest);319 }320 }321 }322 })323 .context("starting Xwayland pointer sampler")?;324 Ok(Self {325 inner: Arc::new(TrackerInner { latest, stop }),326 })327 }328329 #[cfg(not(target_os = "linux"))]330 pub fn try_initialize(331 configured_display: Option<&str>,332 _pointer_surface_dimensions: PointerSurfaceDimensions,333 ) -> Result<Self> {334 if let Some(display) = configured_display {335 validate_display_name(display)?;336 }337 anyhow::bail!("Xwayland pointer feedback is supported only on Linux")338 }339340 pub fn subscribe(&self) -> tokio::sync::watch::Receiver<PointerState> {341 self.inner.latest.subscribe()342 }343}344345#[cfg(target_os = "linux")]346fn connect_display(display: &str) -> Result<(x11rb::rust_connection::RustConnection, u32)> {347 use x11rb::connection::Connection;348349 validate_display_name(display)?;350 let (connection, screen_index) = x11rb::connect(Some(display))351 .with_context(|| format!("connecting to Xwayland {display}"))?;352 let root = connection353 .setup()354 .roots355 .get(screen_index)356 .with_context(|| format!("Xwayland {display} selected an invalid screen"))?357 .root;358 Ok((connection, root))359}360361#[cfg(target_os = "linux")]362struct BootstrapXwayland {363 display: String,364 connection: x11rb::rust_connection::RustConnection,365 root: u32,366 focus_atom: Option<u32>,367 focus_fallback_reported: bool,368 pointer_surface_dimensions: PointerSurfaceDimensions,369}370371#[cfg(target_os = "linux")]372fn connect_bootstrap_display(display: &str) -> Result<BootstrapXwayland> {373 use x11rb::protocol::xproto::ConnectionExt;374375 let (connection, root) =376 connect_display(display).context("connecting to bootstrap Gamescope Xwayland")?;377 let focus_atom = connection378 // SteamOS and older Gamescope builds may not publish this Bazzite-era379 // extension. Do not create the atom and mistake its existence for380 // compositor support.381 .intern_atom(true, FOCUS_DISPLAY_PROPERTY)382 .context("interning Gamescope mouse-focus property")?383 .reply()384 .context("receiving Gamescope mouse-focus atom")?385 .atom;386 let geometry = connection387 .get_geometry(root)388 .context("sending bootstrap X11 GetGeometry")?389 .reply()390 .context("receiving bootstrap X11 GetGeometry reply")?;391 let pointer_surface_dimensions = PointerSurfaceDimensions::new(geometry.width, geometry.height)392 .context("validating bootstrap Gamescope pointer surface dimensions")?;393 Ok(BootstrapXwayland {394 display: display.to_owned(),395 connection,396 root,397 focus_atom: (focus_atom != x11rb::NONE).then_some(focus_atom),398 focus_fallback_reported: false,399 pointer_surface_dimensions,400 })401}402403#[cfg(target_os = "linux")]404impl BootstrapXwayland {405 fn query_focus_display(&mut self) -> Result<String> {406 let discovered = match self.focus_atom {407 Some(focus_atom) => query_focus_display(&self.connection, self.root, focus_atom)?,408 None => None,409 };410 let (focused_display, used_fallback) = select_focus_display(&self.display, discovered);411 if !used_fallback {412 if self.focus_fallback_reported {413 tracing::info!(414 display = %self.display,415 focus_display = %focused_display,416 "Gamescope mouse-focus discovery recovered"417 );418 }419 self.focus_fallback_reported = false;420 } else if !self.focus_fallback_reported {421 tracing::warn!(422 display = %self.display,423 "Gamescope mouse-focus property is unavailable; pointer feedback is bounded to the configured Xwayland display"424 );425 self.focus_fallback_reported = true;426 }427 Ok(focused_display)428 }429}430431#[cfg(any(target_os = "linux", test))]432fn select_focus_display(433 configured_display: &str,434 discovered_display: Option<String>,435) -> (String, bool) {436 match discovered_display {437 Some(discovered_display) => (discovered_display, false),438 None => (configured_display.to_owned(), true),439 }440}441442#[cfg(target_os = "linux")]443fn publish_unavailable(latest: &tokio::sync::watch::Sender<PointerState>) {444 latest.send_if_modified(|latest| {445 if *latest == PointerState::UNAVAILABLE {446 false447 } else {448 *latest = PointerState::UNAVAILABLE;449 true450 }451 });452}453454#[cfg(target_os = "linux")]455struct ActiveXwayland {456 display: String,457 connection: x11rb::rust_connection::RustConnection,458 root: u32,459 cursor_visible_atom: Option<u32>,460 visibility_fallback_reported: bool,461}462463#[cfg(target_os = "linux")]464fn connect_active_display(display: &str) -> Result<ActiveXwayland> {465 use x11rb::protocol::xproto::ConnectionExt;466467 let (connection, root) = connect_display(display)?;468 let cursor_visible_atom = connection469 .intern_atom(true, CURSOR_VISIBLE_PROPERTY)470 .context("interning Gamescope cursor-visibility property")?471 .reply()472 .context("receiving Gamescope cursor-visibility atom")?473 .atom;474 Ok(ActiveXwayland {475 display: display.to_owned(),476 connection,477 root,478 cursor_visible_atom: (cursor_visible_atom != x11rb::NONE).then_some(cursor_visible_atom),479 visibility_fallback_reported: false,480 })481}482483#[cfg(target_os = "linux")]484impl ActiveXwayland {485 fn query_cursor_visible_bounded(&mut self) -> Option<bool> {486 match self487 .cursor_visible_atom488 .map(|atom| query_cursor_visible(&self.connection, self.root, atom))489 .transpose()490 {491 Ok(Some(visible)) => {492 if self.visibility_fallback_reported {493 tracing::info!(494 display = %self.display,495 "Gamescope cursor-visibility feedback recovered"496 );497 }498 self.visibility_fallback_reported = false;499 Some(visible)500 }501 Ok(None) => {502 if !self.visibility_fallback_reported {503 tracing::warn!(504 display = %self.display,505 "Gamescope cursor-visibility property is unavailable; visibility will follow bounded pointer activity"506 );507 self.visibility_fallback_reported = true;508 }509 None510 }511 Err(error) => {512 if !self.visibility_fallback_reported {513 tracing::warn!(514 display = %self.display,515 %error,516 "Gamescope cursor-visibility property is invalid; visibility will follow bounded pointer activity"517 );518 self.visibility_fallback_reported = true;519 }520 None521 }522 }523 }524}525526#[cfg(target_os = "linux")]527fn query_cursor_visible(528 connection: &x11rb::rust_connection::RustConnection,529 root: u32,530 cursor_visible_atom: u32,531) -> Result<bool> {532 use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};533534 let reply = connection535 .get_property(false, root, cursor_visible_atom, AtomEnum::CARDINAL, 0, 1)536 .context("sending Gamescope cursor-visibility property query")?537 .reply()538 .context("receiving Gamescope cursor-visibility property")?;539 anyhow::ensure!(540 reply.format == 32,541 "Gamescope cursor-visibility property is not CARDINAL/32"542 );543 let mut values = reply544 .value32()545 .context("Gamescope cursor-visibility property cannot be read as CARDINAL/32")?;546 let visible = values547 .next()548 .context("Gamescope cursor-visibility property is empty")?;549 anyhow::ensure!(550 values.next().is_none(),551 "Gamescope cursor-visibility property has more than one value"552 );553 Ok(visible != 0)554}555556fn validate_display_name(display: &str) -> Result<()> {557 let number = display.strip_prefix(':').unwrap_or_default();558 anyhow::ensure!(559 !number.is_empty() && number.len() <= 3 && number.bytes().all(|byte| byte.is_ascii_digit()),560 "Xwayland display must be : followed by 1 to 3 digits"561 );562 Ok(())563}564565#[cfg(target_os = "linux")]566fn query_focus_display(567 connection: &x11rb::rust_connection::RustConnection,568 root: u32,569 focus_atom: u32,570) -> Result<Option<String>> {571 use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};572573 let reply = connection574 .get_property(false, root, focus_atom, AtomEnum::CARDINAL, 0, 4)575 .context("sending Gamescope mouse-focus property query")?576 .reply()577 .context("receiving Gamescope mouse-focus property")?;578 parse_optional_focus_display(reply.format, &reply.value)579}580581#[cfg(any(target_os = "linux", test))]582fn parse_optional_focus_display(format: u8, value: &[u8]) -> Result<Option<String>> {583 if format == 0 && value.is_empty() {584 return Ok(None);585 }586 anyhow::ensure!(587 format == 32,588 "Gamescope mouse-focus property is not CARDINAL/32"589 );590 parse_focus_display(value).map(Some)591}592593#[cfg(any(target_os = "linux", test))]594fn parse_focus_display(value: &[u8]) -> Result<String> {595 let first_word = value596 .get(..4)597 .context("Gamescope mouse-focus property is shorter than one CARDINAL")?;598 let end = first_word599 .iter()600 .position(|byte| *byte == 0)601 .unwrap_or(first_word.len());602 let display = std::str::from_utf8(&first_word[..end])603 .context("Gamescope mouse-focus display is not UTF-8")?604 .to_owned();605 validate_display_name(&display)?;606 Ok(display)607}608609#[cfg(target_os = "linux")]610fn query_pointer_position(611 connection: &x11rb::rust_connection::RustConnection,612 root: u32,613 pointer_surface_dimensions: PointerSurfaceDimensions,614) -> Result<PointerPosition> {615 use x11rb::protocol::xproto::ConnectionExt;616617 let geometry = connection618 .get_geometry(root)619 .context("sending X11 GetGeometry")?620 .reply()621 .context("receiving X11 GetGeometry reply")?;622623 let reply = connection624 .query_pointer(root)625 .context("sending X11 QueryPointer")?626 .reply()627 .context("receiving X11 QueryPointer reply")?;628 anyhow::ensure!(629 reply.same_screen && reply.root == root,630 "Xwayland pointer is not on the queried active root"631 );632 map_pointer_position(633 geometry.width,634 geometry.height,635 i32::from(reply.root_x),636 i32::from(reply.root_y),637 pointer_surface_dimensions,638 )639 .context("mapping Xwayland pointer position to the captured surface")640}641642#[cfg(any(target_os = "linux", test))]643#[derive(Clone, Copy, Debug, PartialEq, Eq)]644struct PointerSurfaceRegion {645 offset_x: u16,646 offset_y: u16,647 width: u16,648 height: u16,649}650651#[cfg(any(target_os = "linux", test))]652fn pointer_surface_region(653 width: u16,654 height: u16,655 pointer_surface_dimensions: PointerSurfaceDimensions,656) -> Result<PointerSurfaceRegion> {657 pointer_surface_dimensions658 .validate()659 .context("validating captured pointer surface dimensions")?;660 anyhow::ensure!(661 width > 0 && height > 0,662 "active Xwayland root dimensions must be non-zero"663 );664665 // Gamescope centers a focused game surface while preserving its aspect666 // ratio. Reproduce that transform in the compositor's native pointer667 // coordinate space: independent axis stretching would make the overlay668 // disagree with the game whenever the active Xwayland root is letterboxed.669 let root_width = u64::from(width);670 let root_height = u64::from(height);671 let surface_width = u64::from(pointer_surface_dimensions.width);672 let surface_height = u64::from(pointer_surface_dimensions.height);673 let root_is_wider = root_width * surface_height >= root_height * surface_width;674 let (fitted_width, fitted_height) = if root_is_wider {675 (676 surface_width,677 root_height678 .checked_mul(surface_width)679 .context("pointer surface height scaling overflowed")?680 / root_width,681 )682 } else {683 (684 root_width685 .checked_mul(surface_height)686 .context("pointer surface width scaling overflowed")?687 / root_height,688 surface_height,689 )690 };691 anyhow::ensure!(692 fitted_width > 0 && fitted_height > 0,693 "active Xwayland root aspect cannot be represented on the captured pointer surface"694 );695 let fitted_width =696 u16::try_from(fitted_width).context("fitted pointer surface width exceeds u16")?;697 let fitted_height =698 u16::try_from(fitted_height).context("fitted pointer surface height exceeds u16")?;699 Ok(PointerSurfaceRegion {700 offset_x: (pointer_surface_dimensions.width - fitted_width) / 2,701 offset_y: (pointer_surface_dimensions.height - fitted_height) / 2,702 width: fitted_width,703 height: fitted_height,704 })705}706707#[cfg(any(target_os = "linux", test))]708fn map_pointer_position(709 root_width: u16,710 root_height: u16,711 root_x: i32,712 root_y: i32,713 pointer_surface_dimensions: PointerSurfaceDimensions,714) -> Result<PointerPosition> {715 let region = pointer_surface_region(root_width, root_height, pointer_surface_dimensions)?;716 let x = map_pointer_axis(root_x, root_width, region.offset_x, region.width, "x")?;717 let y = map_pointer_axis(root_y, root_height, region.offset_y, region.height, "y")?;718 PointerPosition::new(x, y).context("scaled pointer position is outside the protocol range")719}720721#[cfg(any(target_os = "linux", test))]722fn map_pointer_axis(723 coordinate: i32,724 root_extent: u16,725 surface_offset: u16,726 surface_extent: u16,727 axis: &str,728) -> Result<i32> {729 let coordinate = u64::try_from(coordinate)730 .with_context(|| format!("Xwayland returned a negative {axis} pointer coordinate"))?;731 let root_extent = u64::from(root_extent);732 let surface_extent = u64::from(surface_extent);733 anyhow::ensure!(734 coordinate < root_extent,735 "Xwayland {axis} pointer coordinate is outside the active root"736 );737 let scaled_within_region = coordinate738 .checked_mul(surface_extent)739 .context("pointer coordinate scaling overflowed")?740 / root_extent;741 let scaled = u64::from(surface_offset)742 .checked_add(scaled_within_region)743 .context("pointer surface offset overflowed")?;744 anyhow::ensure!(745 scaled_within_region < surface_extent,746 "scaled {axis} pointer coordinate is outside the captured surface"747 );748 i32::try_from(scaled).context("scaled pointer coordinate exceeds the protocol integer range")749}750751#[cfg(test)]752mod tests {753 use super::*;754755 #[test]756 #[cfg(target_os = "linux")]757 fn pointer_sampler_is_bounded_to_sixty_hertz() {758 assert!(POINTER_POLL_INTERVAL >= std::time::Duration::from_nanos(1_000_000_000 / 60));759 }760761 #[test]762 fn parses_gamescope_cardinal_string_abi_fail_closed() {763 assert_eq!(parse_optional_focus_display(0, &[]).unwrap(), None);764 assert_eq!(765 parse_optional_focus_display(32, &[b':', b'0', 0, 0]).unwrap(),766 Some(":0".to_owned())767 );768 assert!(parse_optional_focus_display(8, b":0").is_err());769 assert!(parse_optional_focus_display(0, b":0").is_err());770 assert_eq!(parse_focus_display(&[b':', b'0', 0, 0]).unwrap(), ":0");771 assert_eq!(772 parse_focus_display(&[b':', b'1', 0, 0, 78, 0, 0, 0]).unwrap(),773 ":1"774 );775 assert!(parse_focus_display(&[b'0', 0, 0, 0]).is_err());776 assert!(parse_focus_display(&[b':', b'x', 0, 0]).is_err());777 assert!(parse_focus_display(b":0").is_err());778 }779780 #[test]781 fn missing_gamescope_focus_extension_is_bounded_to_configured_display() {782 assert_eq!(select_focus_display(":0", None), (":0".to_owned(), true));783 assert_eq!(784 select_focus_display(":0", Some(":1".to_owned())),785 (":1".to_owned(), false)786 );787 }788789 #[test]790 fn xwayland_pointer_mapping_is_identity_for_equal_surfaces() {791 let expected = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();792793 assert_eq!(794 pointer_surface_region(2_560, 1_600, expected).unwrap(),795 PointerSurfaceRegion {796 offset_x: 0,797 offset_y: 0,798 width: 2_560,799 height: 1_600,800 }801 );802 assert_eq!(803 map_pointer_position(2_560, 1_600, 212, 140, expected).unwrap(),804 PointerPosition::new(212, 140).unwrap()805 );806 }807808 #[test]809 fn logical_xwayland_pointer_scales_to_native_capture_surface() {810 let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();811812 assert_eq!(813 map_pointer_position(1_280, 800, 212, 140, native).unwrap(),814 PointerPosition::new(424, 280).unwrap()815 );816 assert_eq!(817 map_pointer_position(1_280, 800, 1_279, 799, native).unwrap(),818 PointerPosition::new(2_558, 1_598).unwrap()819 );820 }821822 #[test]823 fn pointer_mapping_uses_each_focused_xwayland_root_geometry() {824 let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();825826 let logical = map_pointer_position(1_280, 800, 212, 140, native).unwrap();827 let native_root = map_pointer_position(2_560, 1_600, 424, 280, native).unwrap();828829 assert_eq!(logical, native_root);830 }831832 #[test]833 fn wide_xwayland_root_maps_into_centered_letterbox_region() {834 let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();835836 assert_eq!(837 pointer_surface_region(1_280, 720, native).unwrap(),838 PointerSurfaceRegion {839 offset_x: 0,840 offset_y: 80,841 width: 2_560,842 height: 1_440,843 }844 );845 assert_eq!(846 map_pointer_position(1_280, 720, 0, 0, native).unwrap(),847 PointerPosition::new(0, 80).unwrap()848 );849 assert_eq!(850 map_pointer_position(1_280, 720, 640, 360, native).unwrap(),851 PointerPosition::new(1_280, 800).unwrap()852 );853 assert_eq!(854 map_pointer_position(1_280, 720, 1_279, 719, native).unwrap(),855 PointerPosition::new(2_558, 1_518).unwrap()856 );857 }858859 #[test]860 fn narrow_xwayland_root_maps_into_centered_pillarbox_region() {861 let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();862863 assert_eq!(864 pointer_surface_region(1_024, 768, native).unwrap(),865 PointerSurfaceRegion {866 offset_x: 213,867 offset_y: 0,868 width: 2_133,869 height: 1_600,870 }871 );872 assert_eq!(873 map_pointer_position(1_024, 768, 0, 0, native).unwrap(),874 PointerPosition::new(213, 0).unwrap()875 );876 assert_eq!(877 map_pointer_position(1_024, 768, 512, 384, native).unwrap(),878 PointerPosition::new(1_279, 800).unwrap()879 );880 }881882 #[test]883 fn pointer_mapping_rejects_invalid_geometry_and_coordinates() {884 let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();885886 assert!(map_pointer_position(0, 800, 0, 0, native).is_err());887 assert!(888 map_pointer_position(889 u16::MAX,890 1,891 0,892 0,893 PointerSurfaceDimensions::new(64, 64).unwrap(),894 )895 .is_err()896 );897 assert!(map_pointer_position(1_280, 800, -1, 0, native).is_err());898 assert!(map_pointer_position(1_280, 800, 1_280, 0, native).is_err());899 assert!(map_pointer_position(1_280, 800, 0, 800, native).is_err());900 assert!(901 map_pointer_position(902 1_280,903 800,904 0,905 0,906 PointerSurfaceDimensions {907 width: 0,908 height: 1_600,909 },910 )911 .is_err()912 );913 }914915 #[test]916 fn pointer_mapping_is_bounded_at_maximum_supported_dimensions() {917 let maximum = PointerSurfaceDimensions::new(7_680, 4_320).unwrap();918 let mapped = map_pointer_position(65_520, 36_855, 65_519, 36_854, maximum).unwrap();919920 assert!(mapped.x >= 0 && mapped.x < i32::from(maximum.width));921 assert!(mapped.y >= 0 && mapped.y < i32::from(maximum.height));922 }923924 #[test]925 fn cursor_visibility_is_independent_and_fails_closed() {926 let position = PointerPosition::new(1_280, 800).unwrap();927928 assert_eq!(929 available_pointer_state(position, Some(false)),930 PointerState {931 position: Some(position),932 visible: false,933 }934 );935 assert_eq!(936 available_pointer_state(position, None),937 PointerState {938 position: Some(position),939 visible: false,940 }941 );942 assert!(available_pointer_state(position, Some(true)).visible);943 }944945 #[test]946 fn recent_authoritative_motion_recovers_gamescope_virtual_cursor_visibility() {947 let origin = PointerPosition::new(1_280, 800).unwrap();948 let moved = PointerPosition::new(1_312, 816).unwrap();949 let started_at = Instant::now();950 let mut visibility = PointerActivityVisibility::new(origin);951952 assert!(!visibility.resolve(origin, Some(false), started_at));953 assert!(visibility.resolve(moved, Some(false), started_at));954 assert!(visibility.resolve(955 moved,956 Some(false),957 started_at + POINTER_ACTIVITY_VISIBLE_TIMEOUT - Duration::from_millis(1),958 ));959 assert!(!visibility.resolve(960 moved,961 Some(false),962 started_at + POINTER_ACTIVITY_VISIBLE_TIMEOUT,963 ));964 }965966 #[test]967 fn compositor_visibility_wins_and_reconnect_does_not_inherit_motion() {968 let origin = PointerPosition::new(1_280, 800).unwrap();969 let moved = PointerPosition::new(1_312, 816).unwrap();970 let sampled_at = Instant::now();971 let mut visibility = PointerActivityVisibility::new(origin);972973 assert!(visibility.resolve(origin, Some(true), sampled_at));974 assert!(visibility.resolve(moved, None, sampled_at));975 visibility.reset();976 assert!(!visibility.resolve(moved, None, sampled_at));977 }978979 #[test]980 fn bootstrap_reconnect_backoff_is_bounded_and_resets_after_recovery() {981 let started_at = Instant::now();982 let mut backoff = ReconnectBackoff::new(started_at);983984 assert!(backoff.ready(started_at));985 backoff.failed(started_at);986 assert!(!backoff.ready(started_at));987 assert!(backoff.ready(started_at + BOOTSTRAP_RECONNECT_INITIAL));988989 let mut now = started_at + BOOTSTRAP_RECONNECT_INITIAL;990 for _ in 0..16 {991 backoff.failed(now);992 assert!(backoff.delay <= BOOTSTRAP_RECONNECT_MAXIMUM);993 now += BOOTSTRAP_RECONNECT_MAXIMUM;994 }995 assert_eq!(backoff.delay, BOOTSTRAP_RECONNECT_MAXIMUM);996997 backoff.recovered(now);998 assert!(backoff.ready(now));999 assert_eq!(backoff.delay, BOOTSTRAP_RECONNECT_INITIAL);1000 }1001}