diff --git a/input-capture/Cargo.toml b/input-capture/Cargo.toml index 9b8d59859..bcdeb3246 100644 --- a/input-capture/Cargo.toml +++ b/input-capture/Cargo.toml @@ -58,6 +58,7 @@ bitflags = "2.6.0" [target.'cfg(windows)'.dependencies] windows = { version = "0.61.2", features = [ "Win32_System_LibraryLoader", + "Win32_System_RemoteDesktop", "Win32_System_Threading", "Win32_Foundation", "Win32_Graphics", diff --git a/input-capture/src/dummy.rs b/input-capture/src/dummy.rs index 3a2a734e1..b2296d9dd 100644 --- a/input-capture/src/dummy.rs +++ b/input-capture/src/dummy.rs @@ -42,7 +42,7 @@ impl Capture for DummyInputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { Ok(()) } @@ -62,7 +62,7 @@ impl Stream for DummyInputCapture { let event = match self.start { None => { self.start.replace(current); - CaptureEvent::Begin + CaptureEvent::Begin { cursor: None } } Some(start) => { let elapsed = start.elapsed(); diff --git a/input-capture/src/layer_shell.rs b/input-capture/src/layer_shell.rs index 698c5849c..68436b392 100644 --- a/input-capture/src/layer_shell.rs +++ b/input-capture/src/layer_shell.rs @@ -149,6 +149,13 @@ struct Window { surface: WlSurface, layer_surface: ZwlrLayerSurfaceV1, pos: Position, + /// Output's top-left corner in compositor coordinate space — + /// used together with `wl_pointer::Enter`'s surface-local coords + /// to recover the host screen-space cursor position at the moment + /// of crossing, so we can populate `CaptureEvent::Begin { cursor }` + /// for cross-axis preservation. + output_pos: (i32, i32), + output_size: (i32, i32), } impl Window { @@ -157,6 +164,7 @@ impl Window { qh: &QueueHandle, output: &WlOutput, pos: Position, + output_pos: (i32, i32), size: (i32, i32), ) -> Window { log::debug!("creating window output: {output:?}, size: {size:?}"); @@ -208,6 +216,8 @@ impl Window { buffer, surface, layer_surface, + output_pos, + output_size: size, } } } @@ -221,6 +231,22 @@ impl Drop for Window { } } +/// Translate `wl_pointer.enter` surface-local coords into the host's +/// compositor coordinate space, using the layer-surface's anchor edge +/// and the output it's attached to. Layer surfaces here are 1 px on +/// the on-axis dimension and span the cross-axis, so the surface-local +/// cross-axis coord is the screen offset directly. +fn surface_to_screen(window: &Window, surface_x: f64, surface_y: f64) -> (i32, i32) { + let (ox, oy) = window.output_pos; + let (ow, oh) = window.output_size; + match window.pos { + Position::Left => (ox, oy + surface_y as i32), + Position::Right => (ox + ow.saturating_sub(1), oy + surface_y as i32), + Position::Top => (ox + surface_x as i32, oy), + Position::Bottom => (ox + surface_x as i32, oy + oh.saturating_sub(1)), + } +} + fn get_edges(outputs: &[Output], pos: Position) -> Vec<(Output, i32)> { outputs .iter() @@ -525,7 +551,8 @@ impl State { ); outputs.iter().for_each(|o| { if let Some(info) = o.info.as_ref() { - let window = Window::new(self, &self.qh, &o.wl_output, pos, info.size); + let window = + Window::new(self, &self.qh, &o.wl_output, pos, info.position, info.size); let window = Arc::new(window); self.active_windows.push(window); } @@ -628,7 +655,7 @@ impl Capture for LayerShellInputCapture { Ok(inner.flush_events()?) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { log::debug!("releasing pointer"); let inner = self.0.get_mut(); inner.state.ungrab(); @@ -638,6 +665,28 @@ impl Capture for LayerShellInputCapture { async fn terminate(&mut self) -> Result<(), CaptureError> { Ok(()) } + + fn display_bounds(&self) -> Option<(u32, u32)> { + // Union of every active output's rectangle in compositor + // coords. Mirrors the macOS impl so MotionAbsolute scaling + // stays consistent: cursor coords reported in this same + // space normalize cleanly against the returned dimensions. + let outputs = &self.0.get_ref().state.outputs; + let mut xmin = i32::MAX; + let mut ymin = i32::MAX; + let mut xmax = i32::MIN; + let mut ymax = i32::MIN; + for info in outputs.iter().filter_map(|o| o.info.as_ref()) { + xmin = xmin.min(info.position.0); + ymin = ymin.min(info.position.1); + xmax = xmax.max(info.position.0 + info.size.0); + ymax = ymax.max(info.position.1 + info.size.1); + } + if xmax <= xmin || ymax <= ymin { + return None; + } + Some(((xmax - xmin) as u32, (ymax - ymin) as u32)) + } } impl Stream for LayerShellInputCapture { @@ -735,25 +784,26 @@ impl Dispatch for State { wl_pointer::Event::Enter { serial, surface, - surface_x: _, - surface_y: _, + surface_x, + surface_y, } => { - // get client corresponding to the focused surface - { - if let Some(window) = app.active_windows.iter().find(|w| w.surface == surface) { - app.focused = Some(window.clone()); - app.grab(&surface, pointer, serial, qh); - } else { - return; - } - } - let pos = app + let Some(window) = app .active_windows .iter() .find(|w| w.surface == surface) - .map(|w| w.pos) - .unwrap(); - app.pending_events.push_back((pos, CaptureEvent::Begin)); + .cloned() + else { + return; + }; + app.focused = Some(window.clone()); + app.grab(&surface, pointer, serial, qh); + let cursor = surface_to_screen(&window, surface_x, surface_y); + app.pending_events.push_back(( + window.pos, + CaptureEvent::Begin { + cursor: Some(cursor), + }, + )); } wl_pointer::Event::Leave { .. } => { /* There are rare cases, where when a window is opened in diff --git a/input-capture/src/lib.rs b/input-capture/src/lib.rs index b1ef6c0be..b2c9eb81d 100644 --- a/input-capture/src/lib.rs +++ b/input-capture/src/lib.rs @@ -1,15 +1,19 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, fmt::Display, + future::Future, mem::swap, + pin::Pin, task::{Poll, ready}, + time::Duration, }; use async_trait::async_trait; use futures::StreamExt; use futures_core::Stream; +use tokio::time::Sleep; -use input_event::{Event, KeyboardEvent, scancode}; +use input_event::{Event, KeyboardEvent, PointerEvent, scancode}; pub use error::{CaptureCreationError, CaptureError, InputCaptureError}; @@ -37,17 +41,35 @@ pub type CaptureHandle = u64; #[derive(Copy, Clone, Debug, PartialEq)] pub enum CaptureEvent { - /// capture on this capture handle is now active - Begin, + /// Capture on this handle is now active. `cursor`, when present, + /// is the host's screen-space cursor position (in pixels) at the + /// instant of the edge crossing — the capture loop normalizes it + /// against the host's display bounds and forwards it to the peer + /// as a [`ProtoEvent::CursorPos`] so the guest's cursor lands at + /// the visually-corresponding point on its own screen. Backends + /// that can't report cursor position emit `None`; the peer's + /// cursor stays where it was on remote-takeover (no forced + /// midpoint warp — that masquerades as a mid-screen crossing on + /// fast re-crosses). + Begin { cursor: Option<(i32, i32)> }, /// input event coming from capture handle Input(Event), + /// the capture wrapper detected sustained back-toward-host motion + /// past the configured threshold (the user has pinned the cursor + /// at the host-adjacent edge of the guest and kept pushing). The + /// capture loop should treat this like a release-bind chord. + AutoRelease, } impl Display for CaptureEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - CaptureEvent::Begin => write!(f, "begin capture"), + CaptureEvent::Begin { cursor: None } => write!(f, "begin capture"), + CaptureEvent::Begin { + cursor: Some((x, y)), + } => write!(f, "begin capture @ ({x}, {y})"), CaptureEvent::Input(e) => write!(f, "{e}"), + CaptureEvent::AutoRelease => write!(f, "auto-release"), } } } @@ -127,6 +149,86 @@ pub struct InputCapture { id_map: HashMap, /// pending events pending: VecDeque<(CaptureHandle, CaptureEvent)>, + /// pixel threshold for the cross-platform auto-release-on-wall- + /// press fallback. 0 disables. See `track_wall_press`. + release_threshold_px: u32, + /// position the cursor is currently captured into, if any. Tracks + /// `Begin`/release transitions so the wall-press accumulator + /// resets correctly across capture sessions. + capture_pos: Option, + /// Modeled cursor position on the guest along the entry axis, + /// relative to the host-adjacent edge. 0 = at the entry edge, + /// growing values = further into the guest. Clamped at 0 from + /// below; clamped at the cached peer extent from above when + /// available, otherwise unbounded (degraded fallback). + virtual_pos: f64, + /// Pixels of back-toward-host motion that the modeled cursor + /// could not absorb (proposed virtual_pos < 0). Resets whenever + /// the cursor is back in the interior or moving deeper. + wall_pressure: f64, + /// Modeled guest cursor position in the guest's screen space, + /// updated by accumulating Motion deltas while captured. Seeded + /// on `Begin` from the cross-axis warp target (if peer bounds + /// are known) or the entry-edge midpoint otherwise — i.e. wherever + /// the guest's cursor visually lands at Enter. Read on release + /// to compute a host-side warp so the local cursor reappears at + /// the matching point on the host's screen instead of jumping + /// back to where capture started. + virtual_cursor: Option<(f64, f64)>, + /// Host-coord cursor at the moment of `Begin`, retained until + /// `peer_bounds` arrives so we can retroactively seed + /// `virtual_cursor` once the round-trip completes. Without this, + /// a `Begin` that fires before the peer's `Bounds` reply leaves + /// `virtual_cursor` stuck at `None` for the rest of the session + /// — the wall-press accumulator skips updates and the + /// release-time warp falls back to the original crossing + /// y-value instead of where the cursor visually was on the peer. + pending_begin_cursor: Option<(i32, i32)>, + /// Motion deltas that arrived while `virtual_cursor` was still + /// `None` (between `Begin` and the late-arriving + /// `set_peer_bounds`). Drained into the freshly-seeded + /// `virtual_cursor` when the bootstrap completes so deltas + /// during the round-trip aren't lost. + pending_motion: (f64, f64), + /// Per-position cache of peer display geometry. Populated when + /// the peer responds with a `ProtoEvent::Bounds` event after + /// Ack. Used as the upper clamp for `virtual_pos` so that + /// pushing past the guest's actual far edge doesn't make the + /// model run away. Only the entry-axis dimension is consulted. + peer_bounds: HashMap, + /// True when wall_pressure has crossed `release_threshold_px` and + /// `wall_press_timer` has been armed but not yet either elapsed + /// or been cancelled. Cleared when the peer's handover Leave + /// arrives (which routes through `release_no_host_warp` → + /// `reset_wall_press_state`) or when the cursor moves back into + /// the interior. The wall-press auto-release fires only after + /// `wall_press_deadline` elapses without this being cleared — + /// turning the historically race-y "wall-press vs peer-Leave" + /// into an explicit fallback that only kicks in when the peer + /// can't deliver a Leave (lock screen, restricted DE, dead peer). + wall_press_pending: bool, + /// Window after the threshold is crossed during which a peer + /// Leave can cancel the deferred AutoRelease. Sized so a + /// healthy LAN round-trip beats it comfortably. + wall_press_deadline: Duration, + /// Timer driving the deferred fire. Reset to deadline-from-now + /// on first threshold crossing; polled in `poll_next` so the + /// fire happens even when no further backend events arrive + /// (the user pinned the cursor against the wall and stopped). + wall_press_timer: Pin>, +} + +/// Project a motion delta onto the entry axis. Positive return = +/// "into guest", so virtual_pos increases as the user pushes deeper. +fn entry_axis_delta(position: Position, dx: f64, dy: f64) -> f64 { + match position { + // Position::Left = guest is to the LEFT of host. User entered + // by moving left (-dx). Convention: positive = into guest. + Position::Left => -dx, + Position::Right => dx, + Position::Top => -dy, + Position::Bottom => dy, + } } impl InputCapture { @@ -167,8 +269,396 @@ impl InputCapture { /// release mouse pub async fn release(&mut self) -> Result<(), CaptureError> { + // Compute the host-side warp target before resetting the + // wall-press / virtual_cursor state — once those are cleared + // we lose the data needed to figure out where the guest's + // cursor visually was. + let warp_target = self + .capture_pos + .and_then(|pos| self.host_warp_target_on_release(pos)); + log::info!( + "[release-warp] capture_pos={:?} virtual_cursor={:?} peer_bounds={:?} display_bounds={:?} → warp_target={warp_target:?}", + self.capture_pos, + self.virtual_cursor, + self.capture_pos + .and_then(|p| self.peer_bounds.get(&p).copied()), + self.capture.display_bounds(), + ); self.pressed_keys.clear(); - self.capture.release().await + self.reset_wall_press_state(); + self.capture.release(warp_target).await + } + + /// Release without applying a host-side cursor warp. Used when + /// the remote peer is taking over (it just sent us Enter + + /// CursorPos): the proportional warp from CursorPos is the + /// authoritative final position for our shared cursor, and the + /// stale `virtual_cursor`-derived warp would race against it + /// and frequently win — clobbering the proportional landing + /// with whatever position Linux *thought* the peer's cursor was + /// at before the user moved it. + pub async fn release_no_host_warp(&mut self) -> Result<(), CaptureError> { + log::info!( + "[release-warp] handover release: capture_pos={:?} — skipping host warp, peer's CursorPos is authoritative", + self.capture_pos, + ); + self.pressed_keys.clear(); + self.reset_wall_press_state(); + self.capture.release(None).await + } + + /// Configure the wall-press auto-release pixel threshold. + /// 0 disables. Effective immediately for the next motion event; + /// no need to recreate the backend. + pub fn set_release_threshold(&mut self, threshold: u32) { + self.release_threshold_px = threshold; + } + + /// Cache the peer's display geometry for a position. Used by + /// the wall-press tracker as the upper bound for `virtual_pos` + /// so the model can't run away when the user pushes past the + /// peer's actual far edge. + /// + /// If `Begin` fired before this arrived (the round-trip + /// bootstrap case — `Bounds` is sent in response to `Enter`, + /// which is sent by the host AFTER `Begin` fires), seed + /// `virtual_cursor` retroactively so the wall-press / release + /// machinery has a baseline to track from. Drains any motion + /// that piled up in `pending_motion` so deltas during the + /// round-trip aren't lost. + pub fn set_peer_bounds(&mut self, pos: Position, width: u32, height: u32) { + log::debug!("peer at {pos} reports bounds {width}x{height}"); + self.peer_bounds.insert(pos, (width, height)); + + if self.virtual_cursor.is_none() + && self.capture_pos == Some(pos) + && self.pending_begin_cursor.is_some() + { + let begin_cursor = self.pending_begin_cursor; + let seeded = self.initial_virtual_cursor(pos, begin_cursor); + if let Some((sx, sy)) = seeded { + let (mx, my) = self.pending_motion; + let peer_w = width as f64; + let peer_h = height as f64; + self.virtual_cursor = + Some(((sx + mx).clamp(0.0, peer_w), (sy + my).clamp(0.0, peer_h))); + self.pending_motion = (0.0, 0.0); + log::info!( + "[bootstrap] seeded virtual_cursor={:?} after late peer_bounds at {pos} (drained pending_motion=({mx:.1}, {my:.1}))", + self.virtual_cursor + ); + } + } + } + + /// Forget the cached peer geometry for a position. Called when + /// the corresponding capture is destroyed so re-adding the same + /// peer later (potentially with new geometry) starts fresh. + pub fn clear_peer_bounds(&mut self, pos: Position) { + self.peer_bounds.remove(&pos); + } + + /// Host's own display geometry — width and height in pixels of + /// the union of all displays. Returns `None` when the active + /// backend can't query its own bounds (e.g. xdg-desktop-portal, + /// dummy). Used by `host_normalized_cursor` to compute the + /// [`ProtoEvent::CursorPos`] fraction the guest scales against + /// its own bounds on Enter. + pub fn display_bounds(&self) -> Option<(u32, u32)> { + self.capture.display_bounds() + } + + /// Top-left corner of the host's display union in pointer-event + /// coordinate space. See `Capture::display_origin` for why this + /// matters on multi-monitor macOS hosts. + fn display_origin(&self) -> (i32, i32) { + self.capture.display_origin() + } + + /// Host's screen-space cursor position normalized to the host's + /// own display bounds (each axis in 0..1, clamped). Returns + /// `None` when the active backend can't report its own bounds. + /// Used for the self-sufficient `ProtoEvent::CursorPos` event + /// (the receiver scales the normalized fraction against its + /// own bounds and pins the entry axis to the matching edge), so + /// the first crossing isn't blocked by the bootstrap problem + /// `peer_warp_target` has — that variant requires a prior + /// `Bounds` round-trip from the peer, which can't have happened + /// yet on the very first Enter. + pub fn host_normalized_cursor(&self, cursor: (i32, i32)) -> Option<(f32, f32)> { + let (host_w, host_h) = self.display_bounds()?; + if host_w == 0 || host_h == 0 { + return None; + } + let (origin_x, origin_y) = self.display_origin(); + let (cx, cy) = cursor; + // Subtract the union origin before normalizing so that + // points on a non-origin display (e.g. a macOS external + // monitor positioned to the left of the primary, where + // cursor x is negative) map correctly. Without this, the + // clamp masks every off-primary point as the screen edge. + let nx = ((cx - origin_x) as f32 / host_w as f32).clamp(0.0, 1.0); + let ny = ((cy - origin_y) as f32 / host_h as f32).clamp(0.0, 1.0); + Some((nx, ny)) + } + + /// Cursor warp target on the peer for a transition at `pos`, + /// given the host's screen-space cursor position at the moment + /// of crossing. Returns `None` when either the host's own + /// `display_bounds` or the cached peer geometry is unavailable — + /// in that case there's no warp target to compute and the peer's + /// cursor stays wherever the most recent `CursorPos` (or, if none + /// arrived this session, where it was) put it. + /// + /// Coordinates returned are pixels in the peer's screen space: + /// the cross-axis is preserved as a normalized fraction of the + /// host screen (so a host_y near the top maps to a peer_y near + /// the top regardless of resolution mismatch), the on-axis is + /// pinned to the peer's far edge for the entering side. + pub fn peer_warp_target(&self, pos: Position, cursor: (i32, i32)) -> Option<(i32, i32)> { + let (host_w, host_h) = self.display_bounds()?; + let &(peer_w, peer_h) = self.peer_bounds.get(&pos)?; + let (origin_x, origin_y) = self.display_origin(); + let (cx, cy) = cursor; + // Subtract the union origin before normalizing — same + // rationale as in host_normalized_cursor. + let nx = ((cx - origin_x) as f64 / host_w as f64).clamp(0.0, 1.0); + let ny = ((cy - origin_y) as f64 / host_h as f64).clamp(0.0, 1.0); + let peer_w_i = peer_w as i32; + let peer_h_i = peer_h as i32; + let target = match pos { + // Peer to our Left → cursor exits on left, enters peer on right + Position::Left => (peer_w_i.saturating_sub(1), (ny * peer_h as f64) as i32), + // Peer to our Right → cursor enters peer on left + Position::Right => (0, (ny * peer_h as f64) as i32), + // Peer above → cursor enters peer on bottom + Position::Top => ((nx * peer_w as f64) as i32, peer_h_i.saturating_sub(1)), + // Peer below → cursor enters peer on top + Position::Bottom => ((nx * peer_w as f64) as i32, 0), + }; + Some(target) + } + + /// Returns the upper-clamp value (along the entry axis) for the + /// given position, or `f64::INFINITY` if the peer hasn't reported + /// bounds yet. + fn peer_extent(&self, pos: Position) -> f64 { + let Some(&(w, h)) = self.peer_bounds.get(&pos) else { + return f64::INFINITY; + }; + match pos { + Position::Left | Position::Right => f64::from(w), + Position::Top | Position::Bottom => f64::from(h), + } + } + + fn reset_wall_press_state(&mut self) { + self.capture_pos = None; + self.virtual_pos = 0.0; + self.wall_pressure = 0.0; + self.virtual_cursor = None; + self.pending_begin_cursor = None; + self.pending_motion = (0.0, 0.0); + // Cancel any deferred AutoRelease — release() / handover have + // taken responsibility for the transition. + self.wall_press_pending = false; + } + + /// Initial guest-space cursor position for a freshly-started + /// capture. Mirrors what the guest's emulation will visibly do on + /// the corresponding `Enter`: the `CursorPos` proportional warp + /// target if the host can compute one (capture backend reports + /// cursor), otherwise the entry-edge midpoint as a fallback for + /// the wall-press model's starting position. + fn initial_virtual_cursor( + &self, + pos: Position, + host_cursor: Option<(i32, i32)>, + ) -> Option<(f64, f64)> { + if let Some(host_cursor) = host_cursor { + if let Some((x, y)) = self.peer_warp_target(pos, host_cursor) { + return Some((x as f64, y as f64)); + } + } + let &(peer_w, peer_h) = self.peer_bounds.get(&pos)?; + let pw = peer_w as f64; + let ph = peer_h as f64; + Some(match pos { + Position::Left => (0.0, ph / 2.0), + Position::Right => ((pw - 1.0).max(0.0), ph / 2.0), + Position::Top => (pw / 2.0, 0.0), + Position::Bottom => (pw / 2.0, (ph - 1.0).max(0.0)), + }) + } + + /// Where on the host's own screen the cursor should land when + /// capture is released, given the modeled guest cursor position + /// at the moment of release. Symmetric inverse of + /// `peer_warp_target`: cross-axis is preserved as a normalized + /// fraction of the peer's screen, on-axis is pinned to the + /// host's far edge for the side the guest is on so the cursor + /// reappears at the boundary it just crossed back through. + fn host_warp_target_on_release(&self, pos: Position) -> Option<(i32, i32)> { + let (gx, gy) = self.virtual_cursor?; + let &(peer_w, peer_h) = self.peer_bounds.get(&pos)?; + let (host_w, host_h) = self.capture.display_bounds()?; + if peer_w == 0 || peer_h == 0 || host_w == 0 || host_h == 0 { + return None; + } + let (origin_x, origin_y) = self.display_origin(); + let nx = (gx / peer_w as f64).clamp(0.0, 1.0); + let ny = (gy / peer_h as f64).clamp(0.0, 1.0); + let host_w_i = host_w as i32; + let host_h_i = host_h as i32; + // Add the union origin back so the result is in pointer-event + // coordinate space (which is what `CGDisplay::warp_mouse_cursor_position` + // and friends consume), not "0..host_w" of the union rectangle. + // Matters on macOS hosts whose primary isn't anchored at (0, 0) + // — `display_bounds` returns just the size of the union, so the + // origin needs to be reapplied to recover absolute coords. + Some(match pos { + // Peer to our Left → cursor returns through host's left edge + Position::Left => (origin_x, origin_y + (ny * host_h as f64) as i32), + // Peer to our Right → cursor returns through host's right edge + Position::Right => ( + origin_x + host_w_i.saturating_sub(1), + origin_y + (ny * host_h as f64) as i32, + ), + // Peer above → cursor returns through host's top edge + Position::Top => (origin_x + (nx * host_w as f64) as i32, origin_y), + // Peer below → cursor returns through host's bottom edge + Position::Bottom => ( + origin_x + (nx * host_w as f64) as i32, + origin_y + host_h_i.saturating_sub(1), + ), + }) + } + + /// Update the wall-press accumulator from one event coming up + /// from the backend. Sets `wall_press_pending` (and arms the + /// timer) when the threshold is first crossed; the actual + /// `AutoRelease` synthesis happens in `poll_next` once the + /// deadline elapses without a peer Leave clearing the flag. + fn track_wall_press(&mut self, pos: Position, event: &CaptureEvent) { + match event { + CaptureEvent::Begin { cursor } => { + self.capture_pos = Some(pos); + self.virtual_pos = 0.0; + self.wall_pressure = 0.0; + self.virtual_cursor = self.initial_virtual_cursor(pos, *cursor); + // Stash the host-coord cursor so set_peer_bounds can + // retroactively seed virtual_cursor if peer_bounds + // arrives after Begin. + self.pending_begin_cursor = *cursor; + self.pending_motion = (0.0, 0.0); + log::info!( + "[wp-begin] pos={pos} cursor={cursor:?} peer_bounds={:?} virtual_cursor={:?}", + self.peer_bounds.get(&pos).copied(), + self.virtual_cursor, + ); + } + CaptureEvent::AutoRelease => { + // Don't reset virtual_cursor here — release() needs it + // to compute the host-side warp target. The wrapper's + // release() resets state after consuming it. + } + CaptureEvent::Input(Event::Pointer(PointerEvent::Motion { dx, dy, .. })) => { + let Some(active_pos) = self.capture_pos else { + return; + }; + if active_pos != pos { + return; + } + + // Track guest-space cursor for the on-release warp + // back to the host. Clamped to the peer's bounds so + // the model doesn't drift past the guest's screen + // when the user pushes obliviously. + match ( + self.virtual_cursor.as_mut(), + self.peer_bounds.get(&active_pos), + ) { + (Some(vc), Some(&(peer_w, peer_h))) => { + vc.0 = (vc.0 + *dx).clamp(0.0, peer_w as f64); + vc.1 = (vc.1 + *dy).clamp(0.0, peer_h as f64); + } + // virtual_cursor not yet seeded (peer_bounds was + // None at Begin time and the round-trip hasn't + // completed yet). Buffer the deltas so they can + // be applied retroactively in set_peer_bounds + // once the bootstrap finishes — otherwise the + // motion that happened during the round-trip is + // silently lost and the release-time warp picks + // the wrong y. + (None, _) => { + self.pending_motion.0 += *dx; + self.pending_motion.1 += *dy; + log::debug!( + "[wp-motion] deferred dx={dx:.1} dy={dy:.1} (peer_bounds for {active_pos}: {:?})", + self.peer_bounds.get(&active_pos).copied(), + ); + } + _ => {} + } + + if self.release_threshold_px == 0 { + return; + } + + let delta = entry_axis_delta(active_pos, *dx, *dy); + let proposed = self.virtual_pos + delta; + let upper = self.peer_extent(active_pos); + // Clamp at 0 from below (host-adjacent edge — wall + // pressure accumulates here) and at the peer's + // entry-axis extent from above when known. The upper + // clamp prevents the model from running away if the + // user obliviously pushes their physical mouse past + // the guest's actual far edge. When the peer hasn't + // reported bounds yet (older peer, or pre-Ack + // window), `upper` is INFINITY and we fall back to + // the heuristic behavior. + self.virtual_pos = proposed.clamp(0.0, upper); + + if proposed < 0.0 { + // Motion overshot the host-adjacent edge — + // accumulate the unabsorbed amount as wall + // pressure. + self.wall_pressure += -proposed; + } else { + // Cursor moved into the interior or further in; + // reset so a brief bump against the wall followed + // by motion deeper into the guest doesn't combine + // with a later wall-press to fire spuriously. + self.wall_pressure = 0.0; + if std::mem::take(&mut self.wall_press_pending) { + log::info!( + "wall-press deferred AutoRelease cancelled (cursor moved away from entry edge)" + ); + } + } + + if self.wall_pressure >= f64::from(self.release_threshold_px) + && !self.wall_press_pending + { + self.wall_press_pending = true; + self.wall_press_timer + .as_mut() + .reset(tokio::time::Instant::now() + self.wall_press_deadline); + log::info!( + "wall-press threshold reached ({:.0}px past entry edge, {}px threshold) — \ + deferring AutoRelease for {}ms pending peer Leave", + self.wall_pressure, + self.release_threshold_px, + self.wall_press_deadline.as_millis(), + ); + } + // Fire is now driven by the timer in `poll_next`, not + // directly from this event — keeps the behavior gated + // on "peer didn't claim handover in time" instead of + // racing the peer's Leave. + } + _ => {} + } } /// Drain and return every key the capture has forwarded as @@ -198,6 +688,17 @@ impl InputCapture { pending: Default::default(), position_map: Default::default(), pressed_keys: HashSet::new(), + release_threshold_px: 0, + capture_pos: None, + virtual_pos: 0.0, + wall_pressure: 0.0, + virtual_cursor: None, + pending_begin_cursor: None, + pending_motion: (0.0, 0.0), + peer_bounds: HashMap::new(), + wall_press_pending: false, + wall_press_deadline: Duration::from_millis(150), + wall_press_timer: Box::pin(tokio::time::sleep(Duration::from_secs(0))), }) } @@ -228,6 +729,33 @@ impl Stream for InputCapture { return Poll::Ready(Some(Ok(e))); } + // Deferred wall-press fallback. If the threshold was crossed + // and the deadline elapsed without a peer Leave clearing + // `wall_press_pending` (release_no_host_warp → + // reset_wall_press_state), synthesize AutoRelease for every + // capture handle at the active position. Polled before the + // backend so a fire still happens when the user pinned the + // cursor against the wall and stopped moving (no further + // backend events, but the deadline still has to elapse). + if self.wall_press_pending && self.wall_press_timer.as_mut().poll(cx).is_ready() { + self.wall_press_pending = false; + log::info!( + "wall-press deadline elapsed ({}ms) — firing AutoRelease (no peer Leave; \ + assuming peer-side capture is unavailable, e.g. lock screen)", + self.wall_press_deadline.as_millis(), + ); + if let Some(pos) = self.capture_pos { + if let Some(ids) = self.position_map.get(&pos).cloned() { + for id in ids { + self.pending.push_back((id, CaptureEvent::AutoRelease)); + } + } + } + if let Some(e) = self.pending.pop_front() { + return Poll::Ready(Some(Ok(e))); + } + } + // ready let event = ready!(self.capture.poll_next_unpin(cx)); @@ -248,6 +776,13 @@ impl Stream for InputCapture { self.update_pressed_keys(key, state); } + // wall-press auto-release tracking. Runs against every event + // before routing so a single global accumulator stays consistent + // regardless of how many handles exist at this position. The + // fire itself is deferred and driven by `wall_press_timer` + // above so the peer's Leave can cancel it. + self.track_wall_press(pos, &event); + let len = self .position_map .get(&pos) @@ -256,10 +791,10 @@ impl Stream for InputCapture { match len { 0 => Poll::Pending, - 1 => Poll::Ready(Some(Ok(( - self.position_map.get(&pos).expect("no id")[0], - event, - )))), + 1 => { + let id = self.position_map.get(&pos).expect("no id")[0]; + Poll::Ready(Some(Ok((id, event)))) + } _ => { let mut position_map = HashMap::new(); swap(&mut self.position_map, &mut position_map); @@ -284,11 +819,39 @@ trait Capture: Stream> + U /// destroy the client with the given id, if it exists async fn destroy(&mut self, pos: Position) -> Result<(), CaptureError>; - /// release mouse - async fn release(&mut self) -> Result<(), CaptureError>; + /// release mouse. `warp_target`, when present, is a screen-space + /// pixel point on the host's own display where the local cursor + /// should be placed before becoming visible again — used to + /// preserve cross-axis continuity when capture ends so the cursor + /// reappears next to where it visually was on the guest, not at + /// the spot where capture started. Backends that don't hide the + /// system cursor or can't warp it can ignore the parameter. + async fn release(&mut self, warp_target: Option<(i32, i32)>) -> Result<(), CaptureError>; /// destroy the input capture async fn terminate(&mut self) -> Result<(), CaptureError>; + + /// Host's own display geometry. Default implementation returns + /// `None`; backends that can query their own dimensions override + /// (currently macOS via CGDisplay; others may add this later). + fn display_bounds(&self) -> Option<(u32, u32)> { + None + } + + /// Top-left corner of the union of all displays in the host's + /// global pointer-coordinate system. Defaults to (0, 0) — fine + /// for any backend whose primary display is the origin (Windows, + /// most X11/Wayland setups). Returns the actual `(xmin, ymin)` + /// on macOS, where the global coordinate system is anchored at + /// the primary's top-left and a left-attached external display + /// occupies negative x. Used by `host_normalized_cursor` and + /// `peer_warp_target` to correctly normalize cursor positions + /// outside the primary display — without this, the + /// `clamp(0.0, 1.0)` in those helpers silently maps every point + /// on a non-origin display to the screen edge. + fn display_origin(&self) -> (i32, i32) { + (0, 0) + } } async fn create_backend( diff --git a/input-capture/src/libei.rs b/input-capture/src/libei.rs index fa168957c..15b393840 100644 --- a/input-capture/src/libei.rs +++ b/input-capture/src/libei.rs @@ -417,7 +417,10 @@ async fn do_capture_session( current_pos.replace(Some(pos)); // client entered => send event - event_tx.send((pos, CaptureEvent::Begin)).await.expect("no channel"); + event_tx + .send((pos, CaptureEvent::Begin { cursor: None })) + .await + .expect("no channel"); tokio::select! { _ = notify_release.notified() => { /* capture release */ @@ -589,7 +592,7 @@ impl LanMouseInputCapture for LibeiInputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { self.notify_release.notify_waiters(); Ok(()) } diff --git a/input-capture/src/macos.rs b/input-capture/src/macos.rs index dc941b28c..915346717 100644 --- a/input-capture/src/macos.rs +++ b/input-capture/src/macos.rs @@ -9,7 +9,7 @@ use core_foundation::{ string::{CFStringCreateWithCString, CFStringRef, kCFStringEncodingUTF8}, }; use core_graphics::{ - base::{CGError, kCGErrorSuccess}, + base::{CGError, CGFloat, kCGErrorSuccess}, display::{CGDisplay, CGPoint}, event::{ CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, @@ -62,7 +62,14 @@ struct InputCaptureState { #[derive(Debug)] enum ProducerEvent { - Release, + /// `warp_target`, when present, is a screen-space (Quartz) point + /// at which to warp the local cursor before showing it. Used to + /// preserve cross-axis continuity on release: the visible cursor + /// reappears at the host point matching where it visually was on + /// the guest, instead of snapping back to the capture-start edge. + Release { + warp_target: Option<(i32, i32)>, + }, Create(Position), Destroy(Position), Grab(Position), @@ -153,8 +160,26 @@ impl InputCaptureState { ) -> Result<(), CaptureError> { log::debug!("handling event: {producer_event:?}"); match producer_event { - ProducerEvent::Release => { + ProducerEvent::Release { warp_target } => { + log::info!( + "[release-warp] handle_producer_event Release: current_pos={:?} warp_target={warp_target:?}", + self.current_pos + ); if self.current_pos.is_some() { + // Warp BEFORE clearing current_pos so the + // event-tap callback can't see Some(pos) and + // re-snap the cursor to the entry edge before we + // make it visible again. Then show_cursor() reveals + // it at the warped point. + if let Some((x, y)) = warp_target { + log::info!("[release-warp] warping local cursor to ({x}, {y})"); + if let Err(e) = CGDisplay::warp_mouse_cursor_position(CGPoint { + x: x as CGFloat, + y: y as CGFloat, + }) { + log::warn!("[release-warp] warp_mouse_cursor_position failed: {e:?}"); + } + } self.show_cursor()?; self.current_pos = None; } @@ -518,14 +543,34 @@ fn create_event_tap<'a>( } else if matches!(event_type, CGEventType::MouseMoved) { // Did we cross a barrier? if let Some(new_pos) = state.crossed(cg_ev) { - capture_position = Some(new_pos); - state - .start_capture(cg_ev, new_pos) - .unwrap_or_else(|e| log::warn!("{e}")); - res_events.push(CaptureEvent::Begin); - notify_tx - .blocking_send(ProducerEvent::Grab(new_pos)) - .expect("Failed to send notification"); + // About to commit the cross — final gate: skip if the + // host is locked, since the lock screen consumes + // keyboard before our tap sees it and allowing the + // cursor to leave would produce a mouse-only-on-peer + // half-broken state. Polling CGSession only at this + // commit point (rather than every MouseMoved) keeps + // the per-event cost zero — `is_screen_locked()` is + // an XPC to WindowServer (~10–50µs); a typical user + // crosses a wall a few times per minute. + if is_screen_locked() { + log::info!("host screen locked; suppressing cross to {new_pos:?}"); + } else { + capture_position = Some(new_pos); + // Snapshot the cursor's screen-space position at the + // instant of crossing — before start_capture's + // reset_cursor() snaps it to the edge. The peer uses + // this for the visually-corresponding warp on Enter + // so the cursor doesn't jump to the entry-edge midpoint. + let cross_loc = cg_ev.location(); + let cursor = Some((cross_loc.x as i32, cross_loc.y as i32)); + state + .start_capture(cg_ev, new_pos) + .unwrap_or_else(|e| log::warn!("{e}")); + res_events.push(CaptureEvent::Begin { cursor }); + notify_tx + .blocking_send(ProducerEvent::Grab(new_pos)) + .expect("Failed to send notification"); + } } } @@ -624,6 +669,37 @@ fn event_tap_thread( let _ = exit.send(()); } +/// Query whether the host's screen is locked. Asks the WindowServer +/// for the current login session dictionary and looks up the +/// `CGSSessionScreenIsLocked` key. The key is `kCFBooleanTrue` when +/// locked; on Sequoia 15+ it's typically absent when unlocked rather +/// than `kCFBooleanFalse`, so missing-or-nil is treated as unlocked. +/// Costs ~10–50µs per call (an XPC round-trip to WindowServer); +/// called from the event tap callback only on `MouseMoved`, so the +/// amortized cost is negligible (<2% CPU at typical mouse rates). +fn is_screen_locked() -> bool { + let key = unsafe { + let cstr = CString::new("CGSSessionScreenIsLocked").unwrap(); + CFStringCreateWithCString( + kCFAllocatorDefault, + cstr.as_ptr() as *const c_char, + kCFStringEncodingUTF8, + ) + }; + let dict = unsafe { CGSessionCopyCurrentDictionary() }; + if dict.is_null() { + unsafe { CFRelease(key as *const c_void) }; + return false; + } + let value = unsafe { CFDictionaryGetValue(dict, key as *const c_void) }; + let locked = !value.is_null() && unsafe { CFBooleanGetValue(value as CFBooleanRef) }; + unsafe { + CFRelease(dict as *const c_void); + CFRelease(key as *const c_void); + } + locked +} + /// Quartz display-reconfiguration callback. Fires twice per change: /// once with `kCGDisplayBeginConfigurationFlag` set (BEFORE the /// change is applied — the bounds are still stale at this point), @@ -772,11 +848,12 @@ impl Capture for MacOSInputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { + log::info!("[release-warp] macOS backend release(warp_target={warp_target:?})"); let notify_tx = self.notify_tx.clone(); tokio::task::spawn_local(async move { log::debug!("notifying Release"); - let _ = notify_tx.send(ProducerEvent::Release).await; + let _ = notify_tx.send(ProducerEvent::Release { warp_target }).await; }); Ok(()) } @@ -784,6 +861,54 @@ impl Capture for MacOSInputCapture { async fn terminate(&mut self) -> Result<(), CaptureError> { Ok(()) } + + fn display_bounds(&self) -> Option<(u32, u32)> { + // Mirror the InputEmulation-side implementation: the union of + // every active display's rectangle, in points (which match + // the units used by CGEvent.location() so the + // MotionAbsolute math stays internally consistent). + let displays = CGDisplay::active_displays().ok()?; + let mut xmin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymin = f64::INFINITY; + let mut ymax = f64::NEG_INFINITY; + for id in displays { + let bounds = CGDisplay::new(id).bounds(); + xmin = xmin.min(bounds.origin.x); + xmax = xmax.max(bounds.origin.x + bounds.size.width); + ymin = ymin.min(bounds.origin.y); + ymax = ymax.max(bounds.origin.y + bounds.size.height); + } + if xmax <= xmin || ymax <= ymin { + return None; + } + Some(((xmax - xmin) as u32, (ymax - ymin) as u32)) + } + + fn display_origin(&self) -> (i32, i32) { + // Top-left of the union of all active displays. Matters when + // a secondary monitor is positioned LEFT of (or ABOVE) the + // primary — the global pointer-coordinate system is anchored + // at the primary's top-left, so a left-attached external + // gives cursor x ∈ [-w, 0). Without this offset, + // host_normalized_cursor / peer_warp_target's clamp(0, 1) + // silently maps every point on the external to "left edge" + // and the receiver warps to the wrong column. + let Ok(displays) = CGDisplay::active_displays() else { + return (0, 0); + }; + let mut xmin = f64::INFINITY; + let mut ymin = f64::INFINITY; + for id in displays { + let bounds = CGDisplay::new(id).bounds(); + xmin = xmin.min(bounds.origin.x); + ymin = ymin.min(bounds.origin.y); + } + if xmin.is_infinite() || ymin.is_infinite() { + return (0, 0); + } + (xmin as i32, ymin as i32) + } } impl Stream for MacOSInputCapture { @@ -810,6 +935,19 @@ extern "C" { fn _CGSDefaultConnection() -> CGSConnectionID; } +type CFDictionaryRef = *mut c_void; + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGSessionCopyCurrentDictionary() -> CFDictionaryRef; +} + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFDictionaryGetValue(dict: CFDictionaryRef, key: *const c_void) -> *const c_void; + fn CFBooleanGetValue(boolean: CFBooleanRef) -> bool; +} + extern "C" { fn CGEventSourceSetLocalEventsSuppressionInterval( event_source: CGEventSource, diff --git a/input-capture/src/windows.rs b/input-capture/src/windows.rs index 0d0ed7c21..059abe480 100644 --- a/input-capture/src/windows.rs +++ b/input-capture/src/windows.rs @@ -29,7 +29,7 @@ impl Capture for WindowsInputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { self.event_thread.release_capture(); Ok(()) } diff --git a/input-capture/src/windows/event_thread.rs b/input-capture/src/windows/event_thread.rs index 2a44c0fc2..a4711023d 100644 --- a/input-capture/src/windows/event_thread.rs +++ b/input-capture/src/windows/event_thread.rs @@ -14,6 +14,9 @@ use windows::Win32::Graphics::Gdi::{ EnumDisplayDevicesW, EnumDisplaySettingsW, }; use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::RemoteDesktop::{ + NOTIFY_FOR_THIS_SESSION, WTSRegisterSessionNotification, WTSUnRegisterSessionNotification, +}; use windows::Win32::System::Threading::GetCurrentThreadId; use windows::core::{PCWSTR, w}; @@ -23,7 +26,8 @@ use windows::Win32::UI::WindowsAndMessaging::{ RegisterClassW, SetWindowsHookExW, TranslateMessage, WH_KEYBOARD_LL, WH_MOUSE_LL, WINDOW_STYLE, WM_DISPLAYCHANGE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, - WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSW, WNDPROC, + WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_WTSSESSION_CHANGE, WM_XBUTTONDOWN, WM_XBUTTONUP, + WNDCLASSW, WNDPROC, WTS_SESSION_LOCK, WTS_SESSION_UNLOCK, }; use input_event::{ @@ -122,6 +126,14 @@ thread_local! { static PREV_POS: Cell> = const { Cell::new(None) }; /// displays and generation counter static DISPLAYS: RefCell<(Vec, i32)> = const { RefCell::new((Vec::new(), 0)) }; + /// True while the host's session is locked. Set/cleared from the + /// `WM_WTSSESSION_CHANGE` window message. While true, barrier + /// crossings are suppressed and any active capture is released — + /// matches macOS's lock-screen suppression and what Wayland does + /// for free on locked Linux. Without this, low-level mouse hooks + /// would happily forward motion to the peer while the lock screen + /// consumes keyboard events, leaving a half-broken state. + static HOST_LOCKED: Cell = const { Cell::new(false) }; } fn get_msg() -> Option { @@ -202,8 +214,10 @@ fn start_routine( } } - /* window is used ro receive WM_DISPLAYCHANGE messages */ - unsafe { + /* window is used to receive WM_DISPLAYCHANGE and + * WM_WTSSESSION_CHANGE messages. Keep the HWND so we can register + * for session notifications and unregister on exit. */ + let msg_window = unsafe { CreateWindowExW( Default::default(), w!("lan-mouse-message-window-class"), @@ -218,15 +232,25 @@ fn start_routine( Some(instance), None, ) - .expect("CreateWindowExW"); + .expect("CreateWindowExW") + }; + + /* register for WM_WTSSESSION_CHANGE notifications so we can + * detect lock/unlock and suppress crossings while locked. Failure + * is logged but non-fatal — the rest of the capture still works, + * we just lose the lock-screen suppression. */ + unsafe { + if let Err(e) = WTSRegisterSessionNotification(msg_window, NOTIFY_FOR_THIS_SESSION) { + log::warn!( + "WTSRegisterSessionNotification failed: {e:?} — host-lock suppression disabled" + ); + } } - /* run message loop */ - loop { - // mouse / keybrd proc do not actually return a message - let Some(msg) = get_msg() else { - break; - }; + /* run message loop. mouse / keybrd procs don't actually return + * a message, so `get_msg() == None` ends the loop; an Exit-typed + * thread message breaks out from inside the body. */ + while let Some(msg) = get_msg() { if msg.hwnd.0.is_null() { /* messages sent via PostThreadMessage */ match msg.wParam.0 { @@ -258,6 +282,11 @@ fn start_routine( } } } + + /* unregister session-notification before the window goes away. */ + unsafe { + let _ = WTSUnRegisterSessionNotification(msg_window); + } } fn check_client_activation(wparam: WPARAM, lparam: LPARAM) -> bool { @@ -277,6 +306,14 @@ fn check_client_activation(wparam: WPARAM, lparam: LPARAM) -> bool { return ret; } + /* host session locked — don't let the cursor leave the lock + * screen. The lock screen consumes keyboard before our hook sees + * it; allowing a cross would put the mouse on the peer with no + * keyboard, a confusing half-broken state. */ + if HOST_LOCKED.get() { + return ret; + } + /* check if a client was activated */ let entered = DISPLAYS.with_borrow_mut(|(displays, generation)| { update_display_regions(displays, generation); @@ -302,7 +339,7 @@ fn check_client_activation(wparam: WPARAM, lparam: LPARAM) -> bool { /* notify main thread */ log::debug!("ENTERED @ {prev_pos:?} -> {curr_pos:?}"); let active = ACTIVE_CLIENT.get().expect("active client"); - blocking_send_event(active, CaptureEvent::Begin); + blocking_send_event(active, CaptureEvent::Begin { cursor: None }); ret } @@ -356,12 +393,29 @@ unsafe extern "system" fn kybrd_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) unsafe extern "system" fn window_proc( _hwnd: HWND, uint: u32, - _wparam: WPARAM, + wparam: WPARAM, _lparam: LPARAM, ) -> LRESULT { if uint == WM_DISPLAYCHANGE { log::debug!("display resolution changed"); DISPLAY_RESOLUTION_GENERATION.fetch_add(1, Ordering::Release); + } else if uint == WM_WTSSESSION_CHANGE { + match wparam.0 as u32 { + WTS_SESSION_LOCK => { + HOST_LOCKED.set(true); + if let Some(pos) = ACTIVE_CLIENT.take() { + log::info!("host session locked mid-capture; releasing"); + let _ = try_send_event(pos, CaptureEvent::AutoRelease); + } else { + log::info!("host session locked"); + } + } + WTS_SESSION_UNLOCK => { + HOST_LOCKED.set(false); + log::info!("host session unlocked"); + } + _ => {} + } } LRESULT(1) } diff --git a/input-capture/src/x11.rs b/input-capture/src/x11.rs index 6fc917ec9..cf74940dc 100644 --- a/input-capture/src/x11.rs +++ b/input-capture/src/x11.rs @@ -23,7 +23,7 @@ impl Capture for X11InputCapture { Ok(()) } - async fn release(&mut self) -> Result<(), CaptureError> { + async fn release(&mut self, _warp_target: Option<(i32, i32)>) -> Result<(), CaptureError> { Ok(()) } diff --git a/input-emulation/src/lib.rs b/input-emulation/src/lib.rs index 930695f9a..5b3db3763 100644 --- a/input-emulation/src/lib.rs +++ b/input-emulation/src/lib.rs @@ -178,6 +178,19 @@ impl InputEmulation { self.emulation.terminate().await } + /// Display geometry of this device (union of all active + /// displays), if the backend can report it. See + /// `Emulation::display_bounds`. + pub fn display_bounds(&self) -> Option<(u32, u32)> { + self.emulation.display_bounds() + } + + /// Warp the local cursor to the given absolute position. See + /// `Emulation::warp_cursor`. + pub async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + self.emulation.warp_cursor(x, y).await + } + pub async fn release_keys(&mut self, handle: EmulationHandle) -> Result<(), EmulationError> { if let Some(keys) = self.pressed_keys.get_mut(&handle) { let keys = keys.drain().collect::>(); @@ -237,4 +250,27 @@ trait Emulation: Send { async fn create(&mut self, handle: EmulationHandle); async fn destroy(&mut self, handle: EmulationHandle); async fn terminate(&mut self); + + /// Geometry (width, height) of the union of this device's + /// active displays in pixels. Used by the protocol-level + /// `Bounds` event so a capturing peer can model the guest + /// cursor's position. Backends that can't report geometry + /// should leave the default `None` and the wall-press + /// auto-release fallback will degrade to "no upper clamp" + /// behavior on the host. + fn display_bounds(&self) -> Option<(u32, u32)> { + None + } + + /// Warp the cursor to an absolute position on the receiving + /// device's primary display, if the backend supports absolute + /// positioning. Called when an `Enter` event arrives so the + /// guest cursor lands at the entry edge instead of staying + /// wherever the previous capture session left it. Backends + /// without absolute positioning can leave the default no-op + /// — the wall-press auto-release will be inaccurate but the + /// connection still works. + async fn warp_cursor(&mut self, _x: i32, _y: i32) -> Result<(), EmulationError> { + Ok(()) + } } diff --git a/input-emulation/src/libei.rs b/input-emulation/src/libei.rs index 3ac6e8992..d17f2eab0 100644 --- a/input-emulation/src/libei.rs +++ b/input-emulation/src/libei.rs @@ -19,8 +19,8 @@ use async_trait::async_trait; use reis::{ ei::{ - self, Button, Keyboard, Pointer, Scroll, button::ButtonState, handshake::ContextType, - keyboard::KeyState, + self, Button, Keyboard, Pointer, PointerAbsolute, Scroll, button::ButtonState, + handshake::ContextType, keyboard::KeyState, }, event::{self, Connection, DeviceCapability, DeviceEvent, EiEvent, SeatEvent}, tokio::EiConvertEventStream, @@ -35,6 +35,7 @@ use super::{Emulation, EmulationHandle, error::LibeiEmulationCreationError}; #[derive(Clone, Default)] struct Devices { pointer: Arc>>, + pointer_abs: Arc>>, scroll: Arc>>, button: Arc>>, keyboard: Arc>>, @@ -261,6 +262,37 @@ impl Emulation for LibeiEmulation { let _ = self.session.close().await; self.ei_task.abort(); } + + fn display_bounds(&self) -> Option<(u32, u32)> { + // TODO: derive from ei::Region events on the + // PointerAbsolute device, or query wl_output via a side + // wayland-client connection. For now we return None and + // the host falls back to the no-upper-clamp heuristic. + None + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros() as u64; + let pointer_abs = self.devices.pointer_abs.read().unwrap(); + if let Some((device, pointer_abs)) = pointer_abs.as_ref() { + pointer_abs.motion_absolute(x as f32, y as f32); + device.frame(self.conn.serial(), now); + self.context + .flush() + .map_err(|e| io::Error::new(e.kind(), e))?; + } else { + // Compositor didn't grant a PointerAbsolute device. + // Nothing we can do to warp the cursor; the host's + // wall-press model will be off by however far the + // user pushed forward in the prior session, but + // operation continues. + log::debug!("warp_cursor: no PointerAbsolute device available, skipping"); + } + Ok(()) + } } async fn ei_task( @@ -323,6 +355,13 @@ async fn ei_event_handler( .unwrap() .replace((device.device().clone(), pointer)); } + if let Some(pointer_abs) = e.device().interface::() { + devices + .pointer_abs + .write() + .unwrap() + .replace((device.device().clone(), pointer_abs)); + } if let Some(keyboard) = e.device().interface::() { devices .keyboard diff --git a/input-emulation/src/macos.rs b/input-emulation/src/macos.rs index 881fc2299..35cda3763 100644 --- a/input-emulation/src/macos.rs +++ b/input-emulation/src/macos.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use bitflags::bitflags; use core_graphics::base::CGFloat; use core_graphics::display::{ - CGDirectDisplayID, CGDisplayBounds, CGGetDisplaysWithRect, CGPoint, CGRect, CGSize, + CGDirectDisplayID, CGDisplay, CGDisplayBounds, CGGetDisplaysWithRect, CGPoint, CGRect, CGSize, }; use core_graphics::event::{ CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGKeyCode, CGMouseButton, EventField, @@ -489,6 +489,39 @@ impl Emulation for MacOSEmulation { async fn destroy(&mut self, _handle: EmulationHandle) {} async fn terminate(&mut self) {} + + fn display_bounds(&self) -> Option<(u32, u32)> { + // Union of every active display's rectangle. Matches the + // shape used on the input-capture side so the host's + // wall-press model is consistent across both ends. + let displays = CGDisplay::active_displays().ok()?; + let mut xmin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymin = f64::INFINITY; + let mut ymax = f64::NEG_INFINITY; + for id in displays { + let bounds = CGDisplay::new(id).bounds(); + xmin = xmin.min(bounds.origin.x); + xmax = xmax.max(bounds.origin.x + bounds.size.width); + ymin = ymin.min(bounds.origin.y); + ymax = ymax.max(bounds.origin.y + bounds.size.height); + } + if xmax <= xmin || ymax <= ymin { + return None; + } + Some(((xmax - xmin) as u32, (ymax - ymin) as u32)) + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + let pt = CGPoint { + x: x as CGFloat, + y: y as CGFloat, + }; + // CGDisplay::warp_mouse_cursor_position is a global Quartz + // call; it doesn't matter which CGDisplay receiver we use. + let _ = CGDisplay::warp_mouse_cursor_position(pt); + Ok(()) + } } fn update_modifiers(modifiers: &Cell, key: u32, state: u8) -> bool { diff --git a/input-emulation/src/windows.rs b/input-emulation/src/windows.rs index 6610ec257..18ca7782c 100644 --- a/input-emulation/src/windows.rs +++ b/input-emulation/src/windows.rs @@ -17,7 +17,9 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{ use windows::Win32::UI::Input::KeyboardAndMouse::{ INPUT_0, KEYEVENTF_EXTENDEDKEY, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, SendInput, }; -use windows::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; +use windows::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SetCursorPos, XBUTTON1, XBUTTON2, +}; use super::{Emulation, EmulationHandle}; @@ -80,6 +82,27 @@ impl Emulation for WindowsEmulation { async fn destroy(&mut self, _handle: EmulationHandle) {} async fn terminate(&mut self) {} + + fn display_bounds(&self) -> Option<(u32, u32)> { + // Virtual-screen metrics cover the union of every monitor + // attached to the system, matching the host-side capture + // model that uses the union of all displays. + unsafe { + let w = GetSystemMetrics(SM_CXVIRTUALSCREEN); + let h = GetSystemMetrics(SM_CYVIRTUALSCREEN); + if w <= 0 || h <= 0 { + return None; + } + Some((w as u32, h as u32)) + } + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + unsafe { + let _ = SetCursorPos(x, y); + } + Ok(()) + } } impl WindowsEmulation { diff --git a/input-emulation/src/wlroots.rs b/input-emulation/src/wlroots.rs index f79f8d9eb..34cbb9557 100644 --- a/input-emulation/src/wlroots.rs +++ b/input-emulation/src/wlroots.rs @@ -12,8 +12,13 @@ use wayland_client::WEnum; use wayland_client::backend::WaylandError; use wayland_client::protocol::wl_keyboard::{self, WlKeyboard}; +use wayland_client::protocol::wl_output::{self, WlOutput}; use wayland_client::protocol::wl_pointer::{Axis, AxisSource, ButtonState}; use wayland_client::protocol::wl_seat::WlSeat; +use wayland_protocols::xdg::xdg_output::zv1::client::{ + zxdg_output_manager_v1::ZxdgOutputManagerV1, + zxdg_output_v1::{self, ZxdgOutputV1}, +}; use wayland_protocols_wlr::virtual_pointer::v1::client::{ zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1 as VpManager, zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1 as Vp, @@ -25,7 +30,7 @@ use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{ }; use wayland_client::{ - Connection, Dispatch, EventQueue, QueueHandle, delegate_noop, + Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop, globals::{GlobalListContents, registry_queue_init}, protocol::{wl_registry, wl_seat}, }; @@ -42,6 +47,38 @@ struct State { qh: QueueHandle, vpm: VpManager, vkm: VkManager, + /// All wl_outputs the compositor advertises, keyed by their + /// proxy id. Updated via Geometry/Mode events. We keep the + /// `WlOutput` proxy alive in the value so events keep flowing. + outputs: HashMap)>, + /// Dedicated virtual pointer used only for absolute-position + /// warps on `Enter`. Separate from per-handle pointers so warp + /// works regardless of which client is active. + warp_pointer: Vp, +} + +#[derive(Default, Clone, Copy)] +struct OutputInfo { + /// Position in the compositor's global coordinate space, from + /// wl_output::Event::Geometry. Raw-pixel coordinates. + x: i32, + y: i32, + /// Pixel dimensions of the active mode, from wl_output::Event::Mode. + width: i32, + height: i32, + /// Logical position in the compositor's coordinate space, from + /// zxdg_output_v1::Event::LogicalPosition. Reflects software + /// scaling (e.g. fractional or HiDPI). Falls back to (x, y) when + /// xdg-output isn't available. + logical_x: Option, + logical_y: Option, + /// Logical dimensions, from zxdg_output_v1::Event::LogicalSize. + /// This is the coordinate space the compositor uses for cursor + /// positions and the same one the capture side uses, so we + /// prefer it for `display_bounds()` to keep both sides in sync. + /// Falls back to (width, height) when xdg-output isn't available. + logical_width: Option, + logical_height: Option, } // App State, implements Dispatch event handlers @@ -67,6 +104,39 @@ impl WlrootsEmulation { let vkm: VkManager = globals .bind(&qh, 1..=1, ()) .map_err(|e| WaylandBindError::new(e, "virtual-keyboard-unstable-v1"))?; + // xdg-output gives us LogicalSize/LogicalPosition — the + // coordinate space the compositor actually uses (with + // software/fractional scaling applied). The capture side + // already reports bounds in this space, so emulation needs + // it too or warps land on different proportions than the + // sender computed. Optional: if the compositor doesn't + // advertise xdg_output_manager we fall back to wl_output's + // raw mode dimensions. + let xdg_output_manager: Option = globals.bind(&qh, 1..=3, ()).ok(); + + // Bind every advertised wl_output so we receive Geometry + + // Mode events for each one. Used to compute display_bounds. + let mut outputs: HashMap)> = + HashMap::new(); + for global in globals.contents().clone_list() { + if global.interface == "wl_output" { + // version 2 is enough for Geometry + Mode events. + let output: WlOutput = + globals + .registry() + .bind(global.name, global.version.min(2), &qh, ()); + let id = output.id().protocol_id(); + let xdg_output = xdg_output_manager + .as_ref() + .map(|mgr| mgr.get_xdg_output(&output, &qh, id)); + outputs.insert(id, (output, OutputInfo::default(), xdg_output)); + } + } + + // Dedicated warp pointer — used only for motion_absolute on + // Enter, so warp works even when no per-handle virtual + // pointer is currently active. + let warp_pointer: Vp = vpm.create_virtual_pointer(None, &qh, ()); let input_for_client: HashMap = HashMap::new(); @@ -79,6 +149,8 @@ impl WlrootsEmulation { vpm, vkm, qh, + outputs, + warp_pointer, }, queue, }; @@ -119,6 +191,39 @@ impl State { input.keyboard.destroy(); } } + + /// Bounding rectangle of every active wl_output in the + /// compositor's logical coordinate space (with software / + /// fractional scaling applied). Falls back per-output to raw + /// mode dimensions when xdg-output is unavailable. Returns + /// None if no output has reported usable size info yet. + fn union_bounds(&self) -> Option<(u32, u32)> { + let mut xmin = i32::MAX; + let mut ymin = i32::MAX; + let mut xmax = i32::MIN; + let mut ymax = i32::MIN; + let mut any = false; + for (_, o, _) in self.outputs.values() { + let w = o.logical_width.unwrap_or(o.width); + let h = o.logical_height.unwrap_or(o.height); + if w <= 0 || h <= 0 { + continue; + } + let ox = o.logical_x.unwrap_or(o.x); + let oy = o.logical_y.unwrap_or(o.y); + any = true; + xmin = xmin.min(ox); + ymin = ymin.min(oy); + xmax = xmax.max(ox + w); + ymax = ymax.max(oy + h); + } + if !any { + return None; + } + let w = (xmax - xmin) as u32; + let h = (ymax - ymin) as u32; + Some((w, h)) + } } #[async_trait] @@ -172,7 +277,31 @@ impl Emulation for WlrootsEmulation { } } async fn terminate(&mut self) { - /* nothing to do */ + self.state.warp_pointer.destroy(); + } + + fn display_bounds(&self) -> Option<(u32, u32)> { + self.state.union_bounds() + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + let Some((width, height)) = self.state.union_bounds() else { + return Ok(()); + }; + let now: u32 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u32; + let cx = x.clamp(0, width as i32) as u32; + let cy = y.clamp(0, height as i32) as u32; + self.state + .warp_pointer + .motion_absolute(now, cx, cy, width, height); + self.state.warp_pointer.frame(); + if let Err(e) = self.queue.flush() { + log::warn!("warp_cursor flush failed: {e}"); + } + Ok(()) } } @@ -254,6 +383,33 @@ delegate_noop!(State: Vp); delegate_noop!(State: Vk); delegate_noop!(State: VpManager); delegate_noop!(State: VkManager); +delegate_noop!(State: ZxdgOutputManagerV1); + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ZxdgOutputV1, + event: ::Event, + id: &u32, + _: &Connection, + _: &QueueHandle, + ) { + let Some((_, info, _)) = state.outputs.get_mut(id) else { + return; + }; + match event { + zxdg_output_v1::Event::LogicalPosition { x, y } => { + info.logical_x = Some(x); + info.logical_y = Some(y); + } + zxdg_output_v1::Event::LogicalSize { width, height } => { + info.logical_width = Some(width); + info.logical_height = Some(height); + } + _ => {} + } + } +} impl Dispatch for State { fn event( @@ -282,6 +438,38 @@ impl Dispatch for State { } } +impl Dispatch for State { + fn event( + state: &mut Self, + output: &WlOutput, + event: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let id = output.id().protocol_id(); + let Some((_, info, _)) = state.outputs.get_mut(&id) else { + return; + }; + match event { + wl_output::Event::Geometry { x, y, .. } => { + info.x = x; + info.y = y; + } + wl_output::Event::Mode { + flags: WEnum::Value(flags), + width, + height, + .. + } if flags.contains(wl_output::Mode::Current) => { + info.width = width; + info.height = height; + } + _ => {} + } + } +} + impl Dispatch for State { fn event( _: &mut Self, diff --git a/input-emulation/src/x11.rs b/input-emulation/src/x11.rs index aadca2945..d62e1cdaa 100644 --- a/input-emulation/src/x11.rs +++ b/input-emulation/src/x11.rs @@ -151,4 +151,31 @@ impl Emulation for X11Emulation { async fn terminate(&mut self) { /* nothing to do */ } + + fn display_bounds(&self) -> Option<(u32, u32)> { + unsafe { + // DisplayWidth/DisplayHeight on the default screen + // returns the union extent of the X server's logical + // screen across all monitors (Xinerama / RandR). + let screen = xlib::XDefaultScreen(self.display); + let w = xlib::XDisplayWidth(self.display, screen); + let h = xlib::XDisplayHeight(self.display, screen); + if w <= 0 || h <= 0 { + return None; + } + Some((w as u32, h as u32)) + } + } + + async fn warp_cursor(&mut self, x: i32, y: i32) -> Result<(), EmulationError> { + unsafe { + let root = xlib::XDefaultRootWindow(self.display); + // XWarpPointer with src_w = 0 means "no source window", + // so the cursor moves to (x, y) relative to dest_w + // (the root window) regardless of where it currently is. + xlib::XWarpPointer(self.display, 0, root, 0, 0, 0, 0, x, y); + xlib::XFlush(self.display); + } + Ok(()) + } } diff --git a/lan-mouse-gtk/resources/window.ui b/lan-mouse-gtk/resources/window.ui index 609ea4e88..aec1ea72b 100644 --- a/lan-mouse-gtk/resources/window.ui +++ b/lan-mouse-gtk/resources/window.ui @@ -198,6 +198,58 @@ + + + Outgoing Auto-Release + Fallback release when the peer can't tell us the cursor reached its edge — typically because the peer's screen is locked. No effect on peers that release themselves; the cursor-release keybind still works either way. + + + Release threshold + pixels of wall-press past the entry edge before auto-release fires + + + disabled + center + 8 + 1.0 + + + + + + + + horizontal + true + false + 0 + 12 + 12 + 4 + 8 + + + 0 + 500 + 10 + 50 + 0 + + + + off + 50 + 100 + 200 + 500 + + + + + Connections diff --git a/lan-mouse-gtk/src/lib.rs b/lan-mouse-gtk/src/lib.rs index ecdd7080c..688d37a1f 100644 --- a/lan-mouse-gtk/src/lib.rs +++ b/lan-mouse-gtk/src/lib.rs @@ -269,6 +269,9 @@ fn build_ui(app: &Application) { FrontendEvent::IncomingDisconnected(addr) => { window.show_toast(format!("{addr} disconnected").as_str()); } + FrontendEvent::ReleaseThreshold(threshold) => { + window.set_release_threshold(threshold); + } } } } diff --git a/lan-mouse-gtk/src/window.rs b/lan-mouse-gtk/src/window.rs index f65015725..22c9d7480 100644 --- a/lan-mouse-gtk/src/window.rs +++ b/lan-mouse-gtk/src/window.rs @@ -407,6 +407,10 @@ impl Window { self.request(FrontendRequest::Create); } + pub(super) fn request_release_threshold(&self, threshold: u32) { + self.request(FrontendRequest::SetReleaseThreshold(threshold)); + } + fn open_fingerprint_dialog(&self, fp: Option) { let window = FingerprintWindow::new(fp); window.set_transient_for(Some(self)); @@ -461,6 +465,28 @@ impl Window { self.update_capture_emulation_status(); } + pub(super) fn set_release_threshold(&self, threshold: u32) { + let imp = self.imp(); + // Block the value-changed handler so programmatically setting + // the slider value (e.g. on Sync from the daemon) doesn't + // ricochet back as a SetReleaseThreshold request. + let scale = &imp.release_threshold_scale; + let handler_id = imp.release_threshold_handler.borrow(); + if let Some(id) = handler_id.as_ref() { + scale.block_signal(id); + } + scale.set_value(threshold as f64); + if let Some(id) = handler_id.as_ref() { + scale.unblock_signal(id); + } + let label = if threshold == 0 { + "disabled".to_string() + } else { + format!("{threshold} px") + }; + imp.release_threshold_value.set_label(&label); + } + #[cfg(target_os = "macos")] pub(super) fn refresh_capture_emulation_status(&self) { self.update_capture_emulation_status(); diff --git a/lan-mouse-gtk/src/window/imp.rs b/lan-mouse-gtk/src/window/imp.rs index bb7d48cba..62300c7b0 100644 --- a/lan-mouse-gtk/src/window/imp.rs +++ b/lan-mouse-gtk/src/window/imp.rs @@ -4,7 +4,10 @@ use adw::subclass::prelude::*; use adw::{ActionRow, PreferencesGroup, ToastOverlay, prelude::*}; use glib::subclass::InitializingObject; use gtk::glib::clone; -use gtk::{Button, CompositeTemplate, Entry, Image, Label, ListBox, gdk, gio, glib}; +use gtk::{ + Button, CompositeTemplate, Entry, EventControllerScroll, EventControllerScrollFlags, Image, + Label, ListBox, PropagationPhase, Scale, ScrolledWindow, gdk, gio, glib, +}; use lan_mouse_ipc::{DEFAULT_PORT, FrontendRequestWriter}; @@ -45,6 +48,12 @@ pub struct Window { pub input_capture_button: TemplateChild