From fd1df04be5cf57e6051eb475afba71d56518d39f Mon Sep 17 00:00:00 2001 From: Galen Green Date: Wed, 30 Sep 2026 19:14:33 +1300 Subject: [PATCH] fix(mousetail): prevent duplicate cursors during control takeover - Track controlling peers and allow quiet local mouse takeover - Re-hide macOS cursors that reappear while remotely controlled - Add regression tests and document takeover behavior --- crates/core/src/controller.rs | 143 +++++++++++++++++++++++-- crates/mousetail/src/node.rs | 41 ++++--- crates/mousetail/src/platform/macos.rs | 82 ++++++++++++-- docs/DESIGN.md | 5 +- 4 files changed, 236 insertions(+), 35 deletions(-) diff --git a/crates/core/src/controller.rs b/crates/core/src/controller.rs index 10d05e8..bca3a2f 100644 --- a/crates/core/src/controller.rs +++ b/crates/core/src/controller.rs @@ -9,6 +9,7 @@ //! actions, which keeps it testable and lets the backend decide swallowing inline. use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; use crate::keys::ev; use crate::layout::{DisplayRef, Layout, Machine, Point, Side}; @@ -78,6 +79,11 @@ enum State { const SELF: usize = 0; +/// How long injected input must have been still before local motion counts as the user +/// reaching for this computer's own mouse. On Linux we can't otherwise tell injected pointer +/// motion from real motion. +const TAKEOVER_QUIET: Duration = Duration::from_millis(250); + enum Exit { Cross(DisplayRef, Point), Offline(String), @@ -104,9 +110,14 @@ pub struct Controller { local_held: HashSet, remote_held: HashSet, seq: u64, - /// While another computer is controlling this one, local input stays local: an injected - /// cursor reaching an edge must not bounce off to a third machine. - suspended: bool, + /// The computer controlling this one, if any. Local input stays local then (an injected + /// cursor reaching an edge must not bounce off to a third machine), except that this + /// computer's own mouse can take the cursor back to the controlling computer. + controlled_by: Option, + /// When the controlling computer last moved our cursor. + injected_at: Option, + /// When the cursor last crossed on to another computer. + entered_at: Option, } impl Controller { @@ -125,7 +136,9 @@ impl Controller { local_held: HashSet::new(), remote_held: HashSet::new(), seq: 0, - suspended: false, + controlled_by: None, + injected_at: None, + entered_at: None, } } @@ -295,13 +308,41 @@ impl Controller { } } - pub fn set_suspended(&mut self, suspended: bool) { - self.suspended = suspended; + /// Another computer has started (`Some`) or stopped (`None`) controlling this one. If the + /// cursor was away on some computer, it comes home first: the other side has taken over. + pub fn set_controlled_by(&mut self, by: Option<&str>) -> Vec { + self.controlled_by = by.map(str::to_string); + self.injected_at = None; + let Some(by) = by else { return vec![] }; + match self.state { + State::Remote { on, home, .. } => { + let id = &self.layout.machines[on.machine].id; + // The computer our cursor is on crossing into us is either its own mouse taking + // the cursor back (only possible once we'd been still there a while; it already + // has the cursor, so no Leave) or both of us crossing at the same moment (tell + // it, so we both end up home). + let tell = if id == by { + self.entered_at + .is_some_and(|t| t.elapsed() < TAKEOVER_QUIET) + } else { + self.reachable.contains(id) + }; + self.leave(on, home, tell) + } + State::Local => vec![], + } + } + + /// The controlling computer just moved our cursor. + pub fn note_injected(&mut self) { + self.injected_at = Some(Instant::now()); } pub fn handle(&mut self, input: Input) -> Outcome { - if self.suspended && self.state == State::Local { - return Outcome::default(); + if let Some(by) = &self.controlled_by + && self.state == State::Local + { + return self.handle_controlled(by.clone(), input); } match self.state { State::Local => self.handle_local(input), @@ -345,6 +386,7 @@ impl Controller { pos: point, home: at, }; + self.entered_at = Some(Instant::now()); Outcome { swallow: true, actions: vec![ @@ -365,6 +407,26 @@ impl Controller { } } + /// Being controlled: only a push from this computer's own mouse towards the controlling + /// computer does anything, taking the cursor over there. + fn handle_controlled(&mut self, by: String, input: Input) -> Outcome { + let Input::Motion { at, dx, dy, .. } = input else { + return Outcome::default(); + }; + let quiet = self + .injected_at + .is_none_or(|t| t.elapsed() >= TAKEOVER_QUIET); + let crosses = matches!( + self.find_exit(at, dx, dy), + Some(Exit::Cross(to, _)) if self.layout.machines[to.machine].id == by + ); + if !quiet || !crosses { + return Outcome::default(); + } + self.controlled_by = None; + self.handle_local(input) + } + /// If the local cursor is pushing against an edge that leads to another computer, where /// does it land (or which offline computer is it reaching for)? fn find_exit(&self, at: Point, dx: f64, dy: f64) -> Option { @@ -518,6 +580,7 @@ impl Controller { pos: c.point, home, }; + self.entered_at = Some(Instant::now()); vec![ Action::Send { peer: from, @@ -773,12 +836,72 @@ mod tests { #[test] fn no_crossing_while_being_controlled() { let mut c = desk(); - c.set_suspended(true); + c.set_peer( + "third", + vec![display("t", 0.0, 0.0, 800.0, 600.0, true)], + Point::default(), + ); + let offset = c.offset_beside("third", Side::Right, None).unwrap(); + c.set_peer( + "third", + vec![display("t", 0.0, 0.0, 800.0, 600.0, true)], + offset, + ); + c.set_reachable("third", true); + c.set_controlled_by(Some("imac")); + // Never on to a third computer. + assert_eq!( + c.handle(motion(1511.0, 500.0, 3.0, 0.0)), + Outcome::default() + ); + // Nor back to the controlling one while it's still moving our cursor. + c.note_injected(); assert_eq!(c.handle(motion(0.0, 500.0, -3.0, 0.0)), Outcome::default()); - c.set_suspended(false); + c.set_controlled_by(None); enter(&mut c); } + #[test] + fn own_mouse_takes_the_cursor_back_to_the_controlling_computer() { + let mut c = desk(); + c.set_controlled_by(Some("imac")); + c.injected_at = Instant::now().checked_sub(TAKEOVER_QUIET); + enter(&mut c); + assert_eq!(c.active_peer(), Some("imac")); + // Now controlling it, not controlled by it: pushing back brings the cursor home. + let out = c.handle(motion(0.0, 500.0, 150.0, 0.0)); + assert!( + out.actions + .iter() + .any(|a| matches!(a, Action::Release { .. })) + ); + } + + #[test] + fn being_taken_over_brings_the_cursor_home() { + let mut c = desk(); + enter(&mut c); + // Both crossed at once: tell it, so it comes home too. + let actions = c.set_controlled_by(Some("imac")); + assert!(actions.contains(&Action::Send { + peer: "imac".into(), + msg: Message::Leave + })); + c.set_controlled_by(None); + + enter(&mut c); + c.entered_at = Instant::now().checked_sub(TAKEOVER_QUIET); + // The iMac's own mouse took the cursor back to us: no Leave for it, just come home. + assert_eq!( + c.set_controlled_by(Some("imac")), + vec![Action::Release { + warp: Point::new(0.0, 500.0) + }] + ); + assert_eq!(c.active_peer(), None); + assert_eq!(c.handle(motion(10.0, 500.0, 3.0, 0.0)), Outcome::default()); + } + #[test] fn no_crossing_while_dragging() { let mut c = desk(); diff --git a/crates/mousetail/src/node.rs b/crates/mousetail/src/node.rs index 06a5b8f..8610d31 100644 --- a/crates/mousetail/src/node.rs +++ b/crates/mousetail/src/node.rs @@ -922,7 +922,7 @@ impl Node { active }); if was_controlling_us { - self.controller.lock().unwrap().set_suspended(false); + self.controller.lock().unwrap().set_controlled_by(None); } self.pairing.lock().unwrap().remove(id); } @@ -953,6 +953,13 @@ impl Node { if entering { debug!("cursor → {peer}"); self.claim_sound(&peer); + // Taking the cursor back to the computer that was controlling us. + if let Some(t) = self.target.get() { + let mut t = t.lock().unwrap(); + if t.active.as_deref() == Some(peer.as_str()) { + t.leave(); + } + } // Our clipboard travels with the cursor. After Enter on the same // ordered stream, so the other side knows it's part of the crossing. self.push_clipboard(&peer); @@ -1073,12 +1080,18 @@ impl Node { if !self.is_current(id, conn) { return; } - if let Some(t) = self.target.get() { + let Some(t) = self.target.get() else { return }; + let moved = { let mut t = t.lock().unwrap(); - if t.active.as_deref() == Some(id) && motion.seq > t.last_seq { + let fresh = t.active.as_deref() == Some(id) && motion.seq > t.last_seq; + if fresh { t.last_seq = motion.seq; t.emulator.motion(motion.x, motion.y); } + fresh + }; + if moved { + self.controller.lock().unwrap().note_injected(); } } @@ -1185,7 +1198,7 @@ impl Node { .is_some_and(|t| t.lock().unwrap().active.as_deref() == Some(id)); self.on_input(id, &peer, Message::Leave); if was_active { - self.controller.lock().unwrap().set_suspended(false); + self.controller.lock().unwrap().set_controlled_by(None); self.push_clipboard(id); } // From the computer our cursor is on: someone else has taken it over (or it @@ -1195,14 +1208,9 @@ impl Node { } Message::Enter { .. } if self.target.get().is_some() => { // Being controlled: our own cursor comes home if it's off on another computer - // (perhaps this one, if we both crossed at once), and our capture stands down - // until they leave. - let actions = { - let mut controller = self.controller.lock().unwrap(); - let actions = controller.release(); - controller.set_suspended(true); - actions - }; + // (perhaps this one: its own mouse took the cursor back, or we both crossed at + // once), and our capture stands down until they leave. + let actions = self.controller.lock().unwrap().set_controlled_by(Some(id)); self.apply_actions(actions); // Whoever was controlling us is replaced: tell them, so they come home. let replaced = self.target.get().and_then(|t| { @@ -1210,11 +1218,18 @@ impl Node { active.filter(|a| a != id) }); self.on_input(id, &peer, msg); + self.controller.lock().unwrap().note_injected(); if let Some(replaced) = replaced { self.send(&replaced, Message::Leave); } } - input => self.on_input(id, &peer, input), + input => { + let moves = matches!(input, Message::Button { .. }); + self.on_input(id, &peer, input); + if moves { + self.controller.lock().unwrap().note_injected(); + } + } } } diff --git a/crates/mousetail/src/platform/macos.rs b/crates/mousetail/src/platform/macos.rs index b1adcee..19cba75 100644 --- a/crates/mousetail/src/platform/macos.rs +++ b/crates/mousetail/src/platform/macos.rs @@ -6,9 +6,10 @@ use std::ffi::c_void; use std::ptr; -use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, mpsc}; use std::thread; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Context; use core_foundation::base::TCFType; @@ -58,6 +59,7 @@ unsafe extern "C" { fn CGWarpMouseCursorPosition(point: CGPoint) -> i32; fn CGDisplayHideCursor(display: u32) -> i32; fn CGDisplayShowCursor(display: u32) -> i32; + fn CGCursorIsVisible() -> bool; fn CGMainDisplayID() -> u32; fn CGGetOnlineDisplayList(max: u32, ids: *mut u32, count: *mut u32) -> i32; fn CGDisplayMirrorsDisplay(display: u32) -> u32; @@ -128,6 +130,11 @@ struct Shared { actions: UnboundedSender, grabbed: AtomicBool, tap: AtomicPtr, + /// Hides we owe a show for: macOS sometimes shows the cursor again behind our back, so a + /// grab can take several hides. + hides: AtomicU32, + /// When the cursor was last checked while grabbed (ms since the Unix epoch). + checked: AtomicU64, } static SHARED: OnceLock = OnceLock::new(); @@ -150,6 +157,8 @@ impl Capture { actions, grabbed: AtomicBool::new(false), tap: AtomicPtr::new(ptr::null_mut()), + hides: AtomicU32::new(0), + checked: AtomicU64::new(0), }); anyhow::ensure!( shared.tap.load(Ordering::SeqCst).is_null(), @@ -168,15 +177,8 @@ impl Capture { if !source.is_null() { CGEventSourceSetLocalEventsSuppressionInterval(source, 0.0); } - let key = CFString::from_static_string("SetsCursorInBackground"); - let cid = _CGSDefaultConnection(); - CGSSetConnectionProperty( - cid, - cid, - key.as_concrete_TypeRef() as *const c_void, - CFBoolean::true_value().as_concrete_TypeRef() as *const c_void, - ); } + allow_background_cursor(); Ok(Self) } @@ -284,6 +286,9 @@ extern "C" fn tap_callback( return event; } let grabbed = shared.grabbed.load(Ordering::SeqCst); + if grabbed { + keep_cursor_hidden(shared); + } let inputs = unsafe { to_inputs(etype, event, grabbed) }; if etype != MOUSE_MOVED { tracing::trace!("tap event {etype} grabbed={grabbed} -> {inputs:?}"); @@ -415,7 +420,7 @@ fn apply(action: &Action) { match action { Action::Grab if !shared.grabbed.swap(true, Ordering::SeqCst) => { CGAssociateMouseAndMouseCursorPosition(false); - CGDisplayHideCursor(CGMainDisplayID()); + hide_cursor(shared); } Action::Release { warp } if shared.grabbed.swap(false, Ordering::SeqCst) => { CGWarpMouseCursorPosition(CGPoint { @@ -423,13 +428,68 @@ fn apply(action: &Action) { y: warp.y, }); CGAssociateMouseAndMouseCursorPosition(true); - CGDisplayShowCursor(CGMainDisplayID()); + allow_background_cursor(); + for _ in 0..shared.hides.swap(0, Ordering::SeqCst) { + CGDisplayShowCursor(CGMainDisplayID()); + } + // If macOS reset the count under us these overshoot, but the next grab's check + // re-hides. If it still says hidden, keep going (bounded) so it never stays + // invisible here. + for _ in 0..8 { + if CGCursorIsVisible() { + break; + } + CGDisplayShowCursor(CGMainDisplayID()); + } } _ => {} } } } +/// Let this background process hide the cursor. Private but long-standing (Synergy, Barrier +/// and Deskflow use it); set before every hide and show since macOS can drop it. +fn allow_background_cursor() { + let key = CFString::from_static_string("SetsCursorInBackground"); + unsafe { + let cid = _CGSDefaultConnection(); + CGSSetConnectionProperty( + cid, + cid, + key.as_concrete_TypeRef() as *const c_void, + CFBoolean::true_value().as_concrete_TypeRef() as *const c_void, + ); + } +} + +fn hide_cursor(shared: &Shared) { + allow_background_cursor(); + unsafe { CGDisplayHideCursor(CGMainDisplayID()) }; + shared.hides.fetch_add(1, Ordering::SeqCst); +} + +/// macOS occasionally shows the cursor again while it's on another computer (another app +/// setting its cursor, the Dock, display changes), leaving a frozen cursor on each screen. +/// Check a few times a second while grabbed and hide it again if so. +fn keep_cursor_hidden(shared: &Shared) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64); + let last = shared.checked.load(Ordering::Relaxed); + if now.saturating_sub(last) < 100 + || shared + .checked + .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + return; + } + if unsafe { CGCursorIsVisible() } { + tracing::debug!("cursor reappeared while remote; hiding it again"); + hide_cursor(shared); + } +} + /// True while macOS withholds keystrokes from every app (a password field, a locked screen, /// Terminal's Secure Keyboard Entry). The mouse still works; typing can't be forwarded. /// Is Caps Lock on here? diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0cf297a..340c52d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -61,7 +61,10 @@ Each starts when its permissions allow (macOS Accessibility, Linux `/dev/uinput` and is re-announced to peers in a fresh `Hello`, so granting a permission takes effect without a restart. While a node is being controlled, its own controller is suspended, so an injected cursor reaching an edge can't bounce on to a third machine; on macOS, injected events are also -tagged (`kCGEventSourceUserData`) and ignored by our own tap. +tagged (`kCGEventSourceUserData`) and ignored by our own tap. The one exception: once the +controlling computer has been still for 250 ms, pushing this computer's own mouse against the +edge that leads back to it takes the cursor over there, and the controlling computer, told by +that `Enter`, brings its own cursor home. ### Platform backends