From 6d87442d9a173506adf0a88c2a32ef77ecd73701 Mon Sep 17 00:00:00 2001 From: fc221 Date: Mon, 13 Jul 2026 23:47:44 +0800 Subject: [PATCH 01/12] fix(input): mac-side handoff and injection fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - park the controlled mac cursor on a clipping edge near the server side (arrow hotspot renders top-left, only right/bottom edges clip it to a sliver; corners trip macOS hot corners) — windows keeps the far corner - remote Caps Lock posts the ctrl+space input-source hotkey instead of a dead injected caps keycode, so the focused app's IME switches immediately - injected arrow/nav keys carry SecondaryFn (+NumericPad) flags so system and app shortcut matching recognises them - injected mouse buttons carry kCGMouseEventClickState via a click tracker (system doubleClickInterval, 8px chain distance) so double-clicks register Co-Authored-By: Claude Fable 5 --- src-tauri/src/input.rs | 449 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 438 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 8cba7e7..f0063dd 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -3911,11 +3911,20 @@ fn local_anchor_point(active: &ActiveTarget) -> (f64, f64) { local_return_point(active) } -/// When control returns to the local machine, move the controlled cursor into -/// the far (bottom-right) corner of the remote screen instead of leaving it -/// parked at the shared edge. True cursor hiding isn't reliably possible on the -/// controlled side, so tucking it into a corner is the seamless-feeling -/// approximation. +/// When control returns to the local machine, tuck the controlled cursor out +/// of the way. True cursor hiding isn't reliably possible on the controlled +/// side, so tucking it is the seamless-feeling approximation. +/// +/// Controlled macOS: park ON a clipping edge, at the end nearest this +/// machine. The arrow's hotspot is its top-left tip and the body extends +/// down-right, so only the RIGHT and BOTTOM screen edges clip it to a barely +/// visible sliver (why the old far-corner park "looked like a dot"); the left +/// and top edges show the full arrow. Corners themselves are avoided: macOS +/// hot corners fire on pointer position alone, so the far-corner park +/// triggered actions like "Show all applications" on every single handoff. +/// +/// Controlled Windows (and anything else): keep the long-standing far-corner +/// park unchanged. #[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] fn send_remote_cursor_park( quic_transport: &quic_transport::TransportHandle, @@ -3923,19 +3932,56 @@ fn send_remote_cursor_park( layout_state: &Arc>, input_events: &Arc, ) -> bool { + let (park_x, park_y) = remote_park_point(active); send_packet( quic_transport, &active.target, InputEvent::MouseMove { screen_id: active.current_screen_id.clone(), - x: (active.current_screen.width - 1).max(0), - y: (active.current_screen.height - 1).max(0), + x: park_x, + y: park_y, }, layout_state, input_events, ) } +/// Distance a shared-edge park keeps from the screen corners, so an exit near +/// the top/bottom of the edge cannot land the parked cursor inside a macOS +/// hot-corner trip zone. +const PARK_CORNER_CLEARANCE: i32 = 64; + +#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] +fn remote_park_point(active: &ActiveTarget) -> (i32, i32) { + let width = active.current_screen.width; + let height = active.current_screen.height; + if !active.target.target_platform.eq_ignore_ascii_case("macos") { + return ((width - 1).max(0), (height - 1).max(0)); + } + + // `edge` is the LOCAL screen edge crossed to enter the remote, so this + // machine sits on the OPPOSITE side of the controlled screen. Pick the + // clipping edge (right/bottom) closest to that side. + let clear = |position: i32, extent: i32| { + position.clamp( + PARK_CORNER_CLEARANCE.min((extent / 2).max(0)), + (extent - 1 - PARK_CORNER_CLEARANCE).max((extent / 2).max(0)), + ) + }; + let x = active.x.round() as i32; + let y = active.y.round() as i32; + match active.target.edge { + // This machine is west of the Mac: bottom edge, west end. + Edge::Right => (clear(0, width), (height - 1).max(0)), + // East: the east edge itself clips — keep the exit height. + Edge::Left => ((width - 1).max(0), clear(y, height)), + // North: east edge, north end. + Edge::Bottom => ((width - 1).max(0), clear(0, height)), + // South: bottom edge — keep the exit x. + Edge::Top => (clear(x, width), (height - 1).max(0)), + } +} + #[cfg(target_os = "macos")] fn enter_remote_target_macos(context: &MacCaptureContext, active_target: ActiveTarget) { use core_graphics::geometry::CGPoint; @@ -5192,11 +5238,158 @@ fn inject_mouse_move(x: i32, y: i32, drag_button: Option) { } } +/// One pressed-button record for injected macOS click counting. +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Copy)] +struct MacClickDown { + button: MouseButton, + x: i32, + y: i32, + at: Instant, + count: u8, +} + +/// Click counting for injected macOS mouse buttons. macOS does NOT infer +/// double-clicks from timing for synthetic events — every injected Down/Up +/// with `kCGMouseEventClickState` 0 is an independent single click, so remote +/// double-clicks never registered in apps. Replicate the native rule: a press +/// within the system double-click interval and a few px of the previous one +/// raises the click count (capped at triple), and the release repeats the +/// count of the press it pairs with. +#[cfg(target_os = "macos")] +#[derive(Debug, Default)] +struct MacClickTracker { + last_down: Option, + pressed: [Option; 3], +} + +#[cfg(target_os = "macos")] +impl MacClickTracker { + const MAX_DISTANCE_PX: i32 = 8; + + fn event_count( + &mut self, + button: MouseButton, + down: bool, + x: i32, + y: i32, + now: Instant, + double_click_interval: Duration, + ) -> i64 { + let index = match button { + MouseButton::Left => 0, + MouseButton::Right => 1, + MouseButton::Middle => 2, + }; + + if down { + let count = self + .last_down + .filter(|last| { + last.button == button + && now.saturating_duration_since(last.at) <= double_click_interval + && click_points_are_near(last.x, last.y, x, y, Self::MAX_DISTANCE_PX) + }) + .map(|last| last.count.saturating_add(1).min(3)) + .unwrap_or(1); + let click = MacClickDown { + button, + x, + y, + at: now, + count, + }; + self.last_down = Some(click); + self.pressed[index] = Some(click); + return i64::from(count); + } + + let Some(click) = self.pressed[index].take() else { + return 0; + }; + if click_points_are_near(click.x, click.y, x, y, Self::MAX_DISTANCE_PX) { + i64::from(click.count) + } else { + // The button moved while held: that was a drag, not a click, and + // it must not chain into a double-click either. + self.last_down = None; + 0 + } + } +} + +#[cfg(target_os = "macos")] +fn click_points_are_near(x1: i32, y1: i32, x2: i32, y2: i32, max_distance: i32) -> bool { + let dx = i64::from(x1) - i64::from(x2); + let dy = i64::from(y1) - i64::from(y2); + let max = i64::from(max_distance); + dx * dx + dy * dy <= max * max +} + +/// The user's configured double-click speed ([NSEvent doubleClickInterval]), +/// resolved once via the ObjC runtime and clamped to a sane range so a broken +/// answer degrades to the macOS default feel instead of breaking clicks. +#[cfg(target_os = "macos")] +fn macos_double_click_interval() -> Duration { + static INTERVAL: OnceLock = OnceLock::new(); + *INTERVAL.get_or_init(|| { + use std::ffi::c_void; + use std::os::raw::c_char; + + #[link(name = "objc")] + extern "C" { + fn objc_getClass(name: *const c_char) -> *mut c_void; + fn sel_registerName(name: *const c_char) -> *mut c_void; + fn objc_msgSend(); + } + + let seconds = unsafe { + let class = objc_getClass(b"NSEvent\0".as_ptr() as *const c_char); + if class.is_null() { + 0.5 + } else { + let selector = sel_registerName(b"doubleClickInterval\0".as_ptr() as *const c_char); + let get_interval: extern "C" fn(*mut c_void, *mut c_void) -> f64 = + std::mem::transmute(objc_msgSend as *const ()); + get_interval(class, selector) + } + }; + Duration::from_secs_f64(if seconds.is_finite() && (0.1..=2.0).contains(&seconds) { + seconds + } else { + 0.5 + }) + }) +} + +#[cfg(target_os = "macos")] +fn macos_click_state(button: MouseButton, down: bool, x: i32, y: i32) -> i64 { + macos_click_tracker() + .lock() + .map(|mut tracker| { + tracker.event_count( + button, + down, + x, + y, + Instant::now(), + macos_double_click_interval(), + ) + }) + .unwrap_or(if down { 1 } else { 0 }) +} + +#[cfg(target_os = "macos")] +fn macos_click_tracker() -> &'static Mutex { + static TRACKER: OnceLock> = OnceLock::new(); + TRACKER.get_or_init(|| Mutex::new(MacClickTracker::default())) +} + #[cfg(target_os = "macos")] fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) { use core_graphics::{ display::CGDisplay, - event::{CGEvent, CGEventTapLocation, CGEventType, CGMouseButton}, + event::{CGEvent, CGEventTapLocation, CGEventType, CGMouseButton, EventField}, event_source::{CGEventSource, CGEventSourceStateID}, geometry::CGPoint, }; @@ -5217,6 +5410,10 @@ fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) { let _ = CGDisplay::warp_mouse_cursor_position(point); if let Ok(event) = CGEvent::new_mouse_event(source, event_type, point, mouse_button) { + event.set_integer_value_field( + EventField::MOUSE_EVENT_CLICK_STATE, + macos_click_state(button, down, x, y), + ); event.post(CGEventTapLocation::HID); } } @@ -5247,11 +5444,20 @@ fn inject_scroll(delta_x: i32, delta_y: i32) { #[cfg(target_os = "macos")] static MAC_INJECT_FLAGS: AtomicU64 = AtomicU64::new(0); +/// Latch so a held (auto-repeating) remote Caps Lock toggles the input source +/// exactly once until its key-up arrives. +#[cfg(target_os = "macos")] +static MACOS_CAPS_LOCK_DOWN: AtomicBool = AtomicBool::new(false); + /// Clears the tracked injected-modifier flags. Called when receiving stops so a /// dropped modifier key-up cannot leave Shift/Ctrl/Cmd stuck on for later keys. #[cfg(target_os = "macos")] pub fn reset_injected_modifiers() { MAC_INJECT_FLAGS.store(0, Ordering::Relaxed); + MACOS_CAPS_LOCK_DOWN.store(false, Ordering::Relaxed); + if let Ok(mut tracker) = macos_click_tracker().lock() { + *tracker = MacClickTracker::default(); + } } #[cfg(not(target_os = "macos"))] @@ -5279,6 +5485,30 @@ fn inject_key(key_code: u16, down: bool) { event_source::{CGEventSource, CGEventSourceStateID}, }; + // VK_CAPITAL: replicate the macOS "Caps Lock switches input sources" + // behaviour for remote input. macOS honours that setting only for the + // physical key — an injected caps keycode toggles neither the IME nor the + // caps state — so post the system "Select the previous input source" + // hotkey (⌃Space) instead: HIToolbox then performs the switch exactly as + // for a physical press, including refreshing the focused app's input + // session (TISSelectInputSource from a background process updates the + // menu-bar indicator but the focused app keeps typing in the old source + // until refocused). Remote Caps Lock therefore never acts as a + // letter-case toggle on this Mac. + // ponytail: assumes the ⌃Space symbolic hotkey is enabled (macOS default, + // verified on this deployment); read com.apple.symbolichotkeys key 60 if + // this ever needs to adapt. + if key_code == 0x14 { + if down { + if !MACOS_CAPS_LOCK_DOWN.swap(true, Ordering::Relaxed) { + macos_post_select_previous_input_source(); + } + } else { + MACOS_CAPS_LOCK_DOWN.store(false, Ordering::Relaxed); + } + return; + } + // Keep the running modifier state in sync, so the modifier event itself and // every later key carry the right flags. if let Some(flag) = windows_vk_to_mac_flag(key_code) { @@ -5301,15 +5531,79 @@ fn inject_key(key_code: u16, down: bool) { }; match CGEvent::new_keyboard_event(source, mac_code, down) { Ok(event) => { - event.set_flags(CGEventFlags::from_bits_truncate( - MAC_INJECT_FLAGS.load(Ordering::Relaxed), - )); + let mut flags = CGEventFlags::from_bits_truncate(MAC_INJECT_FLAGS.load(Ordering::Relaxed)); + // A physical Mac keyboard stamps the function-section flags on + // arrow/nav keys (arrows additionally carry the numeric-pad bit). + // Shortcut matching — system ones like ⌃← "move left a space" and + // app key equivalents like ⌘← — compares those flags, so injected + // arrows without them fire nothing: the historic "Ctrl+arrows do + // nothing on the Mac" bug. + flags |= mac_function_section_flags(mac_code); + event.set_flags(flags); event.post(CGEventTapLocation::HID); } Err(_) => log::warn!("inject_key: failed to build keyboard event for mac code {mac_code}"), } } +/// Extra CGEventFlags a physical keyboard sets for function-section keys: +/// arrows (123-126) carry Fn + numeric-pad; Home/End/PageUp/PageDown/forward +/// Delete carry Fn. Everything else gets nothing extra. +#[cfg(target_os = "macos")] +fn mac_function_section_flags(mac_code: u16) -> core_graphics::event::CGEventFlags { + use core_graphics::event::CGEventFlags; + + match mac_code { + // Left, Right, Down, Up + 123..=126 => CGEventFlags::CGEventFlagSecondaryFn | CGEventFlags::CGEventFlagNumericPad, + // Home(115), PageUp(116), forward Delete(117), End(119), PageDown(121) + 115 | 116 | 117 | 119 | 121 => CGEventFlags::CGEventFlagSecondaryFn, + _ => CGEventFlags::empty(), + } +} + +/// Posts the system input-source toggle hotkey (⌃Space, symbolic hotkey 60) +/// as a full physical-like sequence: Control down, Space down/up, Control up. +/// Flags are set per event and deliberately plain ⌃ — a concurrently held +/// remote modifier would form a different chord and miss the hotkey; the next +/// injected key restores the tracked flags anyway. +#[cfg(target_os = "macos")] +fn macos_post_select_previous_input_source() { + use core_graphics::{ + event::{CGEvent, CGEventFlags, CGEventTapLocation}, + event_source::{CGEventSource, CGEventSourceStateID}, + }; + + const MAC_KEY_CONTROL: u16 = 59; // kVK_Control + const MAC_KEY_SPACE: u16 = 49; // kVK_Space + + let control = CGEventFlags::CGEventFlagControl; + let no_flags = CGEventFlags::empty(); + let sequence = [ + (MAC_KEY_CONTROL, true, control), + (MAC_KEY_SPACE, true, control), + (MAC_KEY_SPACE, false, control), + (MAC_KEY_CONTROL, false, no_flags), + ]; + for (mac_code, down, flags) in sequence { + let Ok(source) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + log::warn!("caps toggle: failed to create CGEventSource"); + return; + }; + match CGEvent::new_keyboard_event(source, mac_code, down) { + Ok(event) => { + event.set_flags(flags); + event.post(CGEventTapLocation::HID); + } + Err(_) => { + log::warn!("caps toggle: failed to build keyboard event for mac code {mac_code}"); + return; + } + } + } + log::info!("[diag] caps: posted input-source toggle (ctrl+space)"); +} + #[cfg(target_os = "windows")] fn inject_mouse_move(x: i32, y: i32, drag_button: Option) { crate::windows_input::inject_mouse_move(x, y, drag_button); @@ -5691,6 +5985,139 @@ mod tests { assert_eq!(guarded.x, 1.0); } + #[cfg(target_os = "macos")] + #[test] + fn macos_click_tracker_emits_matching_double_click_counts() { + let mut tracker = MacClickTracker::default(); + let start = Instant::now(); + let interval = Duration::from_millis(500); + + assert_eq!( + tracker.event_count(MouseButton::Left, true, 100, 200, start, interval), + 1 + ); + assert_eq!( + tracker.event_count( + MouseButton::Left, + false, + 100, + 200, + start + Duration::from_millis(40), + interval, + ), + 1 + ); + assert_eq!( + tracker.event_count( + MouseButton::Left, + true, + 102, + 201, + start + Duration::from_millis(180), + interval, + ), + 2, + "a nearby press inside the interval must raise the click count" + ); + assert_eq!( + tracker.event_count( + MouseButton::Left, + false, + 102, + 201, + start + Duration::from_millis(220), + interval, + ), + 2 + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_click_tracker_resets_after_drag_timeout_or_button_change() { + let mut tracker = MacClickTracker::default(); + let start = Instant::now(); + let interval = Duration::from_millis(500); + + assert_eq!( + tracker.event_count(MouseButton::Left, true, 10, 10, start, interval), + 1 + ); + assert_eq!( + tracker.event_count( + MouseButton::Left, + false, + 30, + 30, + start + Duration::from_millis(40), + interval, + ), + 0, + "a drag release is not a click" + ); + assert_eq!( + tracker.event_count( + MouseButton::Right, + true, + 10, + 10, + start + Duration::from_millis(100), + interval, + ), + 1, + "a different button starts its own click chain" + ); + assert_eq!( + tracker.event_count( + MouseButton::Left, + true, + 10, + 10, + start + Duration::from_millis(700), + interval, + ), + 1, + "a press after the interval starts over at a single click" + ); + } + + #[test] + fn remote_park_point_tucks_mac_at_shared_edge_and_keeps_windows_corner() { + let target = target_for_coordinate_tests(); // edge=Right, platform=windows, remote 2560x1440 + let remote = target.remote_screen.clone(); + let mut active = ActiveTarget { + target, + current_screen: remote.clone(), + current_screen_id: remote.id.clone(), + x: 12.0, + y: 700.0, + invert_y: false, + }; + + // Controlled Windows keeps the long-standing far-corner park. + assert_eq!(remote_park_point(&active), (2559, 1439)); + + // Controlled macOS entered through OUR right edge (this machine sits + // to its west): bottom clipping edge, west end, clear of the corner. + active.target.target_platform = "macos".into(); + assert_eq!( + remote_park_point(&active), + (PARK_CORNER_CLEARANCE, 1439) + ); + + // Entered through OUR left edge (this machine to its east): the east + // edge clips the arrow itself — park there at the exit height. + active.target.edge = Edge::Left; + assert_eq!(remote_park_point(&active), (2559, 700)); + + // An exit right next to a corner still keeps hot-corner clearance. + active.y = 1439.0; + assert_eq!( + remote_park_point(&active), + (2559, 1439 - PARK_CORNER_CLEARANCE) + ); + } + #[test] fn screen_switch_hotkey_matching_requires_exact_modifiers() { let hotkeys = crate::ScreenSwitchHotkeys { From 3eee2cf3e9d4ca62431cb1e779b3f71090fd6858 Mon Sep 17 00:00:00 2001 From: fc221 Date: Tue, 14 Jul 2026 08:13:34 +0800 Subject: [PATCH 02/12] perf(transport): stop the command loop from blocking on dead peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport loop serially awaited every command: a dead peer's 2s connect timeout (hit every ~3s by discovery warming and every 2s by clipboard retries) or one 48MB stream write stalled every queued input datagram behind it — the periodic input freezes and the 'QUIC send failed' warn line every few seconds for hours. - datagrams now send synchronously on established connections (quinn's send_datagram is not async) and never wait for a dial; a missing peer gets one background connect task, marked in the map so move bursts cannot spawn a connect storm - stream sends run in spawned tasks, capped by a semaphore (8 in flight) - consecutive-failure fast-fail per peer address (concept from PR #22): after 3 strikes the caller-facing handle returns an error immediately so the input layer releases the cursor, and dial attempts are muted to one probe per 3s window until the peer recovers - failure logging collapses to first-failure + entering-mute + recovery instead of one warn per attempt - drop the always-true ack_required plumbing Co-Authored-By: Claude Fable 5 --- src-tauri/src/quic_transport.rs | 349 +++++++++++++++++++++++++------- 1 file changed, 281 insertions(+), 68 deletions(-) diff --git a/src-tauri/src/quic_transport.rs b/src-tauri/src/quic_transport.rs index ed98641..d5cb7b7 100644 --- a/src-tauri/src/quic_transport.rs +++ b/src-tauri/src/quic_transport.rs @@ -3,9 +3,9 @@ use std::{ fs, net::{SocketAddr, ToSocketAddrs}, path::{Path, PathBuf}, - sync::{mpsc, Arc}, + sync::{mpsc, Arc, Mutex}, thread, - time::Duration, + time::{Duration, Instant}, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; @@ -33,6 +33,16 @@ const MAX_DATAGRAM_BYTES: usize = 16 * 1024; pub(crate) const MAX_STREAM_BYTES: usize = 48 * 1024 * 1024; const PORT_SCAN_COUNT: u16 = 64; const QUIC_WORKER_THREADS: usize = 2; +// Datagram-health fast-fail (concept adopted from PR #22): after this many +// consecutive send/connect failures to a peer, sends short-circuit with an +// error until the retry window elapses, so the input layer releases the +// cursor immediately instead of freezing behind connect timeouts. +const DATAGRAM_FAIL_THRESHOLD: u32 = 3; +const DATAGRAM_RETRY_WINDOW: Duration = Duration::from_secs(3); +const MAX_HEALTH_PEERS: usize = 64; +// Streams (clipboard, files) are handled in spawned tasks; cap the concurrent +// in-flight count so a burst cannot spawn unbounded copies of a 48MB write. +const MAX_CONCURRENT_STREAMS: usize = 8; type DatagramHandler = Arc, SocketAddr) + Send + Sync + 'static>; type StreamHandler = Arc, SocketAddr) -> bool + Send + Sync + 'static>; @@ -44,11 +54,78 @@ pub struct PeerEndpoint { pub protocol_version: u16, } +/// Consecutive-failure record for one peer address. Shared between the +/// caller-facing handle (fast-fail check) and the transport loop (updates). +#[derive(Debug, Clone, Copy)] +struct PeerHealth { + consecutive_failures: u32, + last_failure: Instant, +} + +type HealthMap = Arc>>; + +fn peer_fast_fail_active(health: &HealthMap, addr: &str) -> bool { + health + .lock() + .map(|health| { + health.get(addr).is_some_and(|entry| { + entry.consecutive_failures >= DATAGRAM_FAIL_THRESHOLD + && entry.last_failure.elapsed() < DATAGRAM_RETRY_WINDOW + }) + }) + .unwrap_or(false) +} + +fn record_peer_failure(health: &HealthMap, addr: &str, error: &str) { + let Ok(mut health) = health.lock() else { + return; + }; + if health.len() >= MAX_HEALTH_PEERS && !health.contains_key(addr) { + if let Some(stale) = health + .iter() + .min_by_key(|(_, entry)| entry.last_failure) + .map(|(key, _)| key.clone()) + { + health.remove(&stale); + } + } + let now = Instant::now(); + let entry = health.entry(addr.to_string()).or_insert(PeerHealth { + consecutive_failures: 0, + last_failure: now, + }); + entry.consecutive_failures = entry.consecutive_failures.saturating_add(1); + entry.last_failure = now; + // Log the first failure and the transition into fast-fail; everything in + // between and every muted retry is debug. The old unconditional warn wrote + // a disk line every few seconds for as long as a peer stayed unreachable. + match entry.consecutive_failures { + 1 => log::warn!("QUIC send to {addr} failed: {error}"), + DATAGRAM_FAIL_THRESHOLD => log::warn!( + "QUIC sends to {addr} keep failing; muting attempts to one probe per {}s: {error}", + DATAGRAM_RETRY_WINDOW.as_secs() + ), + _ => log::debug!("QUIC send to {addr} still failing: {error}"), + } +} + +fn record_peer_success(health: &HealthMap, addr: &str) { + let Ok(mut health) = health.lock() else { + return; + }; + if let Some(entry) = health.remove(addr) { + if entry.consecutive_failures >= DATAGRAM_FAIL_THRESHOLD { + log::info!("QUIC sends to {addr} recovered"); + } + } +} + #[derive(Clone)] pub struct TransportHandle { commands: tokio_mpsc::UnboundedSender, port: u16, public_key: String, + peer_health: HealthMap, } impl TransportHandle { @@ -75,6 +152,14 @@ impl TransportHandle { payload.len() )); } + // Fail fast while the peer is known-dead so the input layer releases + // the cursor instead of streaming moves into a black hole. + if peer_fast_fail_active(&self.peer_health, &peer.addr) { + return Err(format!( + "QUIC peer {} unreachable ({DATAGRAM_FAIL_THRESHOLD}+ consecutive failures)", + peer.addr + )); + } self.commands .send(TransportCommand::SendDatagram { peer, payload }) @@ -85,15 +170,6 @@ impl TransportHandle { &self, peer: PeerEndpoint, payload: Vec, - ) -> Result<(), String> { - self.send_stream_inner(peer, payload, true) - } - - fn send_stream_inner( - &self, - peer: PeerEndpoint, - payload: Vec, - ack_required: bool, ) -> Result<(), String> { if payload.len() > MAX_STREAM_BYTES { return Err(format!( @@ -101,13 +177,18 @@ impl TransportHandle { payload.len() )); } + if peer_fast_fail_active(&self.peer_health, &peer.addr) { + return Err(format!( + "QUIC peer {} unreachable ({DATAGRAM_FAIL_THRESHOLD}+ consecutive failures)", + peer.addr + )); + } let (result_tx, result_rx) = mpsc::channel(); self.commands .send(TransportCommand::SendStream { peer, payload, - ack_required, result: result_tx, }) .map_err(|_| "QUIC transport is stopped".to_string())?; @@ -129,7 +210,6 @@ enum TransportCommand { SendStream { peer: PeerEndpoint, payload: Vec, - ack_required: bool, result: mpsc::Sender>, }, Shutdown, @@ -154,6 +234,8 @@ pub fn start( let identity = load_or_create_identity(&identity_dir)?; let (ready_tx, ready_rx) = mpsc::channel(); let (command_tx, command_rx) = tokio_mpsc::unbounded_channel(); + let peer_health: HealthMap = Arc::new(Mutex::new(HashMap::new())); + let loop_health = Arc::clone(&peer_health); thread::Builder::new() .name("mykvm-quic-transport".into()) @@ -177,6 +259,7 @@ pub fn start( command_rx, on_datagram, on_stream, + loop_health, ready_tx, )); }) @@ -190,6 +273,7 @@ pub fn start( commands: command_tx, port: ready.port, public_key: ready.public_key, + peer_health, }) } @@ -198,12 +282,22 @@ struct ReadyTransport { public_key: String, } +/// A cached peer connection, or a marker that a background task is already +/// establishing one (so a burst of mouse moves cannot spawn a connect storm). +enum ConnectionSlot { + Connecting, + Ready(quinn::Connection), +} + +type ConnectionMap = Arc>>; + async fn run_transport( preferred_port: u16, identity: TransportIdentity, mut commands: tokio_mpsc::UnboundedReceiver, on_datagram: DatagramHandler, on_stream: StreamHandler, + health: HealthMap, ready_tx: mpsc::Sender>, ) { let (endpoint, public_key) = match bind_endpoint(preferred_port, &identity) { @@ -225,27 +319,41 @@ async fn run_transport( let _ = ready_tx.send(Ok(ReadyTransport { port, public_key })); spawn_accept_loop(endpoint.clone(), on_datagram, on_stream); - let mut connections: HashMap = HashMap::new(); + // The command loop must never await network progress: one dead peer's 2s + // connect timeout or one 48MB stream write would stall every queued input + // datagram behind it (the "periodic input freeze + warn every 4s" bug). + // Datagrams go out synchronously on established connections; connection + // establishment and stream sends run in spawned tasks. + let connections: ConnectionMap = Arc::new(Mutex::new(HashMap::new())); + let stream_slots = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_STREAMS)); while let Some(command) = commands.recv().await { match command { TransportCommand::SendDatagram { peer, payload } => { - if let Err(error) = send_datagram(&endpoint, &mut connections, peer, payload).await - { - log::warn!("QUIC datagram send failed: {error}"); - } + send_datagram_nonblocking(&endpoint, &connections, &health, peer, payload); } TransportCommand::SendStream { peer, payload, - ack_required, result, } => { - let send_result = - send_stream(&endpoint, &mut connections, peer, payload, ack_required).await; - if let Err(error) = &send_result { - log::warn!("QUIC stream send failed: {error}"); - } - let _ = result.send(send_result); + let Ok(permit) = Arc::clone(&stream_slots).try_acquire_owned() else { + let _ = result.send(Err(format!( + "QUIC stream queue is full ({MAX_CONCURRENT_STREAMS} in flight)" + ))); + continue; + }; + let endpoint = endpoint.clone(); + let connections = Arc::clone(&connections); + let health = Arc::clone(&health); + tokio::spawn(async move { + let outcome = + send_stream_task(&endpoint, &connections, &health, peer, payload).await; + if let Err(error) = &outcome { + log::warn!("QUIC stream send failed: {error}"); + } + let _ = result.send(outcome); + drop(permit); + }); } TransportCommand::Shutdown => break, } @@ -584,33 +692,129 @@ fn spawn_stream_reader( }); } -async fn send_datagram( +/// Datagram send that never awaits: an established connection queues the +/// payload synchronously (quinn's `send_datagram` is not async); a missing or +/// dead connection drops this payload and kicks off a background connect — +/// input datagrams are latest-wins, the next move follows within ~8ms, and +/// `warm_quic_peer` keeps connections pre-established outside crossings. +fn send_datagram_nonblocking( endpoint: &Endpoint, - connections: &mut HashMap, + connections: &ConnectionMap, + health: &HealthMap, peer: PeerEndpoint, payload: Vec, -) -> Result<(), String> { - let (key, connection) = connection_for(endpoint, connections, &peer).await?; - match connection.send_datagram(payload.into()) { - Ok(()) => Ok(()), +) { + let key = match peer_key(&peer) { + Ok(key) => key, Err(error) => { - connections.remove(&key); - Err(error.to_string()) + record_peer_failure(health, &peer.addr, &error); + return; + } + }; + + let ready = { + let Ok(mut map) = connections.lock() else { + return; + }; + match map.get(&key) { + Some(ConnectionSlot::Ready(connection)) if connection.close_reason().is_none() => { + Some(connection.clone()) + } + // A background task is already dialing this peer; drop the payload. + Some(ConnectionSlot::Connecting) => return, + _ => { + map.remove(&key); + None + } } + }; + + if let Some(connection) = ready { + match connection.send_datagram(payload.into()) { + Ok(()) => record_peer_success(health, &peer.addr), + Err(error) => { + if let Ok(mut map) = connections.lock() { + map.remove(&key); + } + record_peer_failure(health, &peer.addr, &error.to_string()); + } + } + return; } + + // Known-dead peer inside its retry window: skip even the background dial + // so an unreachable box costs nothing between probes. + if peer_fast_fail_active(health, &peer.addr) { + return; + } + if let Ok(mut map) = connections.lock() { + map.insert(key.clone(), ConnectionSlot::Connecting); + } + let endpoint = endpoint.clone(); + let connections = Arc::clone(connections); + let health = Arc::clone(health); + tokio::spawn(async move { + match establish_connection(&endpoint, &peer, &key).await { + Ok(connection) => { + if let Ok(mut map) = connections.lock() { + map.insert(key, ConnectionSlot::Ready(connection)); + } + record_peer_success(&health, &peer.addr); + } + Err(error) => { + if let Ok(mut map) = connections.lock() { + map.remove(&key); + } + record_peer_failure(&health, &peer.addr, &error); + } + } + }); } -async fn send_stream( +/// Stream send running inside its own task: reuses a ready connection or +/// dials one inline (a racing datagram dial at worst produces one redundant +/// connection that is dropped on replacement — streams are rare). +async fn send_stream_task( endpoint: &Endpoint, - connections: &mut HashMap, + connections: &ConnectionMap, + health: &HealthMap, peer: PeerEndpoint, payload: Vec, - ack_required: bool, ) -> Result<(), String> { - let (key, connection) = connection_for(endpoint, connections, &peer).await?; - let result = send_stream_on_connection(connection, payload, ack_required).await; + let key = peer_key(&peer)?; + let existing = { + let Ok(map) = connections.lock() else { + return Err("QUIC connection map is poisoned".into()); + }; + match map.get(&key) { + Some(ConnectionSlot::Ready(connection)) if connection.close_reason().is_none() => { + Some(connection.clone()) + } + _ => None, + } + }; + let connection = match existing { + Some(connection) => connection, + None => match establish_connection(endpoint, &peer, &key).await { + Ok(connection) => { + if let Ok(mut map) = connections.lock() { + map.insert(key.clone(), ConnectionSlot::Ready(connection.clone())); + } + record_peer_success(health, &peer.addr); + connection + } + Err(error) => { + record_peer_failure(health, &peer.addr, &error); + return Err(error); + } + }, + }; + + let result = send_stream_on_connection(connection, payload).await; if result.is_err() { - connections.remove(&key); + if let Ok(mut map) = connections.lock() { + map.remove(&key); + } } result } @@ -618,7 +822,6 @@ async fn send_stream( async fn send_stream_on_connection( connection: quinn::Connection, payload: Vec, - ack_required: bool, ) -> Result<(), String> { let (mut send, mut recv) = connection .open_bi() @@ -629,19 +832,11 @@ async fn send_stream_on_connection( .map_err(|error| format!("failed to write QUIC stream: {error}"))?; send.finish() .map_err(|error| format!("failed to finish QUIC stream: {error}"))?; - let ack = tokio::time::timeout(Duration::from_millis(500), recv.read_to_end(64)).await; - if ack_required { - match ack { - Ok(Ok(bytes)) => verify_stream_ack(&bytes)?, - Ok(Err(error)) => { - return Err(format!("failed to read QUIC stream ack: {error}")); - } - Err(_) => { - return Err("QUIC stream ack timed out".into()); - } - } + match tokio::time::timeout(Duration::from_millis(500), recv.read_to_end(64)).await { + Ok(Ok(bytes)) => verify_stream_ack(&bytes), + Ok(Err(error)) => Err(format!("failed to read QUIC stream ack: {error}")), + Err(_) => Err("QUIC stream ack timed out".into()), } - Ok(()) } fn verify_stream_ack(bytes: &[u8]) -> Result<(), String> { @@ -655,30 +850,19 @@ fn verify_stream_ack(bytes: &[u8]) -> Result<(), String> { } } -async fn connection_for( +async fn establish_connection( endpoint: &Endpoint, - connections: &mut HashMap, peer: &PeerEndpoint, -) -> Result<(PeerKey, quinn::Connection), String> { - let key = peer_key(peer)?; - - if let Some(connection) = connections.get(&key) { - if connection.close_reason().is_none() { - return Ok((key, connection.clone())); - } - } - connections.remove(&key); - + key: &PeerKey, +) -> Result { let config = client_config(peer)?; let connecting = endpoint .connect_with(config, key.addr, SERVER_NAME) .map_err(|error| format!("failed to start QUIC connection to {}: {error}", key.addr))?; - let connection = tokio::time::timeout(Duration::from_secs(2), connecting) + tokio::time::timeout(Duration::from_secs(2), connecting) .await .map_err(|_| format!("QUIC connection to {} timed out", key.addr))? - .map_err(|error| format!("failed to connect QUIC to {}: {error}", key.addr))?; - connections.insert(key.clone(), connection.clone()); - Ok((key, connection)) + .map_err(|error| format!("failed to connect QUIC to {}: {error}", key.addr)) } fn peer_key(peer: &PeerEndpoint) -> Result { @@ -699,6 +883,35 @@ fn resolve_peer_addr(addr: &str) -> Result { mod tests { use super::*; + #[test] + fn peer_health_fast_fails_after_threshold_and_recovers_on_success() { + let health: HealthMap = Arc::new(Mutex::new(HashMap::new())); + let addr = "10.0.0.9:47834"; + + for strikes in 1..DATAGRAM_FAIL_THRESHOLD { + record_peer_failure(&health, addr, "timeout"); + assert!( + !peer_fast_fail_active(&health, addr), + "{strikes} failures must not fast-fail yet" + ); + } + record_peer_failure(&health, addr, "timeout"); + assert!( + peer_fast_fail_active(&health, addr), + "reaching the threshold enters fast-fail" + ); + assert!( + !peer_fast_fail_active(&health, "10.0.0.8:47834"), + "health is tracked per peer address" + ); + + record_peer_success(&health, addr); + assert!( + !peer_fast_fail_active(&health, addr), + "one successful send clears the fast-fail state" + ); + } + fn make_cert() -> CertificateDer<'static> { rcgen::generate_simple_self_signed(vec!["mykvm.local".to_string()]) .unwrap() From 09a3fc4404e06a96241d8f248996fd161f593cac Mon Sep 17 00:00:00 2001 From: fc221 Date: Tue, 14 Jul 2026 08:14:35 +0800 Subject: [PATCH 03/12] perf(input): block on the hook message queue instead of polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows hook thread drained PeekMessage then slept 10ms per iteration; low-level hook callbacks are only dispatched while the installing thread services its queue, so every mouse/keyboard event waited out the remainder of the sleep — ~15.6ms real without timeBeginPeriod — batching a 1000Hz mouse into ~64Hz bursts on the local machine and the forwarding path alike. MsgWaitForMultipleObjects wakes on the first queued message (20ms idle timeout keeps the desktop and switch-request checks running). Ported from the bate line. Co-Authored-By: Claude Fable 5 --- src-tauri/src/input.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index f0063dd..1040a0f 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -938,8 +938,8 @@ fn start_platform_capture( switch_request: Arc>>, ) -> NativeStageStatus { use windows_sys::Win32::UI::WindowsAndMessaging::{ - PeekMessageW, SetWindowsHookExW, UnhookWindowsHookEx, MSG, PM_REMOVE, WH_KEYBOARD_LL, - WH_MOUSE_LL, + MsgWaitForMultipleObjects, PeekMessageW, SetWindowsHookExW, UnhookWindowsHookEx, MSG, + PM_REMOVE, QS_ALLINPUT, WH_KEYBOARD_LL, WH_MOUSE_LL, }; let target_count = targets.len(); @@ -1018,10 +1018,18 @@ fn start_platform_capture( } } drain_switch_request_windows(&context); + // Low-level hook callbacks are dispatched only while this thread + // services its message queue. Blocking on the queue (with a short + // timeout for the desktop/switch checks above) instead of sleeping + // 10ms between polls removes up to 10-16ms of added latency per + // input event — the sleep also quantised to ~15.6ms without a + // timeBeginPeriod call, batching a 1000Hz mouse into ~64Hz bursts. + // Slow queue servicing is also what makes Windows silently drop + // low-level hooks. unsafe { + let _ = MsgWaitForMultipleObjects(0, std::ptr::null(), 0, 20, QS_ALLINPUT); while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {} } - thread::sleep(Duration::from_millis(10)); } unsafe { From 741aef6648529386eeb24c42bbe9c918c1bbe115 Mon Sep 17 00:00:00 2001 From: fc221 Date: Tue, 14 Jul 2026 08:16:27 +0800 Subject: [PATCH 04/12] perf(clipboard): move stream handling out of the layout lock on_stream held the layout mutex through clipboard writes (retry sleeps, pbcopy spawn, up to 32MB base64 decode) and file-transfer disk writes; the input capture and receive hot paths block on the same mutex, so every clipboard sync froze input for tens to hundreds of ms. Snapshot the layout under the lock and run the handlers on the clone. Co-Authored-By: Claude Fable 5 --- src-tauri/src/lib.rs | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4ef4127..19ae62b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -735,7 +735,6 @@ impl AppRuntime { let layout_for_input = Arc::clone(&self.layout); let layout_for_clipboard = Arc::clone(&self.layout); - let layout_for_file_transfer = Arc::clone(&self.layout); let layout_for_pairing = Arc::clone(&self.layout); let native_layout_for_input = self.native_layout(); let input_receive_enabled = Arc::clone(&self.input_receive_enabled); @@ -797,27 +796,34 @@ impl AppRuntime { return true; } - if let Ok(layout) = layout_for_file_transfer.lock() { - let current_peer = local_peer_from_layout(&layout); - if handle_file_transfer_packet( - &payload, - &layout, - ¤t_peer.id, - &file_transfers, - &app_handle_for_file_transfer, - ) { - transport_packets_for_stream.fetch_add(1, Ordering::Relaxed); - return true; - } + // Snapshot the layout instead of holding the lock through the + // handlers: clipboard writes retry with sleeps, spawn pbcopy and + // decode up to 32MB of base64, and file transfers write chunks to + // disk — holding the layout lock through any of that stalls the + // input hot paths (which take the same lock) for tens to hundreds + // of ms per sync. Stream packets are rare; one clone is nothing. + let layout = { + let Ok(layout) = layout_for_clipboard.lock() else { + return false; + }; + layout.clone() + }; + let current_peer = local_peer_from_layout(&layout); + + if handle_file_transfer_packet( + &payload, + &layout, + ¤t_peer.id, + &file_transfers, + &app_handle_for_file_transfer, + ) { + transport_packets_for_stream.fetch_add(1, Ordering::Relaxed); + return true; } if !clipboard_receive_enabled.load(Ordering::Relaxed) { return false; } - let Ok(layout) = layout_for_clipboard.lock() else { - return false; - }; - let current_peer = local_peer_from_layout(&layout); if handle_clipboard_packet( &payload, &layout, From e055dd52e0d058773b670af1fb732417c97575fe Mon Sep 17 00:00:00 2001 From: fc221 Date: Tue, 14 Jul 2026 08:22:18 +0800 Subject: [PATCH 05/12] chore(lib): delete the disabled edge-drop window feature EDGE_DROP_WINDOWS_ENABLED has been hardcoded false; everything behind it was dead: the edge drop window sync + specs + geometry helpers, the pointer-drop destination chain (destination_hint wire field, pointer receive root, Finder insertion-directory probe, drop landing window, pointer position readers), their consts, the runtime field, five tests and the test-only wrappers, and the two orphaned HTML assets. destination_hint was serde-default/optional on the wire, so removal stays compatible with older peers. ~720 lines removed. Co-Authored-By: Claude Fable 5 --- public/edge-drop.html | 18 - public/file-drop-landing.html | 89 ---- src-tauri/src/lib.rs | 950 +--------------------------------- 3 files changed, 8 insertions(+), 1049 deletions(-) delete mode 100644 public/edge-drop.html delete mode 100644 public/file-drop-landing.html diff --git a/public/edge-drop.html b/public/edge-drop.html deleted file mode 100644 index fbbb676..0000000 --- a/public/edge-drop.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - diff --git a/public/file-drop-landing.html b/public/file-drop-landing.html deleted file mode 100644 index ecae259..0000000 --- a/public/file-drop-landing.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 19ae62b..1123f58 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,6 @@ use std::{ - collections::{hash_map::DefaultHasher, HashMap, HashSet}, + collections::HashMap, env, fs, - hash::{Hash, Hasher}, io::{Read, Write}, net::{Ipv4Addr, SocketAddr, UdpSocket}, path::{Path, PathBuf}, @@ -19,8 +18,7 @@ use serde::{Deserialize, Serialize}; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, DragDropEvent, Emitter, Manager, Monitor, WebviewUrl, WebviewWindowBuilder, - WindowEvent, Wry, + AppHandle, Emitter, Manager, Monitor, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, }; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; @@ -67,13 +65,6 @@ const CLIPBOARD_WRITE_RETRY_DELAY_MS: u64 = 30; const FILE_TRANSFER_PROTOCOL: &str = "mykvm.file-transfer.v1"; const FILE_TRANSFER_CHUNK_BYTES: usize = 256 * 1024; const FILE_TRANSFER_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024; -const FILE_TRANSFER_DESTINATION_POINTER: &str = "pointer"; -const EDGE_DROP_WINDOWS_ENABLED: bool = false; -const EDGE_DROP_LABEL_PREFIX: &str = "mykvm-edge-drop-"; -const EDGE_DROP_THICKNESS: i32 = 8; -const FILE_DROP_LANDING_LABEL: &str = "mykvm-file-drop-landing"; -const FILE_DROP_LANDING_WIDTH: f64 = 96.0; -const FILE_DROP_LANDING_HEIGHT: f64 = 72.0; const LOG_MAX_FILE_SIZE_BYTES: u128 = 1024 * 1024; const AUTOSTART_ARG: &str = "--mykvm-autostart"; const QUIT_EXISTING_ARG: &str = "--mykvm-quit-existing"; @@ -486,8 +477,6 @@ struct FileTransferPacket { cluster_id: String, pair_secret: String, file_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - destination_hint: Option, total_bytes: u64, chunk_index: u64, offset: u64, @@ -495,35 +484,6 @@ struct FileTransferPacket { data: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum EdgeDropSide { - Left, - Right, - Top, - Bottom, -} - -impl EdgeDropSide { - fn as_str(self) -> &'static str { - match self { - EdgeDropSide::Left => "left", - EdgeDropSide::Right => "right", - EdgeDropSide::Top => "top", - EdgeDropSide::Bottom => "bottom", - } - } -} - -#[derive(Debug, Clone, PartialEq)] -struct EdgeDropWindowSpec { - label: String, - target_device_id: String, - x: f64, - y: f64, - width: f64, - height: f64, -} - struct AppRuntime { app_handle: AppHandle, layout: Arc>, @@ -532,7 +492,6 @@ struct AppRuntime { peers: Arc>>, pairing_challenge: Arc>>, file_transfers: Arc>>, - edge_drop_targets: Arc>>, quic_transport: Mutex>, discovery_stop: Mutex>>, input_stop: Mutex>>, @@ -571,7 +530,6 @@ impl AppRuntime { peers: Arc::new(Mutex::new(Vec::new())), pairing_challenge: Arc::new(Mutex::new(None)), file_transfers: Arc::new(Mutex::new(HashMap::new())), - edge_drop_targets: Arc::new(Mutex::new(HashMap::new())), quic_transport: Mutex::new(None), discovery_stop: Mutex::new(None), input_stop: Mutex::new(None), @@ -2126,15 +2084,8 @@ fn send_files_to_device( paths: Vec, state: tauri::State<'_, AppRuntime>, ) -> Result { - send_files_to_device_with_destination(state.inner(), &device_id, paths, None) -} - -fn send_files_to_device_with_destination( - state: &AppRuntime, - device_id: &str, - paths: Vec, - destination_hint: Option<&str>, -) -> Result { + let state = state.inner(); + let device_id = device_id.as_str(); if paths.is_empty() { return Err("请选择要传输的文件。".into()); } @@ -2157,13 +2108,7 @@ fn send_files_to_device_with_destination( let mut byte_count = 0_u64; for file in files { - let packet_count = send_transfer_file( - &quic_transport, - &local_peer.id, - &target, - &file, - destination_hint, - )?; + let packet_count = send_transfer_file(&quic_transport, &local_peer.id, &target, &file)?; state .transport_packets .fetch_add(packet_count, Ordering::Relaxed); @@ -2178,464 +2123,6 @@ fn send_files_to_device_with_destination( }) } -fn start_edge_drop_window_sync(app_handle: AppHandle) { - thread::spawn(move || loop { - let state = app_handle.state::(); - let runtime = state.inner(); - let layout = runtime.layout_snapshot(); - let peers = active_peer_snapshot(&runtime.peers); - let specs = edge_drop_specs_for_window_visibility( - &layout, - &peers, - runtime.main_window_visible.load(Ordering::Relaxed), - ); - let next_labels = specs - .iter() - .map(|spec| spec.label.clone()) - .collect::>(); - let next_targets = specs - .iter() - .map(|spec| (spec.label.clone(), spec.target_device_id.clone())) - .collect::>(); - let stale_labels = runtime - .edge_drop_targets - .lock() - .map(|mut targets| { - let stale = targets - .keys() - .filter(|label| !next_labels.contains(*label)) - .cloned() - .collect::>(); - *targets = next_targets; - stale - }) - .unwrap_or_default(); - - let main_handle = app_handle.clone(); - let _ = app_handle.run_on_main_thread(move || { - sync_edge_drop_windows_on_main(&main_handle, specs, stale_labels); - }); - - thread::sleep(Duration::from_millis(1500)); - }); -} - -fn sync_edge_drop_windows_on_main( - app_handle: &AppHandle, - specs: Vec, - stale_labels: Vec, -) { - for label in stale_labels { - if let Some(window) = app_handle.get_webview_window(&label) { - let _ = window.hide(); - } - } - - for spec in specs { - if let Some(window) = app_handle.get_webview_window(&spec.label) { - let _ = window.set_position(tauri::Position::Logical(tauri::LogicalPosition::new( - spec.x, spec.y, - ))); - let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize::new( - spec.width, - spec.height, - ))); - let _ = window.set_always_on_top(true); - let _ = window.set_focusable(false); - if !window.is_visible().unwrap_or(false) { - let _ = window.show(); - } - continue; - } - - let build_result = WebviewWindowBuilder::new( - app_handle, - &spec.label, - WebviewUrl::App("edge-drop.html".into()), - ) - .title("") - .position(spec.x, spec.y) - .inner_size(spec.width, spec.height) - .decorations(false) - .resizable(false) - .maximizable(false) - .minimizable(false) - .closable(false) - .shadow(false) - .transparent(true) - .always_on_top(true) - .visible_on_all_workspaces(true) - .skip_taskbar(true) - .focused(false) - .focusable(false) - .build(); - - if let Err(error) = build_result { - log::warn!("edge drop window {} failed: {error}", spec.label); - } - } -} - -fn handle_edge_drop_window_event(window: &tauri::Window, event: &WindowEvent) -> bool { - let label = window.label(); - if !label.starts_with(EDGE_DROP_LABEL_PREFIX) { - return false; - } - - match event { - WindowEvent::CloseRequested { api, .. } => { - api.prevent_close(); - let _ = window.hide(); - } - WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) => { - if paths.is_empty() { - return true; - } - let app_handle = window.app_handle().clone(); - let label = label.to_string(); - let paths = paths - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect::>(); - thread::spawn(move || { - let state = app_handle.state::(); - let target_device_id = state - .edge_drop_targets - .lock() - .ok() - .and_then(|targets| targets.get(&label).cloned()); - let Some(target_device_id) = target_device_id else { - log::warn!("edge drop ignored: no target for {label}"); - return; - }; - - match send_files_to_device_with_destination( - state.inner(), - &target_device_id, - paths, - Some(FILE_TRANSFER_DESTINATION_POINTER), - ) { - Ok(summary) => log::info!( - "edge drop sent {} file(s) to {} bytes={}", - summary.file_count, - summary.target_name, - summary.byte_count - ), - Err(error) => log::warn!("edge drop transfer failed: {error}"), - } - }); - } - _ => {} - } - - true -} - -fn handle_file_drop_landing_window_event(window: &tauri::Window, event: &WindowEvent) -> bool { - if window.label() != FILE_DROP_LANDING_LABEL { - return false; - } - - if let WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - let _ = window.hide(); - } - - true -} - -fn edge_drop_specs_for_layout(layout: &LayoutState, peers: &[LanPeer]) -> Vec { - if !layout.file_transfer_enabled { - return Vec::new(); - } - - let Some(local_device) = layout.devices.iter().find(|device| device.role == "local") else { - return Vec::new(); - }; - - let mut specs = match layout.machine_role.as_str() { - "server" => server_edge_drop_specs(layout, local_device), - "client" => client_edge_drop_specs(layout, local_device, peers), - _ => Vec::new(), - }; - specs.sort_by(|left, right| left.label.cmp(&right.label)); - specs -} - -fn edge_drop_specs_for_window_visibility( - layout: &LayoutState, - peers: &[LanPeer], - main_window_visible: bool, -) -> Vec { - if main_window_visible { - edge_drop_specs_for_layout(layout, peers) - } else { - Vec::new() - } -} - -fn server_edge_drop_specs(layout: &LayoutState, local_device: &Device) -> Vec { - let mut specs = Vec::new(); - for device in layout.devices.iter().filter(|device| { - device.role != "local" - && device.online - && device.input_ready - && device.protocol_version == quic_transport::PROTOCOL_VERSION - && !device.transport_public_key.trim().is_empty() - }) { - for local_screen in &local_device.screens { - for remote_screen in &device.screens { - if edge_screens_overlap(local_screen, remote_screen) { - continue; - } - let Some(side) = edge_touching_side(local_screen, remote_screen) else { - continue; - }; - let Some((x, y, width, height)) = - edge_drop_rect_between(local_screen, remote_screen, side) - else { - continue; - }; - specs.push(EdgeDropWindowSpec { - label: edge_drop_label(&device.id, local_screen, Some(remote_screen), side), - target_device_id: device.id.clone(), - x, - y, - width, - height, - }); - } - } - } - specs -} - -fn client_edge_drop_specs( - layout: &LayoutState, - local_device: &Device, - peers: &[LanPeer], -) -> Vec { - let Some(target_device_id) = online_paired_controller_id(layout, peers) else { - return Vec::new(); - }; - - let mut specs = Vec::new(); - for local_screen in &local_device.screens { - for side in [ - EdgeDropSide::Left, - EdgeDropSide::Right, - EdgeDropSide::Top, - EdgeDropSide::Bottom, - ] { - if local_edge_has_neighbor(&local_device.screens, local_screen, side) { - continue; - } - let (x, y, width, height) = outer_edge_drop_rect(local_screen, side); - specs.push(EdgeDropWindowSpec { - label: edge_drop_label(&target_device_id, local_screen, None, side), - target_device_id: target_device_id.clone(), - x, - y, - width, - height, - }); - } - } - - specs -} - -fn online_paired_controller_id(layout: &LayoutState, peers: &[LanPeer]) -> Option { - layout - .paired_controllers - .iter() - .find(|controller| { - peers.iter().any(|peer| { - (peer.id == controller.id - || (!controller.transport_public_key.trim().is_empty() - && peer.transport_public_key == controller.transport_public_key)) - && peer.protocol_version == quic_transport::PROTOCOL_VERSION - && !peer.transport_public_key.trim().is_empty() - && peer.quic_port != 0 - }) - }) - .map(|controller| controller.id.clone()) -} - -fn edge_drop_label( - target_device_id: &str, - local_screen: &Screen, - remote_screen: Option<&Screen>, - side: EdgeDropSide, -) -> String { - let remote = remote_screen - .map(|screen| edge_label_component(&screen.id)) - .unwrap_or_else(|| "outer".into()); - format!( - "{}{}-{}-{}-{}", - EDGE_DROP_LABEL_PREFIX, - edge_label_component(target_device_id), - edge_label_component(&local_screen.id), - remote, - side.as_str() - ) -} - -fn edge_label_component(value: &str) -> String { - let component = value - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::(); - let component = component - .trim_matches('-') - .chars() - .take(48) - .collect::(); - if !component.is_empty() { - return component; - } - - let mut hasher = DefaultHasher::new(); - value.hash(&mut hasher); - format!("id-{:016x}", hasher.finish()) -} - -fn edge_drop_rect_between( - local: &Screen, - remote: &Screen, - side: EdgeDropSide, -) -> Option<(f64, f64, f64, f64)> { - let thickness = EDGE_DROP_THICKNESS; - let (x, y, width, height) = match side { - EdgeDropSide::Left | EdgeDropSide::Right => { - let y = local.y.max(remote.y); - let height = (local.y + local.height).min(remote.y + remote.height) - y; - if height <= 0 { - return None; - } - let x = if side == EdgeDropSide::Right { - local.x + local.width - thickness - } else { - local.x - }; - (x, y, thickness, height) - } - EdgeDropSide::Top | EdgeDropSide::Bottom => { - let x = local.x.max(remote.x); - let width = (local.x + local.width).min(remote.x + remote.width) - x; - if width <= 0 { - return None; - } - let y = if side == EdgeDropSide::Bottom { - local.y + local.height - thickness - } else { - local.y - }; - (x, y, width, thickness) - } - }; - - Some((x as f64, y as f64, width as f64, height as f64)) -} - -fn outer_edge_drop_rect(screen: &Screen, side: EdgeDropSide) -> (f64, f64, f64, f64) { - let thickness = EDGE_DROP_THICKNESS; - let (x, y, width, height) = match side { - EdgeDropSide::Left => (screen.x, screen.y, thickness, screen.height), - EdgeDropSide::Right => ( - screen.x + screen.width - thickness, - screen.y, - thickness, - screen.height, - ), - EdgeDropSide::Top => (screen.x, screen.y, screen.width, thickness), - EdgeDropSide::Bottom => ( - screen.x, - screen.y + screen.height - thickness, - screen.width, - thickness, - ), - }; - - (x as f64, y as f64, width as f64, height as f64) -} - -fn local_edge_has_neighbor(screens: &[Screen], screen: &Screen, side: EdgeDropSide) -> bool { - screens - .iter() - .filter(|candidate| candidate.id != screen.id) - .any(|candidate| edge_touching_side(screen, candidate) == Some(side)) -} - -fn edge_touching_side(local: &Screen, remote: &Screen) -> Option { - if edge_near(local.x + local.width, remote.x) - && edge_ranges_overlap( - local.y, - local.y + local.height, - remote.y, - remote.y + remote.height, - ) - { - return Some(EdgeDropSide::Right); - } - - if edge_near(local.x, remote.x + remote.width) - && edge_ranges_overlap( - local.y, - local.y + local.height, - remote.y, - remote.y + remote.height, - ) - { - return Some(EdgeDropSide::Left); - } - - if edge_near(local.y + local.height, remote.y) - && edge_ranges_overlap( - local.x, - local.x + local.width, - remote.x, - remote.x + remote.width, - ) - { - return Some(EdgeDropSide::Bottom); - } - - if edge_near(local.y, remote.y + remote.height) - && edge_ranges_overlap( - local.x, - local.x + local.width, - remote.x, - remote.x + remote.width, - ) - { - return Some(EdgeDropSide::Top); - } - - None -} - -fn edge_screens_overlap(local: &Screen, remote: &Screen) -> bool { - local.x < remote.x + remote.width - && local.x + local.width > remote.x - && local.y < remote.y + remote.height - && local.y + local.height > remote.y -} - -fn edge_near(a: i32, b: i32) -> bool { - (a - b).abs() <= 80 -} - -fn edge_ranges_overlap(a_start: i32, a_end: i32, b_start: i32, b_end: i32) -> bool { - i32::min(a_end, b_end) - i32::max(a_start, b_start) > 80 -} - #[tauri::command] fn sync_window_chrome(window: tauri::WebviewWindow, theme: String) -> Result<(), String> { #[cfg(target_os = "windows")] @@ -3332,12 +2819,6 @@ pub fn run() { .build(), ) .on_window_event(|window, event| { - if handle_edge_drop_window_event(window, event) { - return; - } - if handle_file_drop_landing_window_event(window, event) { - return; - } if window.label() == "main" { if let WindowEvent::Focused(focused) = event { set_main_window_focused(window.app_handle(), *focused); @@ -3424,9 +2905,6 @@ pub fn run() { setup_macos_cursor_hider(app); #[cfg(target_os = "macos")] setup_macos_window_visibility_watcher(app); - if EDGE_DROP_WINDOWS_ENABLED { - start_edge_drop_window_sync(app.handle().clone()); - } setup_tray(app)?; if let Err(error) = sync_runtime_toggle_shortcut(app.handle()) { log::warn!("failed to register quick start/stop shortcut: {error}"); @@ -5521,7 +4999,6 @@ fn send_transfer_file( origin_id: &str, target: &FileTransferTarget, file: &TransferFile, - destination_hint: Option<&str>, ) -> Result { let transfer_id = format!("file-{}-{}", now_ms(), random_hex(8)); let mut packet_count = 0_u64; @@ -5538,7 +5015,6 @@ fn send_transfer_file( file.total_bytes, 0, 0, - destination_hint, Vec::new(), ), )?; @@ -5569,7 +5045,6 @@ fn send_transfer_file( file.total_bytes, chunk_index, offset, - destination_hint, data, ), )?; @@ -5590,7 +5065,6 @@ fn send_transfer_file( file.total_bytes, chunk_index, offset, - destination_hint, Vec::new(), ), )?; @@ -5609,7 +5083,6 @@ fn file_transfer_packet( total_bytes: u64, chunk_index: u64, offset: u64, - destination_hint: Option<&str>, data: Vec, ) -> FileTransferPacket { FileTransferPacket { @@ -5621,7 +5094,6 @@ fn file_transfer_packet( cluster_id: target.cluster_id.clone(), pair_secret: target.pair_secret.clone(), file_name: file_name.into(), - destination_hint: destination_hint.map(str::to_string), total_bytes, chunk_index, offset, @@ -5659,23 +5131,7 @@ fn handle_file_transfer_packet( log::warn!("file transfer receive failed: could not resolve receive directory"); return false; }; - let pointer_receive_root = if packet.kind == "start" - && packet.destination_hint.as_deref() == Some(FILE_TRANSFER_DESTINATION_POINTER) - { - file_transfer_pointer_receive_root(app) - } else { - None - }; - - handle_decoded_file_transfer_packet( - packet, - layout, - local_peer_id, - transfers, - &receive_root, - pointer_receive_root.as_deref(), - Some(app), - ) + handle_decoded_file_transfer_packet(packet, layout, local_peer_id, transfers, &receive_root) } fn file_transfer_receive_root(app: &AppHandle) -> Result { @@ -5689,63 +5145,6 @@ fn file_transfer_receive_root(app: &AppHandle) -> Result { .map_err(|error| format!("failed to resolve file transfer receive directory: {error}")) } -fn file_transfer_pointer_receive_root(app: &AppHandle) -> Option { - platform_pointer_receive_root(app).and_then(usable_existing_directory) -} - -#[cfg(target_os = "macos")] -fn platform_pointer_receive_root(app: &AppHandle) -> Option { - app.path() - .desktop_dir() - .ok() - .or_else(macos_finder_insertion_directory) -} - -#[cfg(not(target_os = "macos"))] -fn platform_pointer_receive_root(app: &AppHandle) -> Option { - app.path().desktop_dir().ok() -} - -fn usable_existing_directory(path: PathBuf) -> Option { - if path.is_absolute() && path.is_dir() { - Some(path) - } else { - None - } -} - -#[cfg(target_os = "macos")] -fn macos_finder_insertion_directory() -> Option { - let script = r#" -tell application "Finder" - try - set targetFolder to insertion location as alias - return POSIX path of targetFolder - on error - return "" - end try -end tell -"#; - let output = Command::new("osascript") - .arg("-e") - .arg(script) - .output() - .ok()?; - if !output.status.success() { - return None; - } - - let path = String::from_utf8_lossy(&output.stdout) - .trim() - .trim_end_matches('/') - .to_string(); - if path.is_empty() { - return None; - } - - Some(PathBuf::from(path)) -} - #[cfg(test)] fn handle_file_transfer_packet_with_root( payload: &[u8], @@ -5753,41 +5152,11 @@ fn handle_file_transfer_packet_with_root( local_peer_id: &str, transfers: &Arc>>, receive_root: &Path, -) -> bool { - handle_file_transfer_packet_with_destination_root( - payload, - layout, - local_peer_id, - transfers, - receive_root, - None, - None, - ) -} - -#[cfg(test)] -fn handle_file_transfer_packet_with_destination_root( - payload: &[u8], - layout: &LayoutState, - local_peer_id: &str, - transfers: &Arc>>, - receive_root: &Path, - pointer_receive_root: Option<&Path>, - landing_app: Option<&AppHandle>, ) -> bool { let Some(packet) = decode_wire_packet::(payload) else { return false; }; - - handle_decoded_file_transfer_packet( - packet, - layout, - local_peer_id, - transfers, - receive_root, - pointer_receive_root, - landing_app, - ) + handle_decoded_file_transfer_packet(packet, layout, local_peer_id, transfers, receive_root) } fn handle_decoded_file_transfer_packet( @@ -5796,8 +5165,6 @@ fn handle_decoded_file_transfer_packet( local_peer_id: &str, transfers: &Arc>>, receive_root: &Path, - pointer_receive_root: Option<&Path>, - landing_app: Option<&AppHandle>, ) -> bool { if packet.protocol != FILE_TRANSFER_PROTOCOL { return false; @@ -5815,132 +5182,14 @@ fn handle_decoded_file_transfer_packet( return true; } - let destination_root = - file_transfer_destination_root(&packet, receive_root, pointer_receive_root); match packet.kind.as_str() { - "start" => { - let show_landing = - packet.destination_hint.as_deref() == Some(FILE_TRANSFER_DESTINATION_POINTER); - let file_name = packet.file_name.clone(); - let accepted = start_incoming_file_transfer(packet, transfers, destination_root); - if accepted && show_landing { - if let Some(app) = landing_app { - show_file_drop_landing_window(app, &file_name); - } - } - accepted - } + "start" => start_incoming_file_transfer(packet, transfers, receive_root), "chunk" => append_incoming_file_transfer_chunk(packet, transfers), "finish" => finish_incoming_file_transfer(packet, transfers), _ => false, } } -fn file_transfer_destination_root<'a>( - packet: &FileTransferPacket, - receive_root: &'a Path, - pointer_receive_root: Option<&'a Path>, -) -> &'a Path { - if packet.destination_hint.as_deref() == Some(FILE_TRANSFER_DESTINATION_POINTER) { - return pointer_receive_root.unwrap_or(receive_root); - } - - receive_root -} - -fn show_file_drop_landing_window(app: &AppHandle, _file_name: &str) { - let Some((x, y)) = current_pointer_position() else { - return; - }; - let x = x - FILE_DROP_LANDING_WIDTH / 2.0; - let y = y - FILE_DROP_LANDING_HEIGHT / 2.0; - - if let Some(window) = app.get_webview_window(FILE_DROP_LANDING_LABEL) { - let _ = window.set_position(tauri::Position::Logical(tauri::LogicalPosition::new(x, y))); - let _ = window.set_always_on_top(true); - let _ = window.set_focusable(false); - let _ = window.show(); - schedule_file_drop_landing_hide(app.clone()); - return; - } - - let build_result = WebviewWindowBuilder::new( - app, - FILE_DROP_LANDING_LABEL, - WebviewUrl::App("file-drop-landing.html".into()), - ) - .title("") - .position(x, y) - .inner_size(FILE_DROP_LANDING_WIDTH, FILE_DROP_LANDING_HEIGHT) - .decorations(false) - .resizable(false) - .maximizable(false) - .minimizable(false) - .closable(false) - .shadow(false) - .transparent(true) - .always_on_top(true) - .visible_on_all_workspaces(true) - .skip_taskbar(true) - .focused(false) - .focusable(false) - .build(); - - if let Err(error) = build_result { - log::warn!("file drop landing window failed: {error}"); - return; - } - - schedule_file_drop_landing_hide(app.clone()); -} - -fn schedule_file_drop_landing_hide(app: AppHandle) { - thread::spawn(move || { - thread::sleep(Duration::from_millis(2200)); - let main_handle = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Some(window) = main_handle.get_webview_window(FILE_DROP_LANDING_LABEL) { - let _ = window.hide(); - } - }); - }); -} - -fn current_pointer_position() -> Option<(f64, f64)> { - platform_current_pointer_position() -} - -#[cfg(target_os = "macos")] -fn platform_current_pointer_position() -> Option<(f64, f64)> { - use core_graphics::{ - event::CGEvent, - event_source::{CGEventSource, CGEventSourceStateID}, - }; - - let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState).ok()?; - let event = CGEvent::new(source).ok()?; - let point = event.location(); - Some((point.x, point.y)) -} - -#[cfg(target_os = "windows")] -fn platform_current_pointer_position() -> Option<(f64, f64)> { - use windows_sys::Win32::{Foundation::POINT, UI::WindowsAndMessaging::GetCursorPos}; - - let mut point = POINT { x: 0, y: 0 }; - let ok = unsafe { GetCursorPos(&mut point) }; - if ok == 0 { - return None; - } - - Some((point.x as f64, point.y as f64)) -} - -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn platform_current_pointer_position() -> Option<(f64, f64)> { - None -} - fn start_incoming_file_transfer( packet: FileTransferPacket, transfers: &Arc>>, @@ -7809,105 +7058,6 @@ mod tests { assert_eq!(layout.devices[1].host, "10.0.0.2"); } - #[test] - fn edge_drop_specs_follow_server_screen_adjacency() { - let mut layout = test_layout(); - layout.devices[1].screens[0].x = 1920; - - let specs = edge_drop_specs_for_layout(&layout, &[]); - - assert_eq!(specs.len(), 1); - let spec = &specs[0]; - assert_eq!(spec.target_device_id, "peer-client-10-0-0-2"); - assert_eq!(spec.x, 1912.0); - assert_eq!(spec.y, 0.0); - assert_eq!(spec.width, EDGE_DROP_THICKNESS as f64); - assert_eq!(spec.height, 1080.0); - } - - #[test] - fn edge_drop_specs_respect_file_transfer_setting() { - let mut layout = test_layout(); - layout.devices[1].screens[0].x = 1920; - layout.file_transfer_enabled = false; - - let specs = edge_drop_specs_for_layout(&layout, &[]); - - assert!(specs.is_empty()); - } - - #[test] - fn edge_drop_specs_pause_when_main_window_is_hidden() { - let mut layout = test_layout(); - layout.devices[1].screens[0].x = 1920; - - assert_eq!( - edge_drop_specs_for_window_visibility(&layout, &[], true).len(), - 1 - ); - assert!(edge_drop_specs_for_window_visibility(&layout, &[], false).is_empty()); - } - - #[test] - fn edge_drop_specs_skip_internal_client_monitor_edges() { - let mut layout = test_layout(); - layout.machine_role = "client".into(); - layout.input_mode = "receive".into(); - layout.devices.truncate(1); - let mut second_screen = test_screen("local-device"); - second_screen.id = "local-device-display-2".into(); - second_screen.x = 1920; - layout.devices[0].screens.push(second_screen); - layout.paired_controllers = vec![PairedController { - id: "peer-server-10-0-0-1".into(), - name: "Server".into(), - host: "server".into(), - ip: "10.0.0.1".into(), - transport_public_key: "server-public-key".into(), - protocol_version: quic_transport::PROTOCOL_VERSION, - cluster_id: layout.cluster_id.clone(), - paired_at_ms: now_ms(), - }]; - let mut peer = test_peer(); - peer.id = "peer-server-10-0-0-1".into(); - peer.name = "Server".into(); - peer.machine_role = "server".into(); - peer.transport_public_key = "server-public-key".into(); - - let specs = edge_drop_specs_for_layout(&layout, &[peer]); - - assert_eq!(specs.len(), 6); - assert!(specs.iter().all(|spec| { - spec.target_device_id == "peer-server-10-0-0-1" && spec.width > 0.0 && spec.height > 0.0 - })); - assert!(!specs.iter().any(|spec| { - spec.x == (1920 - EDGE_DROP_THICKNESS) as f64 - && spec.y == 0.0 - && spec.width == EDGE_DROP_THICKNESS as f64 - && spec.height == 1080.0 - })); - assert!(specs.iter().any(|spec| { - spec.x == (3840 - EDGE_DROP_THICKNESS) as f64 - && spec.y == 0.0 - && spec.width == EDGE_DROP_THICKNESS as f64 - && spec.height == 1080.0 - })); - } - - #[test] - fn edge_drop_label_handles_non_ascii_ids() { - let mut local = test_screen("local-device"); - local.id = "显示器".into(); - let mut remote = test_screen("remote-device"); - remote.id = "远程".into(); - - let label = edge_drop_label("设备", &local, Some(&remote), EdgeDropSide::Right); - - assert!(label.starts_with(EDGE_DROP_LABEL_PREFIX)); - assert!(label.contains("id-")); - assert!(label.ends_with("-right")); - } - #[test] fn peer_presence_does_not_add_unapproved_peer_screens() { let mut layout = test_layout(); @@ -8685,66 +7835,6 @@ mod tests { let _ = fs::remove_dir_all(root); } - #[test] - fn file_transfer_pointer_hint_writes_to_drop_destination() { - let layout = test_layout(); - let default_root = temp_test_dir("file-transfer-default-root"); - let drop_root = temp_test_dir("file-transfer-pointer-root"); - let transfers = Arc::new(Mutex::new(HashMap::new())); - - for packet in [ - test_file_transfer_packet_with_destination_hint( - "start", - "transfer-pointer", - "desktop.txt", - 7, - 0, - 0, - b"", - Some(FILE_TRANSFER_DESTINATION_POINTER), - ), - test_file_transfer_packet_with_destination_hint( - "chunk", - "transfer-pointer", - "desktop.txt", - 7, - 0, - 0, - b"desktop", - Some(FILE_TRANSFER_DESTINATION_POINTER), - ), - test_file_transfer_packet_with_destination_hint( - "finish", - "transfer-pointer", - "desktop.txt", - 7, - 1, - 7, - b"", - Some(FILE_TRANSFER_DESTINATION_POINTER), - ), - ] { - let payload = encode_wire_packet(&packet).expect("file packet should encode"); - assert!(handle_file_transfer_packet_with_destination_root( - &payload, - &layout, - "local-device", - &transfers, - &default_root, - Some(&drop_root), - None, - )); - } - - assert!(!default_root.join("desktop.txt").exists()); - assert_eq!( - fs::read_to_string(drop_root.join("desktop.txt")).expect("received file"), - "desktop" - ); - let _ = fs::remove_dir_all(default_root); - let _ = fs::remove_dir_all(drop_root); - } - #[test] fn file_transfer_rejects_out_of_order_chunks() { let layout = test_layout(); @@ -8797,29 +7887,6 @@ mod tests { chunk_index: u64, offset: u64, data: &[u8], - ) -> FileTransferPacket { - test_file_transfer_packet_with_destination_hint( - kind, - transfer_id, - file_name, - total_bytes, - chunk_index, - offset, - data, - None, - ) - } - - #[allow(clippy::too_many_arguments)] - fn test_file_transfer_packet_with_destination_hint( - kind: &str, - transfer_id: &str, - file_name: &str, - total_bytes: u64, - chunk_index: u64, - offset: u64, - data: &[u8], - destination_hint: Option<&str>, ) -> FileTransferPacket { FileTransferPacket { protocol: FILE_TRANSFER_PROTOCOL.into(), @@ -8830,7 +7897,6 @@ mod tests { cluster_id: "cluster-test".into(), pair_secret: "secret-test".into(), file_name: file_name.into(), - destination_hint: destination_hint.map(str::to_string), total_bytes, chunk_index, offset, From d4dfe21064e52deeae1bbae5275ae17c797967f0 Mon Sep 17 00:00:00 2001 From: fc221 Date: Tue, 14 Jul 2026 08:40:18 +0800 Subject: [PATCH 06/12] perf(input): cache instead of rebuilding per event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every mouse event on both the capture and receive paths repeated work whose answer changes on layout edits, not per event: - capture: current_input_targets rebuilt the target list under a BLOCKING layout lock per local move — including a LanPeer build whose local_ip_address() opened a UDP socket (4 syscalls) inside the tap/hook callback. Targets are now cached behind a 250ms TTL (Arc, try_lock, stale-serve), local_ip_address caches for 5s, and the per-target target_is_online lock round-trip is gone (build_input_targets already filters online + input-ready) - send: mouse packets use the pairing context cached on the target instead of re-locking the layout and re-deriving the origin peer per packet (key events still consult the live layout for the modifier remap), and the packet serializes through a borrowing mirror so the ~1KB of credential strings is no longer cloned per event - receive: datagrams decode outside the layout lock and input-first (control packets were parsed and rejected first on every move); the lock now covers only authorization + coordinate mapping while injection syscalls run after release; the local peer id is cached for 5s instead of building a LanPeer per packet; the clipboard target is only rewritten when the controller actually changes; the remote mouse position/buttons mutex became two atomics Co-Authored-By: Claude Fable 5 --- src-tauri/src/input.rs | 404 +++++++++++++++++++++++++++-------------- src-tauri/src/lib.rs | 39 ++-- 2 files changed, 292 insertions(+), 151 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 1040a0f..d5d92fc 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -99,7 +99,12 @@ const MACOS_RAW_GESTURE_EVENT_TYPES: &[u32] = &[ #[cfg(target_os = "windows")] const WINDOWS_DESKTOP_CHECK_INTERVAL_MS: u64 = 250; -static REMOTE_MOUSE_STATE: OnceLock> = OnceLock::new(); +/// Last injected remote cursor position, packed x<<32|y, plus held buttons. +/// Plain atomics: these are touched on every received mouse event and a +/// global mutex there is contention for nothing (the fields are independent +/// and a racing reader tolerates one event of skew). +static REMOTE_MOUSE_POSITION: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); +static REMOTE_MOUSE_BUTTONS: AtomicU64 = AtomicU64::new(0); #[cfg(target_os = "macos")] static MACOS_ACCESSIBILITY_PROMPTED: AtomicBool = AtomicBool::new(false); #[cfg(target_os = "windows")] @@ -156,6 +161,23 @@ pub struct ClipboardTarget { pub expires_at: Option, } +/// Borrowing serialization mirror of [`InputPacket`]: identical named +/// MessagePack bytes (guarded by a test), but building one clones none of the +/// ~1KB of credential strings — send_packet runs per mouse event. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InputPacketRef<'a> { + protocol: &'a str, + target_device_id: &'a str, + origin_device_id: &'a str, + origin_port: u16, + origin_transport_public_key: &'a str, + origin_protocol_version: u16, + cluster_id: &'a str, + pair_secret: &'a str, + event: &'a InputEvent, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct InputPacket { @@ -202,13 +224,6 @@ enum InputControlCommand { SecureAttention, } -#[derive(Debug, Default, Clone, Copy)] -struct RemoteMouseState { - x: i32, - y: i32, - buttons: u64, -} - pub fn stopped_capture_status() -> NativeStageStatus { NativeStageStatus { state: "stubbed".into(), @@ -705,6 +720,7 @@ fn start_input_capture( input_events: Arc, switch_request: Arc>>, ) -> NativeStageStatus { + invalidate_input_targets_cache(); start_platform_capture( targets, layout_state, @@ -1193,14 +1209,53 @@ fn build_input_targets(layout: &LayoutState, native_layout: &LayoutState) -> Vec targets } +/// How long a built target list may be served from cache. Crossing geometry +/// and pairing context only change on layout edits and peer presence flips; +/// a quarter second of staleness is invisible at a screen edge, while +/// rebuilding per mouse move cost a blocking layout lock, a LanPeer build +/// (UDP-socket IP probe) and a dozen String clones inside the event tap. +const INPUT_TARGETS_TTL: Duration = Duration::from_millis(250); + +static INPUT_TARGETS_CACHE: Mutex>)>> = Mutex::new(None); + fn current_input_targets( layout_state: &Arc>, native_layout: &LayoutState, -) -> Vec { - layout_state - .lock() - .map(|layout| build_input_targets(&layout, native_layout)) - .unwrap_or_default() +) -> Arc> { + if let Ok(cache) = INPUT_TARGETS_CACHE.lock() { + if let Some((built_at, targets)) = cache.as_ref() { + if built_at.elapsed() < INPUT_TARGETS_TTL { + return Arc::clone(targets); + } + } + } + + // Never block the event tap on a held layout lock (a save may be writing + // to disk under it): serve the stale cache instead and retry next event. + match layout_state.try_lock() { + Ok(layout) => { + let targets = Arc::new(build_input_targets(&layout, native_layout)); + drop(layout); + if let Ok(mut cache) = INPUT_TARGETS_CACHE.lock() { + *cache = Some((Instant::now(), Arc::clone(&targets))); + } + targets + } + Err(_) => INPUT_TARGETS_CACHE + .lock() + .ok() + .and_then(|cache| cache.as_ref().map(|(_, targets)| Arc::clone(targets))) + .unwrap_or_default(), + } +} + +/// Drops the cached target list so the next event rebuilds it — called when +/// capture starts, so a fresh session can never act on the previous +/// session's pairing context. +fn invalidate_input_targets_cache() { + if let Ok(mut cache) = INPUT_TARGETS_CACHE.lock() { + *cache = None; + } } fn touching_edge(local: &Screen, remote: &Screen) -> Option { @@ -1281,21 +1336,20 @@ fn send_packet( layout_state: &Arc>, input_events: &Arc, ) -> bool { - let packet_context = input_packet_context(target, event, layout_state); - let event = packet_context.event; - let packet = InputPacket { - protocol: INPUT_PROTOCOL.into(), - target_device_id: target.device_id.clone(), - origin_device_id: packet_context.origin_device_id, + let mut packet_context = input_packet_context(target, event, layout_state); + let Some(peer) = packet_context.peer.take() else { + return false; + }; + let packet = InputPacketRef { + protocol: INPUT_PROTOCOL, + target_device_id: &target.device_id, + origin_device_id: &packet_context.origin_device_id, origin_port: quic_transport.port(), - origin_transport_public_key: quic_transport.public_key().to_string(), + origin_transport_public_key: quic_transport.public_key(), origin_protocol_version: quic_transport::PROTOCOL_VERSION, - cluster_id: packet_context.cluster_id, - pair_secret: packet_context.pair_secret, - event, - }; - let Some(peer) = packet_context.peer else { - return false; + cluster_id: &packet_context.cluster_id, + pair_secret: &packet_context.pair_secret, + event: &packet_context.event, }; let payload = match rmp_serde::to_vec_named(&packet) { @@ -1400,6 +1454,15 @@ fn input_packet_context( event, }; + // Mouse events — essentially every packet — always use the context cached + // on the target at build time: it is at most INPUT_TARGETS_TTL stale and + // needs no layout lock, no origin re-derivation and no address formatting. + // Key events still consult the live layout for the modifier remap; they + // arrive at typing rate, not at mouse rate. + if !matches!(event, InputEvent::Key { .. }) { + return fallback_context(event); + } + let layout = match layout_state.try_lock() { Ok(layout) => layout, Err(TryLockError::WouldBlock) => return fallback_context(event), @@ -1538,86 +1601,131 @@ fn mark_target_offline( device.online = false; } -fn target_is_online(target: &InputTarget, layout_state: &Arc>) -> bool { - layout_state - .lock() - .ok() - .and_then(|layout| { - layout - .devices - .iter() - .find(|device| device.id == target.device_id) - .map(|device| device.online && device.input_ready) - }) - .unwrap_or(false) -} - -pub fn try_inject_packet_from_source( - layout: &LayoutState, +/// Handles one received input-plane datagram end to end. Structured for the +/// per-move cost, not readability of the rare cases: +/// - decode runs entirely OUTSIDE the layout lock, and input packets decode +/// first (they outnumber control packets by orders of magnitude; the old +/// control-first order fully parsed every mouse move against the wrong +/// schema before trying the right one) +/// - the layout lock covers only authorization + coordinate mapping; +/// injection is syscalls and runs after the lock is released, so a slow +/// WindowServer round-trip can no longer stretch the critical section every +/// other hot path contends on +pub fn handle_input_datagram( + layout_state: &Arc>, native_layout: &LayoutState, payload: &[u8], source: SocketAddr, input_events: &Arc, - local_peer_id: &str, clipboard_target: &Arc>>, ) -> bool { - let Some(packet) = decode_input_packet(payload) else { - return false; - }; - - if packet.protocol != INPUT_PROTOCOL { - return false; - } - - if !packet_authorized(layout, &packet) { - warn_unauthorized_packet(layout, &packet); - return true; - } - - if !packet_targets_local(layout, &packet.target_device_id, local_peer_id) { + if let Some(packet) = decode_input_packet(payload) { + if packet.protocol != INPUT_PROTOCOL { + return false; + } + let command = { + let Ok(layout) = layout_state.lock() else { + return false; + }; + if !packet_authorized(&layout, &packet) { + warn_unauthorized_packet(&layout, &packet); + return true; + } + let local_peer_id = cached_local_peer_id(&layout); + if !packet_targets_local(&layout, &packet.target_device_id, &local_peer_id) { + return true; + } + refresh_clipboard_target(clipboard_target, &layout, &packet, source); + input_event_to_command(&layout, native_layout, packet.event) + }; + let Some(command) = command else { + return true; + }; + if dispatch_input_command(command) { + input_events.fetch_add(1, Ordering::Relaxed); + } return true; } - if packet.origin_port != 0 && !packet.origin_transport_public_key.trim().is_empty() { - let device_id = if packet.origin_device_id.trim().is_empty() { - source.ip().to_string() - } else { - packet.origin_device_id.clone() + if let Some(packet) = decode_input_control_packet(payload) { + let Ok(layout) = layout_state.lock() else { + return false; }; - // Persist the controller as our clipboard peer so a copy made on this - // machine syncs back to it immediately, without needing the remote - // cursor to re-enter. Refreshed on every input packet; cleared when - // input/clipboard stops. - set_clipboard_target( - clipboard_target, - device_id, - format!("{}:{}", source.ip(), packet.origin_port), - packet.origin_transport_public_key.clone(), - packet.origin_protocol_version, - layout.cluster_id.clone(), - layout.pair_secret.clone(), - None, - ); + let local_peer_id = cached_local_peer_id(&layout); + return handle_control_packet(&layout, packet, source, &local_peer_id); } - let injected = inject_input_event(layout, native_layout, packet.event); - if injected { - input_events.fetch_add(1, Ordering::Relaxed); + false +} + +/// The local peer id is derived from the hostname + LAN address and is needed +/// for every received packet; deriving it builds a full LanPeer (screens and +/// all). Cache the id briefly instead of rebuilding it per datagram. +fn cached_local_peer_id(layout: &LayoutState) -> String { + const LOCAL_PEER_ID_TTL: Duration = Duration::from_secs(5); + static CACHE: Mutex> = Mutex::new(None); + + if let Ok(mut cached) = CACHE.lock() { + if let Some((resolved_at, id)) = cached.as_ref() { + if resolved_at.elapsed() < LOCAL_PEER_ID_TTL { + return id.clone(); + } + } + let id = crate::local_peer_from_layout(layout).id; + *cached = Some((Instant::now(), id.clone())); + return id; } + crate::local_peer_from_layout(layout).id +} - true +/// Persist the controller as our clipboard peer so a copy made on this +/// machine syncs back to it immediately, without needing the remote cursor to +/// re-enter. The target only actually changes on the first packet of a +/// session (or a controller switch); skip the five per-packet allocations the +/// unconditional rewrite used to pay on every mouse move. +fn refresh_clipboard_target( + clipboard_target: &Arc>>, + layout: &LayoutState, + packet: &InputPacket, + source: SocketAddr, +) { + if packet.origin_port == 0 || packet.origin_transport_public_key.trim().is_empty() { + return; + } + if !packet.origin_device_id.trim().is_empty() { + if let Ok(target) = clipboard_target.lock() { + if target.as_ref().is_some_and(|target| { + target.device_id == packet.origin_device_id + && target.transport_public_key == packet.origin_transport_public_key + && target.protocol_version == packet.origin_protocol_version + }) { + return; + } + } + } + let device_id = if packet.origin_device_id.trim().is_empty() { + source.ip().to_string() + } else { + packet.origin_device_id.clone() + }; + set_clipboard_target( + clipboard_target, + device_id, + format!("{}:{}", source.ip(), packet.origin_port), + packet.origin_transport_public_key.clone(), + packet.origin_protocol_version, + layout.cluster_id.clone(), + layout.pair_secret.clone(), + None, + ); } -pub fn try_handle_control_packet_from_source( +fn handle_control_packet( layout: &LayoutState, - payload: &[u8], + packet: InputControlPacket, source: SocketAddr, local_peer_id: &str, ) -> bool { - let Some(packet) = decode_input_control_packet(payload) else { - return false; - }; - if packet.protocol != INPUT_CONTROL_PROTOCOL { return false; } @@ -1877,44 +1985,31 @@ fn scale_size(value: i32, scale: f64) -> i32 { .clamp(1.0, i32::MAX as f64) as i32 } -fn remote_mouse_state() -> &'static Mutex { - REMOTE_MOUSE_STATE.get_or_init(|| Mutex::new(RemoteMouseState::default())) +fn pack_remote_position(x: i32, y: i32) -> i64 { + ((x as i64) << 32) | (y as u32 as i64) +} + +fn unpack_remote_position(packed: i64) -> (i32, i32) { + ((packed >> 32) as i32, packed as u32 as i32) } fn update_remote_mouse_position(x: i32, y: i32) -> Option { - let Ok(mut state) = remote_mouse_state().lock() else { - return None; - }; - state.x = x; - state.y = y; - primary_button_from_mask(state.buttons) + REMOTE_MOUSE_POSITION.store(pack_remote_position(x, y), Ordering::Relaxed); + button_from_mask(REMOTE_MOUSE_BUTTONS.load(Ordering::Relaxed)) } fn update_remote_mouse_button(button: MouseButton, down: bool) -> (i32, i32) { - let Ok(mut state) = remote_mouse_state().lock() else { - return (0, 0); - }; if down { - state.buttons |= mouse_button_mask(button); + REMOTE_MOUSE_BUTTONS.fetch_or(mouse_button_mask(button), Ordering::Relaxed); } else { - state.buttons &= !mouse_button_mask(button); + REMOTE_MOUSE_BUTTONS.fetch_and(!mouse_button_mask(button), Ordering::Relaxed); } - (state.x, state.y) -} - -fn primary_button_from_mask(mask: u64) -> Option { - button_from_mask(mask) + unpack_remote_position(REMOTE_MOUSE_POSITION.load(Ordering::Relaxed)) } -fn inject_input_event( - layout: &LayoutState, - native_layout: &LayoutState, - event: InputEvent, -) -> bool { - let Some(command) = input_event_to_command(layout, native_layout, event) else { - return false; - }; - +/// Executes a mapped input command on this machine. Runs outside the layout +/// lock — injection is syscalls and must not stretch the critical section. +fn dispatch_input_command(command: InputCommand) -> bool { #[cfg(target_os = "windows")] { // Inject locally on the normal desktop; hand off to the privileged SYSTEM @@ -2953,7 +3048,7 @@ fn handle_windows_mouse_move(context: &WindowsCaptureContext, x: f64, y: f64) -> } let targets = current_input_targets(&context.layout_state, &context.native_layout); - if let Some(active_target) = crossing_target(&targets, x, y, dx, dy, &context.layout_state) { + if let Some(active_target) = crossing_target(&targets, x, y, dx, dy) { let anchor = local_anchor_point(&active_target); hide_windows_cursor_if_needed(context); set_windows_cursor(anchor.0.round() as i32, anchor.1.round() as i32); @@ -3509,9 +3604,8 @@ fn crossing_target( y: f64, dx: f64, dy: f64, - layout_state: &Arc>, ) -> Option { - crossing_target_with_transform(targets, x, y, dx, dy, false, layout_state) + crossing_target_with_transform(targets, x, y, dx, dy, false) } fn crossing_target_with_transform( @@ -3521,15 +3615,14 @@ fn crossing_target_with_transform( dx: f64, dy: f64, invert_y: bool, - layout_state: &Arc>, ) -> Option { targets .iter() .find_map(|target| { - if !target_is_online(target, layout_state) { - return None; - } - + // No per-move online re-check here: build_input_targets only + // emits online + input-ready devices and the target cache + // refreshes within INPUT_TARGETS_TTL, so the check only re-took + // the layout lock per target per event to learn the same answer. crossing_layout_point(target, x, y, dx, dy).map(|point| (target, point)) }) .map(|(target, (mapped_x, mapped_y))| { @@ -3683,7 +3776,7 @@ fn mac_crossing_target( dy: f64, ) -> Option { if let Some(target) = - crossing_target_with_transform(targets, x, y, dx, dy, false, &context.layout_state) + crossing_target_with_transform(targets, x, y, dx, dy, false) { return Some(target); } @@ -3696,7 +3789,7 @@ fn mac_crossing_target( return None; } - crossing_target_with_transform(targets, x, flipped_y, dx, -dy, true, &context.layout_state) + crossing_target_with_transform(targets, x, flipped_y, dx, -dy, true) } #[cfg(target_os = "macos")] @@ -6281,14 +6374,7 @@ mod tests { #[test] fn hotkey_return_uses_recorded_point_then_local_screen_center() { - let active = crossing_target( - &[target_for_coordinate_tests()], - 1919.0, - 500.0, - 40.0, - 0.0, - &Arc::new(Mutex::new(layout_for_target_tests())), - ) + let active = crossing_target(&[target_for_coordinate_tests()], 1919.0, 500.0, 40.0, 0.0) .expect("target should be active"); assert_eq!( @@ -6387,6 +6473,42 @@ mod tests { } } + #[test] + fn borrowed_packet_mirror_encodes_identically_to_input_packet() { + let packet = InputPacket { + protocol: INPUT_PROTOCOL.into(), + target_device_id: "peer-device".into(), + origin_device_id: "local-device".into(), + origin_port: 47833, + origin_transport_public_key: "local-public-key".into(), + origin_protocol_version: quic_transport::PROTOCOL_VERSION, + cluster_id: "cluster-test".into(), + pair_secret: "secret-test".into(), + event: InputEvent::MouseMove { + screen_id: "display-1".into(), + x: 320, + y: 240, + }, + }; + let mirror = InputPacketRef { + protocol: &packet.protocol, + target_device_id: &packet.target_device_id, + origin_device_id: &packet.origin_device_id, + origin_port: packet.origin_port, + origin_transport_public_key: &packet.origin_transport_public_key, + origin_protocol_version: packet.origin_protocol_version, + cluster_id: &packet.cluster_id, + pair_secret: &packet.pair_secret, + event: &packet.event, + }; + + assert_eq!( + rmp_serde::to_vec_named(&packet).expect("encode owned packet"), + rmp_serde::to_vec_named(&mirror).expect("encode borrowed mirror"), + "the send-path mirror must stay byte-identical to InputPacket on the wire" + ); + } + #[test] fn input_packet_context_uses_stable_peer_origin_id() { let layout = layout_for_target_tests(); @@ -6394,6 +6516,20 @@ mod tests { let layout_state = Arc::new(Mutex::new(layout)); let target = target_for_coordinate_tests(); + // Key events consult the live layout and must resolve the stable id. + let context = input_packet_context( + &target, + InputEvent::Key { + key_code: 0x41, + down: true, + }, + &layout_state, + ); + assert_ne!(expected_origin_id, "local-device"); + assert_eq!(context.origin_device_id, expected_origin_id); + + // Mouse events take the hot path: the context cached on the target at + // build time, which carries the same stable id without a layout lock. let context = input_packet_context( &target, InputEvent::MouseMove { @@ -6403,9 +6539,7 @@ mod tests { }, &layout_state, ); - - assert_ne!(expected_origin_id, "local-device"); - assert_eq!(context.origin_device_id, expected_origin_id); + assert_eq!(context.origin_device_id, target.origin_device_id); } #[test] @@ -6638,7 +6772,7 @@ mod tests { fn fast_crossing_carries_entry_delta_into_remote() { let target = target_for_coordinate_tests(); let layout_state = Arc::new(Mutex::new(layout_for_target_tests())); - let active = crossing_target(&[target], 1919.0, 500.0, 40.0, 0.0, &layout_state) + let active = crossing_target(&[target], 1919.0, 500.0, 40.0, 0.0) .expect("fast edge movement should cross"); assert!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1123f58..e47f69f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -715,26 +715,12 @@ impl AppRuntime { if !input_receive_enabled.load(Ordering::Relaxed) { return; } - let Ok(layout) = layout_for_input.lock() else { - return; - }; - let current_peer = local_peer_from_layout(&layout); - if input::try_handle_control_packet_from_source( - &layout, - &payload, - source, - ¤t_peer.id, - ) { - transport_packets_for_input.fetch_add(1, Ordering::Relaxed); - return; - } - if input::try_inject_packet_from_source( - &layout, + if input::handle_input_datagram( + &layout_for_input, &native_layout_for_input, &payload, source, &input_events, - ¤t_peer.id, &clipboard_target, ) { transport_packets_for_input.fetch_add(1, Ordering::Relaxed); @@ -4095,6 +4081,27 @@ fn local_host_label() -> String { } fn local_ip_address() -> Option { + // Callers include per-event hot paths (input target building, packet + // origin resolution) and the discovery loop; the UDP-socket probe is four + // syscalls. The LAN address changes on network reconfigs, not per event — + // cache it briefly. + const LOCAL_IP_CACHE_TTL: Duration = Duration::from_secs(5); + static CACHE: Mutex)>> = Mutex::new(None); + + if let Ok(mut cached) = CACHE.lock() { + if let Some((probed_at, address)) = cached.as_ref() { + if probed_at.elapsed() < LOCAL_IP_CACHE_TTL { + return address.clone(); + } + } + let fresh = probe_local_ip_address(); + *cached = Some((Instant::now(), fresh.clone())); + return fresh; + } + probe_local_ip_address() +} + +fn probe_local_ip_address() -> Option { let socket = UdpSocket::bind("0.0.0.0:0").ok()?; socket.connect("8.8.8.8:80").ok()?; let address = socket.local_addr().ok()?; From 6fd20658bcc21b92d4632f6db07755a71c996e03 Mon Sep 17 00:00:00 2001 From: fc221 Date: Tue, 14 Jul 2026 08:42:54 +0800 Subject: [PATCH 07/12] chore(ui): delete dead frontend code - ~355 lines of dead CSS: 30 classes from an abandoned UI iteration with zero TSX references, incl. the unreachable mac custom chrome (App.tsx hard-codes windows-only chrome), plus their entries in shared selectors and media queries - desktopApi browser-preview theatre (fake runtime state machine, fake Math.sin performance samples, fake peers) replaced with one static stub; browser dev mode still renders the layout editor - 17 unused i18n keys in both languages, the unused sendSecureAttention export, the unread DiagnosticDevice/knownDevices types, the edgeSwitchHotkeyFromKeyboardEvent pass-through alias, and @types/node (nothing type-checks node code) tsc -b and npm run build pass. Co-Authored-By: Claude Fable 5 --- package.json | 1 - src/App.css | 383 ++------------------------------------------- src/App.tsx | 8 +- src/desktopApi.ts | 168 ++++++-------------- src/hotkeyInput.ts | 7 - src/i18n.ts | 38 ----- src/runtime.ts | 12 -- 7 files changed, 63 insertions(+), 554 deletions(-) diff --git a/package.json b/package.json index f6f6d8e..ed1020b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.11.2", - "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/src/App.css b/src/App.css index c1819d1..f6b2cdc 100644 --- a/src/App.css +++ b/src/App.css @@ -12,13 +12,6 @@ padding-top: 0; } -.app-shell.custom-chrome-mac { - height: 100vh; - min-height: 100vh; - border-radius: 18px; - overflow: hidden auto; -} - .app-shell.theme-light { color: #172033; background: linear-gradient(180deg, #f8fbff 0, #f2f5fa 280px), #f2f5fa; @@ -31,8 +24,6 @@ .theme-light .layout-panel, .theme-light .loading-panel, .theme-light .connection-row, -.theme-light .protocol-grid div, -.theme-light .metric-grid div, .theme-light .network-meta div, .theme-light .runtime-meta div { border-color: #d7deea; @@ -41,11 +32,8 @@ .theme-light .brand-mark, .theme-light .secondary-button, -.theme-light .screen-chip, -.theme-light .role-status-pill, .theme-light .add-device-form input, .theme-light .settings-number-input, -.theme-light .settings-text-input, .theme-light .hotkey-recorder-button, .theme-light .role-switcher button, .theme-light .segmented-control, @@ -67,9 +55,7 @@ .theme-light .layout-toolbar h1, .theme-light .surface-card h2, .theme-light .connection-title strong, -.theme-light .protocol-grid strong, .theme-light .runtime-meta dd, -.theme-light .metric-grid dd, .theme-light .network-meta dd { color: #172033; } @@ -227,8 +213,7 @@ color: #ffffff; } -.theme-light .secondary-button:hover, -.theme-light .screen-chip:hover { +.theme-light .secondary-button:hover { background: #e9eef6; } @@ -410,11 +395,7 @@ .header-actions, .window-controls, .header-tabs, -.status-strip, -.layout-toolbar, -.panel-heading, -.device-topline, -.mini-panel-heading { +.layout-toolbar { display: flex; align-items: center; } @@ -503,7 +484,6 @@ .window-controls button, .primary-button, .secondary-button, -.screen-chip, .screen-rect { border: 0; cursor: pointer; @@ -551,55 +531,6 @@ color: #ffffff; } -.mac-window-controls { - position: relative; - z-index: 2; - align-self: center; - gap: 2px; - height: 36px; - padding: 0 7px 0 9px; -} - -.mac-window-controls button { - width: 22px; - height: 22px; - padding: 0; - display: grid; - place-items: center; - background: transparent; - box-shadow: none; - -webkit-app-region: no-drag; -} - -.mac-window-controls button::before { - content: ""; - width: 12px; - height: 12px; - border-radius: 999px; - border: 1px solid rgba(0, 0, 0, 0.22); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); -} - -.mac-window-controls button.close::before { - background: #ff5f57; -} - -.mac-window-controls button.minimize::before { - background: #febc2e; -} - -.mac-window-controls button.maximize::before { - background: #28c840; -} - -.mac-window-controls button:hover::before { - filter: brightness(0.95); -} - -.theme-light .mac-window-controls button::before { - border-color: rgba(31, 41, 55, 0.18); -} - .window-control-icon { width: 12px; height: 12px; @@ -623,23 +554,8 @@ justify-self: auto; } -.role-status-pill { - display: inline-flex; - align-items: center; - min-height: 32px; - padding: 0 10px; - border: 1px solid rgba(63, 63, 70, 0.9); - border-radius: 999px; - background: #242429; - color: #d4d4d8; - font-size: 12px; - font-weight: 800; - white-space: nowrap; -} - .primary-button, -.secondary-button, -.screen-chip { +.secondary-button { min-height: 34px; border-radius: 8px; padding: 0 12px; @@ -654,27 +570,23 @@ color: #f8fafc; } -.secondary-button, -.screen-chip { +.secondary-button { border: 1px solid rgba(63, 63, 70, 0.92); background: #242429; color: #d4d4d8; } .secondary-button:hover, -.screen-chip:hover, .primary-button:hover { transform: translateY(-1px); } -.secondary-button:hover, -.screen-chip:hover { +.secondary-button:hover { background: #2d2d33; } .primary-button:disabled, -.secondary-button:disabled, -.screen-chip:disabled { +.secondary-button:disabled { cursor: progress; opacity: 0.55; } @@ -734,35 +646,6 @@ font-size: 13px; } -.status-strip { - min-height: 62px; - gap: 10px; - padding: 0 4px; -} - -.status-strip div { - min-width: 116px; - padding: 10px 14px; - border: 1px solid rgba(63, 63, 70, 0.82); - border-radius: 10px; - background: #222226; -} - -.status-strip span { - display: block; - color: #fafafa; - font-size: 20px; - line-height: 1; - font-weight: 800; -} - -.status-strip label { - display: block; - margin-top: 7px; - color: #8f8f99; - font-size: 12px; -} - .workspace-shell { flex: 1; min-height: 0; @@ -770,7 +653,6 @@ } .layout-panel, -.inspector-panel, .loading-panel { min-width: 0; border: 1px solid rgba(63, 63, 70, 0.88); @@ -787,16 +669,12 @@ gap: 12px; } -.layout-toolbar, -.panel-heading { +.layout-toolbar { justify-content: space-between; gap: 14px; } -.layout-toolbar h1, -.panel-heading h2, -.device-card h3, -.mini-panel h3 { +.layout-toolbar h1 { margin: 0; } @@ -1022,17 +900,6 @@ font-size: 12px; } -.inspector-panel { - min-height: 0; - overflow: hidden; -} - -.tab-panel { - height: 100%; - padding: 16px; - overflow: auto; -} - .page-panel { flex: 0 0 auto; min-height: auto; @@ -1304,34 +1171,21 @@ line-height: 1.55; } -.metric-grid, -.device-meta, .network-meta { display: grid; gap: 10px; } -.metric-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.metric-grid div, -.network-meta div, -.mini-panel, -.device-card, -.peer-list li { +.network-meta div { border: 1px solid rgba(63, 63, 70, 0.82); border-radius: 10px; background: #242429; } -.metric-grid div, .network-meta div { padding: 10px; } -.metric-grid dt, -.device-meta dt, .network-meta dt { color: #71717a; font-size: 11px; @@ -1340,8 +1194,6 @@ text-transform: uppercase; } -.metric-grid dd, -.device-meta dd, .network-meta dd { margin: 4px 0 0; color: #f4f4f5; @@ -1349,24 +1201,6 @@ overflow-wrap: anywhere; } -.arrange-toolbar, -.device-actions, -.screen-list { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.arrange-toolbar { - margin-top: 14px; -} - -.arrange-toolbar span { - width: 100%; - color: #a1a1aa; - font-size: 13px; -} - .add-device-form { display: grid; grid-template-columns: 1fr 1fr auto auto; @@ -1379,8 +1213,7 @@ } .add-device-form input, -.settings-number-input, -.settings-text-input { +.settings-number-input { width: 100%; min-width: 0; border: 1px solid rgba(63, 63, 70, 0.92); @@ -1391,15 +1224,13 @@ } .add-device-form input, -.settings-number-input, -.settings-text-input { +.settings-number-input { min-height: 34px; padding: 0 10px; } .add-device-form input:focus, -.settings-number-input:focus, -.settings-text-input:focus { +.settings-number-input:focus { border-color: #2786ff; } @@ -1531,48 +1362,6 @@ color: #f4f4f5; } -.runtime-summary-card { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(280px, 0.42fr); - gap: 16px; - align-items: start; -} - -.protocol-card { - display: grid; - grid-template-columns: minmax(0, 0.44fr) minmax(0, 1fr); - gap: 16px; - align-items: start; -} - -.protocol-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; -} - -.protocol-grid div { - min-height: 72px; - padding: 12px; - border: 1px solid rgba(63, 63, 70, 0.82); - border-radius: 8px; - background: #242429; -} - -.protocol-grid span { - display: block; - color: #60a5fa; - font-size: 12px; - font-weight: 800; -} - -.protocol-grid strong { - display: block; - margin-top: 8px; - color: #f4f4f5; - font-size: 14px; -} - .runtime-meta { margin: 0; display: grid; @@ -1599,28 +1388,11 @@ font-size: 13px; } -.device-list, -.stack, -.peer-list, -.adjacency-list { +.stack { display: grid; gap: 10px; } -.device-card, -.mini-panel { - padding: 12px; -} - -.device-card.active { - border-color: rgba(39, 134, 255, 0.9); - background: #202a36; -} - -.device-topline { - gap: 10px; -} - .device-badge { width: 13px; height: 13px; @@ -1640,104 +1412,10 @@ background: #f59e0b; } -.device-topline p, -.adjacency-list, -.peer-list, -.screen-list, -.error-banner, -.mini-panel p { - margin: 0; -} - -.device-topline p { - color: #a1a1aa; - font-size: 13px; -} - -.role-pill { - display: inline-flex; - align-items: center; - margin-left: 8px; - padding: 2px 7px; - border-radius: 999px; - background: #303038; - color: #d4d4d8; - font-size: 12px; - font-weight: 800; -} - -.role-pill-local { - background: rgba(39, 134, 255, 0.18); - color: #60a5fa; -} - -.role-pill-client { - background: rgba(20, 184, 166, 0.16); - color: #2dd4bf; -} - -.device-meta { - grid-template-columns: 1fr 0.7fr; - margin-top: 12px; -} - -.device-actions { - margin-top: 12px; -} - .danger-button { color: #fda4af; } -.screen-list, -.peer-list, -.adjacency-list { - list-style: none; - padding: 0; -} - -.screen-list { - margin-top: 12px; -} - -.screen-chip { - font-size: 12px; -} - -.screen-chip.selected { - background: #2786ff; - border-color: #2786ff; - color: #f8fafc; -} - -.empty-screen-note { - color: #71717a; - font-size: 13px; -} - -.peer-list { - margin-bottom: 14px; -} - -.peer-list li { - display: flex; - justify-content: space-between; - gap: 10px; - padding: 10px; - font-size: 13px; -} - -.peer-list div { - min-width: 0; - display: grid; - gap: 3px; -} - -.peer-list span { - color: #8f8f99; - overflow-wrap: anywhere; -} - .runtime-line { margin: 8px 0; color: #f4f4f5; @@ -1745,12 +1423,6 @@ font-weight: 800; } -.mini-panel p:last-child { - color: #a1a1aa; - font-size: 13px; - overflow-wrap: anywhere; -} - .network-meta { margin-top: 4px; } @@ -2007,20 +1679,6 @@ line-height: 1.55; } -.adjacency-list { - margin-top: 14px; -} - -.adjacency-list li { - display: flex; - justify-content: space-between; - gap: 10px; - padding: 9px 0; - border-top: 1px solid rgba(63, 63, 70, 0.72); - color: #d4d4d8; - font-size: 13px; -} - .info-banner { margin: 0; padding: 10px 12px; @@ -2447,9 +2105,6 @@ .connection-stack, .settings-layout, - .runtime-summary-card, - .protocol-card, - .protocol-grid, .connection-add-card { grid-template-columns: 1fr; } @@ -2461,10 +2116,6 @@ .connection-actions { justify-content: flex-start; } - - .inspector-panel { - max-height: 42vh; - } } @media (max-width: 560px) { @@ -2503,22 +2154,16 @@ margin: 0 -12px 0; } - .status-strip { - overflow-x: auto; - } - .layout-board { min-height: 420px; } .add-device-form, - .metric-grid, .compact-meta, .settings-control-row, .role-choice-grid, .page-heading, - .runtime-meta, - .protocol-grid { + .runtime-meta { grid-template-columns: 1fr; } diff --git a/src/App.tsx b/src/App.tsx index 1cb3295..b056f93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,7 +51,6 @@ import { APP_VERSION, REPOSITORY_URL } from "./constants"; import { TEXT } from "./i18n"; import type { AppText } from "./i18n"; import { - edgeSwitchHotkeyFromKeyboardEvent, formatEdgeSwitchHotkeyForDisplay, hotkeyFromKeyboardEvent, metaKeyLabelForPlatform, @@ -643,7 +642,6 @@ function App() { navigator.platform.toLowerCase(); const metaKeyLabel = metaKeyLabelForPlatform(localPlatform); const usesWindowsChrome = localPlatform.includes("win"); - const usesCustomChrome = usesWindowsChrome; const inputServiceInstalled = Boolean(runtime?.inputService.installed); const inputServiceReady = inputServiceInstalled && @@ -674,7 +672,7 @@ function App() { const shellClassName = `app-shell ${chromeClassName} theme-${resolvedTheme}`; function renderWindowTitlebar() { - if (!usesCustomChrome) { + if (!usesWindowsChrome) { return null; } @@ -1400,7 +1398,7 @@ function App() { } const captureEdgeSwitchHotkey = useEffectEvent((event: KeyboardEvent) => { - const hotkey = edgeSwitchHotkeyFromKeyboardEvent(event, metaKeyLabel); + const hotkey = hotkeyFromKeyboardEvent(event, metaKeyLabel); if (!hotkey) { return; } @@ -3354,7 +3352,7 @@ function hotkeyTagLabel(part: string, platform: string) { case "disabled": return "Off"; default: - return part.length === 1 ? part.toUpperCase() : part.toUpperCase(); + return part.toUpperCase(); } } diff --git a/src/desktopApi.ts b/src/desktopApi.ts index 2ac66c6..c051ef2 100644 --- a/src/desktopApi.ts +++ b/src/desktopApi.ts @@ -1,5 +1,5 @@ import { invoke, isTauri } from '@tauri-apps/api/core' -import { RELEASES_URL, REPOSITORY_URL } from './constants' +import { APP_VERSION, RELEASES_URL, REPOSITORY_URL } from './constants' import { defaultLayout } from './defaultLayout' import type { AppStateSnapshot, @@ -29,70 +29,47 @@ export interface FileTransferSummary { byteCount: number } -const FALLBACK_RUNTIME: RuntimeStatus = { +// ponytail: static stopped-state stub so `npm run dev` in a plain browser still +// renders the layout editor; the real runtime lives in the Tauri backend. +const STUB_DETAIL = 'Available only in the Tauri desktop runtime.' + +const BROWSER_RUNTIME: RuntimeStatus = { started: false, - transport: { - state: 'stubbed', - detail: 'Desktop fallback mode does not start discovery, input capture, or injection.', - }, - capture: { - state: 'stubbed', - detail: 'Global input capture will be implemented in the Rust layer.', - }, - inject: { - state: 'stubbed', - detail: 'System input injection will be implemented in the Rust layer.', - }, - clipboard: { - state: 'stubbed', - detail: '剪贴板同步需要在 Tauri 桌面端运行。', - }, - privilege: { - isElevated: false, - canElevate: false, - detail: 'Administrator restart is available only in the Windows desktop runtime.', - }, + transport: { state: 'stubbed', detail: STUB_DETAIL }, + capture: { state: 'stubbed', detail: STUB_DETAIL }, + inject: { state: 'stubbed', detail: STUB_DETAIL }, + clipboard: { state: 'stubbed', detail: STUB_DETAIL }, + privilege: { isElevated: false, canElevate: false, detail: STUB_DETAIL }, inputService: { installed: false, running: false, workerSessionId: null, pipeAvailable: false, sasAvailable: false, - detail: 'Windows lock screen input service is available only in the Windows desktop runtime.', + detail: STUB_DETAIL, }, discovery: { state: 'idle', - detail: 'LAN discovery is available only in the Tauri desktop runtime.', - port: 47833, + detail: STUB_DETAIL, + port: defaultLayout.transportPort, localPeer: { id: 'browser-preview', - name: 'Desktop fallback', + name: 'Browser preview', platform: navigator.platform, machineRole: defaultLayout.machineRole, clusterId: defaultLayout.clusterId, pairingRequired: false, - host: window.location.hostname || 'localhost', + host: 'localhost', ip: '127.0.0.1', transportPort: defaultLayout.transportPort, quicPort: defaultLayout.quicPort, transportPublicKey: '', protocolVersion: 1, - screenCount: 1, + screenCount: 0, inputReady: false, - screens: [ - { - id: 'browser-display-1', - name: 'Browser display', - x: defaultLayout.devices[0].screens[0].x, - y: defaultLayout.devices[0].screens[0].y, - width: defaultLayout.devices[0].screens[0].width, - height: defaultLayout.devices[0].screens[0].height, - scale: defaultLayout.devices[0].screens[0].scale, - isPrimary: true, - }, - ], - appVersion: '0.1.0', - lastSeenMs: Date.now(), + screens: [], + appVersion: APP_VERSION, + lastSeenMs: 0, }, peers: [], }, @@ -106,13 +83,11 @@ const FALLBACK_RUNTIME: RuntimeStatus = { }, } -let browserRuntime = FALLBACK_RUNTIME - export async function loadAppState(): Promise { if (!isTauri()) { return { layout: defaultLayout, - runtime: browserRuntime, + runtime: BROWSER_RUNTIME, } } @@ -123,7 +98,7 @@ export async function saveLayout(layout: LayoutState): Promise if (!isTauri()) { return { layout, - runtime: browserRuntime, + runtime: BROWSER_RUNTIME, } } @@ -134,7 +109,7 @@ export async function resetPairing(): Promise { if (!isTauri()) { return { layout: { ...defaultLayout, pairedControllers: [] }, - runtime: browserRuntime, + runtime: BROWSER_RUNTIME, } } @@ -159,29 +134,7 @@ export async function setAutostart(enabled: boolean): Promise { export async function startRuntime(): Promise { if (!isTauri()) { - browserRuntime = { - started: true, - transport: { - state: 'ready', - detail: 'Desktop fallback does not start native discovery, input capture, or injection.', - }, - capture: FALLBACK_RUNTIME.capture, - inject: FALLBACK_RUNTIME.inject, - clipboard: FALLBACK_RUNTIME.clipboard, - privilege: FALLBACK_RUNTIME.privilege, - inputService: FALLBACK_RUNTIME.inputService, - pairing: FALLBACK_RUNTIME.pairing, - discovery: { - ...FALLBACK_RUNTIME.discovery, - detail: 'Desktop fallback cannot scan the LAN. Start the Tauri desktop app to use UDP discovery.', - localPeer: { - ...FALLBACK_RUNTIME.discovery.localPeer, - lastSeenMs: Date.now(), - }, - }, - } - - return browserRuntime + return BROWSER_RUNTIME } return invoke('start_runtime') @@ -189,18 +142,7 @@ export async function startRuntime(): Promise { export async function readRuntimeStatus(): Promise { if (!isTauri()) { - browserRuntime = { - ...browserRuntime, - discovery: { - ...browserRuntime.discovery, - localPeer: { - ...browserRuntime.discovery.localPeer, - lastSeenMs: Date.now(), - }, - }, - } - - return browserRuntime + return BROWSER_RUNTIME } return invoke('read_runtime_status') @@ -209,21 +151,20 @@ export async function readRuntimeStatus(): Promise { export async function readDiagnosticInfo(): Promise { if (!isTauri()) { return { - report: 'Desktop diagnostics are available only in the Tauri desktop runtime.', - appVersion: '0.1.0', + report: STUB_DETAIL, + appVersion: APP_VERSION, platform: navigator.platform, role: defaultLayout.machineRole, - runtimeStarted: browserRuntime.started, - localName: browserRuntime.discovery.localPeer.name, - localIp: browserRuntime.discovery.localPeer.ip, - discoveryPort: browserRuntime.discovery.port, - quicPort: browserRuntime.discovery.localPeer.quicPort, - peerCount: browserRuntime.discovery.peers.length, - knownDevices: [], + runtimeStarted: false, + localName: BROWSER_RUNTIME.discovery.localPeer.name, + localIp: BROWSER_RUNTIME.discovery.localPeer.ip, + discoveryPort: BROWSER_RUNTIME.discovery.port, + quicPort: BROWSER_RUNTIME.discovery.localPeer.quicPort, + peerCount: 0, logDir: '', configDir: '', - networkHint: 'Desktop diagnostics are available only in the Tauri desktop runtime.', - firewallHint: 'Desktop diagnostics are available only in the Tauri desktop runtime.', + networkHint: STUB_DETAIL, + firewallHint: STUB_DETAIL, } } @@ -240,8 +181,7 @@ export async function openLogDirectory(): Promise { export async function stopRuntime(): Promise { if (!isTauri()) { - browserRuntime = FALLBACK_RUNTIME - return browserRuntime + return BROWSER_RUNTIME } return invoke('stop_runtime') @@ -249,7 +189,7 @@ export async function stopRuntime(): Promise { export async function scanLanPeers(): Promise { if (!isTauri()) { - return browserRuntime.discovery + return BROWSER_RUNTIME.discovery } return invoke('scan_lan_peers') @@ -284,7 +224,7 @@ export async function confirmLanPairing(host: string, code: string) { export async function dismissPairingRequest(): Promise { if (!isTauri()) { - return browserRuntime + return BROWSER_RUNTIME } return invoke('dismiss_pairing_request') @@ -300,21 +240,13 @@ export async function writeClipboardText(text: string): Promise { export async function readPerformanceSample(): Promise { if (!isTauri()) { - const memory = (performance as Performance & { - memory?: { - usedJSHeapSize: number - jsHeapSizeLimit: number - } - }).memory - const usedMb = memory ? memory.usedJSHeapSize / 1024 / 1024 : 96 + Math.sin(Date.now() / 2000) * 12 - return { timestampMs: Date.now(), - appCpuPercent: Math.max(2, Math.min(100, 18 + Math.sin(Date.now() / 1500) * 10)), - appMemoryMb: usedMb, - transportPackets: Math.round(Date.now() / 1000) % 700, - inputEvents: Math.round(Date.now() / 80) % 1200, - clipboardPackets: Math.round(Date.now() / 5000) % 80, + appCpuPercent: 0, + appMemoryMb: 0, + transportPackets: 0, + inputEvents: 0, + clipboardPackets: 0, } } @@ -331,7 +263,7 @@ export async function restartAsAdmin(): Promise { export async function readInputServiceStatus(): Promise { if (!isTauri()) { - return browserRuntime.inputService + return BROWSER_RUNTIME.inputService } return invoke('read_input_service_status') @@ -339,7 +271,7 @@ export async function readInputServiceStatus(): Promise { export async function installInputService(): Promise { if (!isTauri()) { - return browserRuntime.inputService + return BROWSER_RUNTIME.inputService } return invoke('install_input_service') @@ -347,20 +279,12 @@ export async function installInputService(): Promise { export async function uninstallInputService(): Promise { if (!isTauri()) { - return browserRuntime.inputService + return BROWSER_RUNTIME.inputService } return invoke('uninstall_input_service') } -export async function sendSecureAttention(deviceId: string): Promise { - if (!isTauri()) { - return - } - - await invoke('send_secure_attention', { deviceId }) -} - export async function sendFilesToDevice(deviceId: string, paths: string[]): Promise { if (!isTauri()) { return { diff --git a/src/hotkeyInput.ts b/src/hotkeyInput.ts index 7756363..61a52cf 100644 --- a/src/hotkeyInput.ts +++ b/src/hotkeyInput.ts @@ -24,13 +24,6 @@ const MODIFIER_KEYS = new Set([ "windows", ]); -export function edgeSwitchHotkeyFromKeyboardEvent( - event: HotkeyKeyboardEventLike, - metaKeyLabel: MetaKeyLabel = "meta", -): string | null { - return hotkeyFromKeyboardEvent(event, metaKeyLabel); -} - /// Generic hotkey capture used by both the runtime-toggle recorder and the /// screen-switch direction recorders. Returns the canonical hotkey string, or /// `null` to ignore the event (e.g. a bare modifier press), or `"disabled"` to diff --git a/src/i18n.ts b/src/i18n.ts index 12993b9..f45fc01 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -35,7 +35,6 @@ export const TEXT = { minimize: "最小化", maximize: "最大化", close: "隐藏", - copyright: "Copyright © 2026 MyKVM", github: "GitHub", }, loading: { @@ -59,18 +58,14 @@ export const TEXT = { roleTitle: "工作模式", roleCopy: "服务端负责管理布局并捕获输入;客户端保持轻量常驻,接收远端键鼠。", - pairingTitle: "配对状态", pairedWith: "已配对", notPaired: "当前未配对", resetPairing: "解除配对", - resetPairingCopy: - "清除本机已保存的配对,回到「待配对」状态,然后在服务端重新发起配对(重输验证码)。换电脑、升级后失配或想重配时用。", autostart: "开机自启", autostartOn: "开启", autostartOff: "关闭", edgeSwitchHotkey: "快捷启停", edgeSwitchHotkeyRecording: "按下启停快捷键...", - edgeSwitchHotkeyPlaceholder: "点击后按组合键 / f12 / disabled", screenSwitchTitle: "快捷切屏", screenSwitchRecording: "按下快捷键...", screenSwitchCopy: @@ -102,18 +97,12 @@ export const TEXT = { adminRestarting: "正在重启", inputServiceTitle: "锁屏控制服务", inputServiceEyebrow: "Windows LocalSystem", - inputServiceCopy: - "Windows 被控端可安装本机 SYSTEM 服务,在锁屏、安全桌面和普通桌面间跟随注入远端授权输入。", inputServicePromptTitle: "安装锁屏控制服务", inputServicePromptCopy: "这台 Windows 作为被控端时,需要本机 LocalSystem 服务才能在锁屏和 UAC 界面接收键鼠。安装会请求一次管理员授权。", uninstallInputServicePromptTitle: "卸载锁屏控制服务", uninstallInputServicePromptCopy: "卸载后,这台 Windows 在锁屏和 UAC 界面将不能接收远端键鼠输入。", - inputServiceInstalled: "服务", - inputServiceWorker: "会话", - inputServicePipe: "管道", - inputServiceSas: "Ctrl+Alt+Del", inputServiceReady: "已就绪", inputServiceInstalledStatus: "已安装", inputServiceNeedsInstall: "需要服务", @@ -196,13 +185,7 @@ export const TEXT = { scanningCopy: "正在搜索同网络下的 MyKVM 设备…", pair: "配对", repair: "重新配对", - fileTransferTitle: "文件传输 (bate)", - fileTransferCopy: "把文件拖到目标设备,MyKVM 会通过已配对的 QUIC 连接发送。(测试功能,可能不稳定)", - fileTransferDrop: "拖入发送", - fileTransferSending: "发送中", fileTransferSent: "已发送", - fileTransferUnavailable: "暂无在线且已配对的接收设备。", - sendSecureAttention: "发送 Ctrl+Alt+Del", pairingEyebrow: "PAIRING", serverPairingTitle: "输入客户端验证码", serverPairingCopy: "客户端屏幕上会显示 6 位验证码:", @@ -221,7 +204,6 @@ export const TEXT = { inputNotReady: "输入未就绪", deviceNamePlaceholder: "设备名称", hostPlaceholder: "Host 或 IP(可加 :端口,如 192.168.1.5:47833)", - screensUnit: "屏", }, errors: { title: "操作失败", @@ -229,7 +211,6 @@ export const TEXT = { loadState: "加载桌面状态失败。", saveLayout: "保存布局失败。", updateRuntime: "更新原生运行时失败。", - refreshState: "刷新桌面状态失败。", scanLan: "扫描局域网设备失败。", manualHostRequired: "请输入对方设备的 Host 或 IP。", peerWithoutScreens: "在线,但没有上报屏幕信息。", @@ -281,7 +262,6 @@ export const TEXT = { minimize: "Minimize", maximize: "Maximize", close: "Hide", - copyright: "Copyright © 2026 MyKVM", github: "GitHub", }, loading: { @@ -307,18 +287,14 @@ export const TEXT = { roleTitle: "Work Mode", roleCopy: "Server manages layout and captures input; Client stays lightweight and receives remote input.", - pairingTitle: "Pairing", pairedWith: "Paired with", notPaired: "Not paired", resetPairing: "Unpair", - resetPairingCopy: - "Clear this machine's saved pairing and return to \"needs pairing\", then re-initiate from the server (re-enter the code). Use after switching machines, an update mismatch, or to re-pair.", autostart: "Launch at startup", autostartOn: "On", autostartOff: "Off", edgeSwitchHotkey: "Quick toggle", edgeSwitchHotkeyRecording: "Press start/stop shortcut...", - edgeSwitchHotkeyPlaceholder: "Press shortcut / f12 / disabled", screenSwitchTitle: "Quick switch", screenSwitchRecording: "Press shortcut...", screenSwitchCopy: @@ -350,18 +326,12 @@ export const TEXT = { adminRestarting: "Restarting", inputServiceTitle: "Lock Screen Control", inputServiceEyebrow: "Windows LocalSystem", - inputServiceCopy: - "Windows clients can install a local SYSTEM service to inject authorized remote input across the normal desktop, secure desktop, and lock screen.", inputServicePromptTitle: "Install Lock Screen Control", inputServicePromptCopy: "This Windows client needs a local LocalSystem service to receive keyboard and mouse input on the lock screen and UAC desktop. Installing requests administrator approval once.", uninstallInputServicePromptTitle: "Uninstall Lock Screen Control", uninstallInputServicePromptCopy: "After uninstalling, this Windows client cannot receive remote keyboard or mouse input on the lock screen or UAC desktop.", - inputServiceInstalled: "Service", - inputServiceWorker: "Session", - inputServicePipe: "Pipe", - inputServiceSas: "Ctrl+Alt+Del", inputServiceReady: "Ready", inputServiceInstalledStatus: "Installed", inputServiceNeedsInstall: "Service Required", @@ -444,13 +414,7 @@ export const TEXT = { scanningCopy: "Searching for MyKVM devices on this network…", pair: "Pair", repair: "Re-pair", - fileTransferTitle: "File Transfer (bate)", - fileTransferCopy: "Drop files onto a target device to send them over the paired QUIC link. (Experimental, may be unstable)", - fileTransferDrop: "Drop to send", - fileTransferSending: "Sending", fileTransferSent: "Sent", - fileTransferUnavailable: "No online paired receiver is available.", - sendSecureAttention: "Send Ctrl+Alt+Del", pairingEyebrow: "PAIRING", serverPairingTitle: "Enter Client Code", serverPairingCopy: "The client screen shows a 6-digit code:", @@ -469,7 +433,6 @@ export const TEXT = { inputNotReady: "Input not ready", deviceNamePlaceholder: "Device name", hostPlaceholder: "Host or IP (optional :port, e.g. 192.168.1.5:47833)", - screensUnit: "screens", }, errors: { title: "Operation Failed", @@ -477,7 +440,6 @@ export const TEXT = { loadState: "Failed to load desktop state.", saveLayout: "Failed to save layout.", updateRuntime: "Failed to update native runtime.", - refreshState: "Failed to refresh desktop state.", scanLan: "Failed to scan LAN peers.", manualHostRequired: "Enter the other device host or IP.", peerWithoutScreens: "is online, but did not report screen data.", diff --git a/src/runtime.ts b/src/runtime.ts index 18b3c46..7a9a70c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -71,17 +71,6 @@ export interface AppStateSnapshot { runtime: RuntimeStatus } -export interface DiagnosticDevice { - name: string - host: string - role: string - online: boolean - inputReady: boolean - discoveryPort: number - quicPort: number - sameSubnet?: boolean | null -} - export interface DiagnosticInfo { report: string appVersion: string @@ -93,7 +82,6 @@ export interface DiagnosticInfo { discoveryPort: number quicPort: number peerCount: number - knownDevices: DiagnosticDevice[] logDir: string configDir: string networkHint: string From 4bc7b073d56ad51c98e0de32c9681103872a81b0 Mon Sep 17 00:00:00 2001 From: fc221 Date: Wed, 15 Jul 2026 19:29:54 +0800 Subject: [PATCH 08/12] fix(input): explain UIPI-refused injection instead of failing silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a standard-user MyKVM injects a click or key into an elevated / uiAccess foreground window (Task Manager ships uiAccess=true, UAC-elevated apps, etc.), Windows drops the SendInput with ERROR_ACCESS_DENIED. Cursor MOVE still works (SetCursorPos), so the symptom is 'remote control just stopped' with no explanation while MyKVM is plainly still running. The code was writing a per-event debug file to C:\ProgramData\MyKVM\*.txt on every refusal and telling the user nothing. Replace both with one throttled log line (once per 10s) that names the cause and the fix: restart MyKVM as administrator to control elevated windows. Not a code-fixable limitation — UIPI is a security boundary — but no longer a silent one. Co-Authored-By: Claude Fable 5 --- src-tauri/src/windows_input.rs | 60 ++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/windows_input.rs b/src-tauri/src/windows_input.rs index c8931ac..7349ee5 100644 --- a/src-tauri/src/windows_input.rs +++ b/src-tauri/src/windows_input.rs @@ -1,9 +1,47 @@ #![cfg(target_os = "windows")] -use std::ptr; +use std::{ + ptr, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; use crate::shared_input::{mouse_button_mask, InputCommand, MouseButton}; +/// Explains a refused button/key injection, throttled to one line per 10s. +/// +/// Windows blocks `SendInput` from a standard-user process into an elevated or +/// uiAccess foreground window (Task Manager — which ships `uiAccess="true"` — a +/// UAC-elevated app, etc.) with `ERROR_ACCESS_DENIED`; the events are dropped +/// silently, which reads to the user as "remote control just stopped" even +/// though MyKVM is still running. Cursor MOVE keeps working because it goes +/// through SetCursorPos, not SendInput — so only clicks and keys die, exactly +/// the reported symptom. Surface it instead of failing mutely (this replaces a +/// leftover per-event debug write to C:\ProgramData\MyKVM\*.txt). +fn note_injection_refused(kind: &str, error: u32) { + use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; + + static LAST_WARN: OnceLock> = OnceLock::new(); + let cell = LAST_WARN.get_or_init(|| Mutex::new(Instant::now() - Duration::from_secs(60))); + if let Ok(mut last) = cell.lock() { + if last.elapsed() < Duration::from_secs(10) { + return; + } + *last = Instant::now(); + } + + if error == ERROR_ACCESS_DENIED { + log::warn!( + "injected {kind} refused by Windows (ERROR_ACCESS_DENIED): an elevated or \ + uiAccess window (e.g. Task Manager, a UAC-elevated app) has focus. A \ + standard-user MyKVM cannot hook or inject into higher-privilege windows — \ + restart MyKVM as administrator (Settings) on this machine to control them." + ); + } else { + log::warn!("injected {kind} was refused: SendInput failed with error {error}"); + } +} + pub fn inject_command(command: &InputCommand, pressed_keys: &mut Vec, button_mask: &mut u64) { if matches!(command, InputCommand::ReleaseAll) { release_pressed_inputs(pressed_keys, button_mask); @@ -257,14 +295,8 @@ pub fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) { }, }; unsafe { - let sent = SendInput(1, &input, std::mem::size_of::() as i32); - if sent == 0 { - let err = windows_sys::Win32::Foundation::GetLastError(); - std::fs::write( - "C:\\ProgramData\\MyKVM\\helper-btn-err.txt", - format!("mouse button {flag:?} error {err}\n"), - ) - .ok(); + if SendInput(1, &input, std::mem::size_of::() as i32) == 0 { + note_injection_refused("mouse button", windows_sys::Win32::Foundation::GetLastError()); } } } @@ -328,14 +360,8 @@ pub fn inject_key(key_code: u16, down: bool) { }, }; unsafe { - let sent = SendInput(1, &input, std::mem::size_of::() as i32); - if sent == 0 { - let err = windows_sys::Win32::Foundation::GetLastError(); - std::fs::write( - "C:\\ProgramData\\MyKVM\\helper-key-err.txt", - format!("key {key_code:#04x} down={down} error {err}\n"), - ) - .ok(); + if SendInput(1, &input, std::mem::size_of::() as i32) == 0 { + note_injection_refused("key", windows_sys::Win32::Foundation::GetLastError()); } } } From 602a95ced5e731f433126ef6c776c9e9706dd1d3 Mon Sep 17 00:00:00 2001 From: fc221 Date: Thu, 16 Jul 2026 14:55:24 +0800 Subject: [PATCH 09/12] fix(clipboard): authorize by transport key so mac->win sync survives id drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #23: keyboard/mouse worked but clipboard synced only one way (Windows copy -> macOS paste worked; macOS copy -> Windows paste never did). Root cause is an authorization asymmetry between input and clipboard on the receiving CLIENT. Input packets (packet_authorized_fields) accept a paired controller matched by its STABLE transport public key OR its id OR the legacy local-device fallback. Clipboard (clipboard_packet_authorized) matched by the origin id ALONE — and a controller's derived peer id drifts when its LAN IP changes, so once the macOS server's id no longer equalled the id Windows recorded at pairing, its clipboard was silently rejected while its input kept flowing (key still matched). The reverse direction worked because the macOS receiver is role=server and skips the paired-controller check entirely. Carry the sender's transport public key on ClipboardPacket (serde-default, so older peers still decode) and match it first, mirroring input auth. The key is ~700 bytes on a rare, stream-delivered packet that already carries the full copied text/image, so the size cost is negligible. Co-Authored-By: Claude Fable 5 --- src-tauri/src/lib.rs | 86 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e47f69f..8a338f1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4411,6 +4411,14 @@ fn clipboard_ready_status() -> NativeStageStatus { struct ClipboardPacket { protocol: String, origin_id: String, + // The sender's QUIC transport public key. Defaulted so packets from older + // peers (which never sent it) still decode as an empty string. Authorization + // matches on this stable key first, exactly like input packets, so a copy + // still syncs after the origin's derived peer id drifts (e.g. its LAN IP + // changed since pairing) — the bug where input kept working but clipboard + // silently stopped in one direction. + #[serde(default)] + origin_transport_public_key: String, #[serde(default)] target_id: String, #[serde(default)] @@ -4445,6 +4453,7 @@ struct ClipboardFormat { fn clipboard_packet_from_content( content: ClipboardContent, origin_id: String, + origin_transport_public_key: String, target_id: String, cluster_id: String, pair_secret: String, @@ -4455,6 +4464,7 @@ fn clipboard_packet_from_content( ClipboardContent::Text(text) => ClipboardPacket { protocol: CLIPBOARD_PROTOCOL.into(), origin_id, + origin_transport_public_key, target_id, cluster_id, pair_secret, @@ -4471,6 +4481,7 @@ fn clipboard_packet_from_content( ClipboardContent::Image(image) => ClipboardPacket { protocol: CLIPBOARD_PROTOCOL.into(), origin_id, + origin_transport_public_key, target_id, cluster_id, pair_secret, @@ -4584,6 +4595,7 @@ fn run_clipboard_sync( let packet = clipboard_packet_from_content( content, local_peer_id.clone(), + quic_transport.public_key().to_string(), target.device_id.clone(), target.cluster_id.clone(), target.pair_secret.clone(), @@ -4850,10 +4862,19 @@ fn clipboard_packet_authorized(layout: &LayoutState, packet: &ClipboardPacket) - } if layout.machine_role == "client" && !layout.paired_controllers.is_empty() { - return layout - .paired_controllers - .iter() - .any(|controller| controller.id == packet.origin_id); + // Mirror input-packet authorization (packet_authorized_fields): match on + // the STABLE transport public key first, then the id, then the legacy + // "local-device" fallback. Matching by id alone silently rejected a + // controller whose derived peer id had drifted (LAN IP change) even + // though its key was unchanged — which is why input kept working while + // clipboard from that controller stopped. + let key = packet.origin_transport_public_key.trim(); + return layout.paired_controllers.iter().any(|controller| { + (!key.is_empty() && controller.transport_public_key == key) + || controller.id == packet.origin_id + }) || (layout.paired_controllers.len() == 1 + && packet.origin_id == "local-device" + && !key.is_empty()); } true @@ -7459,6 +7480,7 @@ mod tests { let mut packet = ClipboardPacket { protocol: CLIPBOARD_PROTOCOL.into(), origin_id: "attacker".into(), + origin_transport_public_key: String::new(), target_id: "local-device".into(), cluster_id: layout.cluster_id.clone(), pair_secret: layout.pair_secret.clone(), @@ -7478,6 +7500,54 @@ mod tests { assert!(clipboard_packet_authorized(&layout, &packet)); } + #[test] + fn clipboard_packet_authorized_by_transport_key_after_id_drift() { + // Regression for the macOS->Windows one-way clipboard bug: input kept + // working (it matches the stable transport key) but clipboard was + // rejected because it matched the origin id alone, which drifts when the + // controller's LAN IP changes. Clipboard must accept the same key. + let mut layout = test_layout(); + layout.machine_role = "client".into(); + layout.paired_controllers = vec![PairedController { + id: "server-10-0-0-1".into(), + name: "Server".into(), + host: "server".into(), + ip: "10.0.0.1".into(), + transport_public_key: "server-key".into(), + protocol_version: quic_transport::PROTOCOL_VERSION, + cluster_id: layout.cluster_id.clone(), + paired_at_ms: now_ms(), + }]; + let mut packet = ClipboardPacket { + protocol: CLIPBOARD_PROTOCOL.into(), + // id no longer matches the recorded controller (IP moved), + origin_id: "server-10-0-0-77".into(), + origin_transport_public_key: "server-key".into(), + target_id: "local-device".into(), + cluster_id: layout.cluster_id.clone(), + pair_secret: layout.pair_secret.clone(), + signature: "text:hi".into(), + formats: vec![ClipboardFormat { + kind: "plainText".into(), + text: "hi".into(), + image: None, + }], + text: "hi".into(), + image: None, + sequence: 1, + }; + + assert!( + clipboard_packet_authorized(&layout, &packet), + "a drifted id with the paired transport key must still be authorized" + ); + packet.origin_transport_public_key = "attacker-key".into(); + assert!( + !clipboard_packet_authorized(&layout, &packet), + "neither the id nor the key matches — must be rejected" + ); + } + #[test] fn clipboard_image_signature_includes_content_hash() { let first = ClipboardContent::Image(ClipboardImage { @@ -7505,6 +7575,7 @@ mod tests { rgba_base64: "A".repeat(encoded_len), }), "local-device".into(), + String::new(), "peer-device".into(), "cluster-test".into(), "secret-test".into(), @@ -7525,6 +7596,7 @@ mod tests { let packet = clipboard_packet_from_content( ClipboardContent::Text("hello".into()), "local-device".into(), + String::new(), "peer-device".into(), "cluster-test".into(), "secret-test".into(), @@ -7564,6 +7636,7 @@ mod tests { let packet = ClipboardPacket { protocol: CLIPBOARD_PROTOCOL.into(), origin_id: "peer-client-10-0-0-2".into(), + origin_transport_public_key: String::new(), target_id: "local-device".into(), cluster_id: layout.cluster_id.clone(), pair_secret: layout.pair_secret.clone(), @@ -7612,6 +7685,7 @@ mod tests { let packet = clipboard_packet_from_content( ClipboardContent::Text("中文测试 abc 123".into()), "peer-client-10-0-0-2".into(), + String::new(), "local-device".into(), layout.cluster_id.clone(), layout.pair_secret.clone(), @@ -7652,6 +7726,7 @@ mod tests { let packet = ClipboardPacket { protocol: CLIPBOARD_PROTOCOL.into(), origin_id: "peer-client-10-0-0-2".into(), + origin_transport_public_key: String::new(), target_id: String::new(), cluster_id: layout.cluster_id.clone(), pair_secret: layout.pair_secret.clone(), @@ -7697,6 +7772,7 @@ mod tests { let first = clipboard_packet_from_content( ClipboardContent::Text("new".into()), "peer-client-10-0-0-2".into(), + String::new(), "local-device".into(), layout.cluster_id.clone(), layout.pair_secret.clone(), @@ -7705,6 +7781,7 @@ mod tests { let stale = clipboard_packet_from_content( ClipboardContent::Text("old".into()), "peer-client-10-0-0-2".into(), + String::new(), "local-device".into(), layout.cluster_id.clone(), layout.pair_secret.clone(), @@ -7738,6 +7815,7 @@ mod tests { let packet = clipboard_packet_from_content( ClipboardContent::Text("hello".into()), "peer-client-10-0-0-2".into(), + String::new(), "local-device".into(), layout.cluster_id.clone(), layout.pair_secret.clone(), From fbf508d9282dae14f2f818a19efb72fc72cd3e42 Mon Sep 17 00:00:00 2001 From: fc221 Date: Thu, 16 Jul 2026 15:15:16 +0800 Subject: [PATCH 10/12] perf(input): drop the pairing block from steady-state datagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The borrowed InputPacketRef removed the per-event credential CLONE but every datagram still carried the full ~0.8KB pairing block on the wire — ~0.5KB of which (mostly the base64 transport certificate) is constant for a whole control session. On a low-MTU / VPN path that leaves little headroom under the QUIC datagram size limit, and it is pure repetition. Send the credentials only ~once per 2s per destination; steady-state packets omit them (serde skip on empty) and shrink to ~0.15KB. The receiver authorizes a credentialled packet in full and caches the source address for 5s, then admits the credential-less packets in between while that authorization is fresh. A peer that never proved the pairing secret from an address never gets a cache entry, so its credential-less packets are always rejected — same trust as before, just amortized. The clipboard target is set by the credentialled packets and persists, so credential-less packets need nothing extra. Wire-compatible in decode (all credential fields already serde-default), but a credential-less packet is rejected by an OLD receiver, so BOTH sides must run this build — a one-sided update degrades that direction's input to ~0.5Hz until the peer is updated. Co-Authored-By: Claude Fable 5 --- src-tauri/src/input.rs | 218 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index d5d92fc..c140365 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -99,6 +99,86 @@ const MACOS_RAW_GESTURE_EVENT_TYPES: &[u32] = &[ #[cfg(target_os = "windows")] const WINDOWS_DESKTOP_CHECK_INTERVAL_MS: u64 = 250; +/// How often the full pairing credentials (~0.5KB, mostly the base64 transport +/// certificate) ride an input packet. Between refreshes, packets omit them and +/// the receiver authorizes by its per-source cache — cutting steady-state +/// input datagrams from ~0.8KB to ~0.15KB. Must stay below +/// `INPUT_ORIGIN_CACHE_TTL` so the receiver's authorization never lapses +/// mid-session. +const INPUT_FULL_CRED_REFRESH: Duration = Duration::from_secs(2); +/// How long the receiver treats a source address as authorized after a +/// credentialled packet, so it can admit the credential-less packets in +/// between. A peer that never proved the pairing secret from this address +/// never gets an entry, so credential-less packets from it are always rejected. +const INPUT_ORIGIN_CACHE_TTL: Duration = Duration::from_secs(5); + +/// True when a full-credential input packet is due for a destination whose last +/// credentialled send was `last_sent` (or never). Pure half of +/// `should_send_full_input_credentials`. +fn credential_send_due(last_sent: Option, now: Instant) -> bool { + last_sent + .map(|last| now.saturating_duration_since(last) >= INPUT_FULL_CRED_REFRESH) + .unwrap_or(true) +} + +/// True when a source authorized at `authorized_at` is still within the cache +/// TTL. Pure half of `input_origin_recently_authorized`. +fn origin_authorization_fresh(authorized_at: Option, now: Instant) -> bool { + authorized_at + .map(|at| now.saturating_duration_since(at) < INPUT_ORIGIN_CACHE_TTL) + .unwrap_or(false) +} + +/// Per-destination timestamp of the last full-credential input packet. Keyed by +/// the target's QUIC address so alternating between two targets still refreshes +/// each independently. +fn input_full_cred_tracker() -> &'static Mutex> { + static TRACKER: OnceLock>> = OnceLock::new(); + TRACKER.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Decides whether to attach full credentials to the packet now being built for +/// `addr`, and records the decision so the next `INPUT_FULL_CRED_REFRESH` +/// window of packets can omit them. On lock poisoning it errs toward including +/// credentials (a larger but always-authorizable packet). +fn should_send_full_input_credentials(addr: &str) -> bool { + let Ok(mut tracker) = input_full_cred_tracker().lock() else { + return true; + }; + let now = Instant::now(); + let due = credential_send_due(tracker.get(addr).copied(), now); + if due { + tracker.retain(|_, last| { + now.saturating_duration_since(*last) < INPUT_ORIGIN_CACHE_TTL.saturating_mul(4) + }); + tracker.insert(addr.to_string(), now); + } + due +} + +/// Source addresses that sent a valid credentialled input packet within +/// `INPUT_ORIGIN_CACHE_TTL`, so their credential-less packets can be admitted. +fn authorized_input_origins() -> &'static Mutex> { + static ORIGINS: OnceLock>> = OnceLock::new(); + ORIGINS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn cache_authorized_input_origin(source: SocketAddr) { + if let Ok(mut origins) = authorized_input_origins().lock() { + let now = Instant::now(); + origins.retain(|_, last| now.saturating_duration_since(*last) < INPUT_ORIGIN_CACHE_TTL); + origins.insert(source, now); + } +} + +fn input_origin_recently_authorized(source: SocketAddr) -> bool { + let authorized_at = authorized_input_origins() + .lock() + .ok() + .and_then(|origins| origins.get(&source).copied()); + origin_authorization_fresh(authorized_at, Instant::now()) +} + /// Last injected remote cursor position, packed x<<32|y, plus held buttons. /// Plain atomics: these are touched on every received mouse event and a /// global mutex there is contention for nothing (the fields are independent @@ -161,19 +241,30 @@ pub struct ClipboardTarget { pub expires_at: Option, } +fn str_ref_is_empty(value: &&str) -> bool { + value.is_empty() +} + /// Borrowing serialization mirror of [`InputPacket`]: identical named -/// MessagePack bytes (guarded by a test), but building one clones none of the -/// ~1KB of credential strings — send_packet runs per mouse event. +/// MessagePack bytes when every field is populated (guarded by a test), but +/// building one clones none of the ~0.8KB of credential strings — send_packet +/// runs per mouse event. The credential fields are also skipped when empty, so +/// steady-state packets (which omit them — see send_packet) drop ~0.5KB of the +/// static pairing block, mostly the base64 transport certificate. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct InputPacketRef<'a> { protocol: &'a str, target_device_id: &'a str, + #[serde(skip_serializing_if = "str_ref_is_empty")] origin_device_id: &'a str, origin_port: u16, + #[serde(skip_serializing_if = "str_ref_is_empty")] origin_transport_public_key: &'a str, origin_protocol_version: u16, + #[serde(skip_serializing_if = "str_ref_is_empty")] cluster_id: &'a str, + #[serde(skip_serializing_if = "str_ref_is_empty")] pair_secret: &'a str, event: &'a InputEvent, } @@ -1340,15 +1431,37 @@ fn send_packet( let Some(peer) = packet_context.peer.take() else { return false; }; + // Attach the full pairing credentials only ~once per INPUT_FULL_CRED_REFRESH + // per destination; steady-state packets omit them (empty -> skipped on the + // wire) and the receiver authorizes them from its per-source cache. This is + // what actually shrinks the datagram; the borrowed mirror above only kept + // the omitted-string case allocation-free. + let include_credentials = should_send_full_input_credentials(&peer.addr); let packet = InputPacketRef { protocol: INPUT_PROTOCOL, target_device_id: &target.device_id, - origin_device_id: &packet_context.origin_device_id, + origin_device_id: if include_credentials { + &packet_context.origin_device_id + } else { + "" + }, origin_port: quic_transport.port(), - origin_transport_public_key: quic_transport.public_key(), + origin_transport_public_key: if include_credentials { + quic_transport.public_key() + } else { + "" + }, origin_protocol_version: quic_transport::PROTOCOL_VERSION, - cluster_id: &packet_context.cluster_id, - pair_secret: &packet_context.pair_secret, + cluster_id: if include_credentials { + &packet_context.cluster_id + } else { + "" + }, + pair_secret: if include_credentials { + &packet_context.pair_secret + } else { + "" + }, event: &packet_context.event, }; @@ -1623,18 +1736,31 @@ pub fn handle_input_datagram( if packet.protocol != INPUT_PROTOCOL { return false; } + // Steady-state datagrams omit the pairing block (it rides ~once per + // INPUT_FULL_CRED_REFRESH). A credentialled packet is authorized in + // full and (re)authorizes this source; a credential-less one is + // admitted only while that authorization is still fresh, so a peer that + // never proved the pairing secret from this address can never inject. + let carries_credentials = !packet.pair_secret.trim().is_empty(); let command = { let Ok(layout) = layout_state.lock() else { return false; }; - if !packet_authorized(&layout, &packet) { - warn_unauthorized_packet(&layout, &packet); + if carries_credentials { + if !packet_authorized(&layout, &packet) { + warn_unauthorized_packet(&layout, &packet); + return true; + } + cache_authorized_input_origin(source); + } else if !input_origin_recently_authorized(source) { return true; } let local_peer_id = cached_local_peer_id(&layout); if !packet_targets_local(&layout, &packet.target_device_id, &local_peer_id) { return true; } + // No-op for credential-less packets (empty key); the clipboard + // target was set by the last credentialled packet and persists. refresh_clipboard_target(clipboard_target, &layout, &packet, source); input_event_to_command(&layout, native_layout, packet.event) }; @@ -6509,6 +6635,82 @@ mod tests { ); } + #[test] + fn credential_less_packet_omits_the_pairing_block_and_still_decodes() { + let event = InputEvent::MouseMove { + screen_id: "display-1".into(), + x: 320, + y: 240, + }; + let full = InputPacketRef { + protocol: INPUT_PROTOCOL, + target_device_id: "peer-device", + origin_device_id: "local-device", + origin_port: 47833, + // ~roughly the size of a real base64 transport certificate + origin_transport_public_key: &"A".repeat(492), + origin_protocol_version: quic_transport::PROTOCOL_VERSION, + cluster_id: "cluster-test", + pair_secret: "secret-test", + event: &event, + }; + let lean = InputPacketRef { + protocol: INPUT_PROTOCOL, + target_device_id: "peer-device", + origin_device_id: "", + origin_port: 47833, + origin_transport_public_key: "", + origin_protocol_version: quic_transport::PROTOCOL_VERSION, + cluster_id: "", + pair_secret: "", + event: &event, + }; + + let full_bytes = rmp_serde::to_vec_named(&full).expect("encode full"); + let lean_bytes = rmp_serde::to_vec_named(&lean).expect("encode lean"); + assert!( + lean_bytes.len() + 400 < full_bytes.len(), + "credential-less packet ({} bytes) should be far smaller than full ({} bytes)", + lean_bytes.len(), + full_bytes.len() + ); + + // The receiver still decodes it, with the omitted credentials defaulted + // to empty so it takes the cached-authorization path. + let decoded = decode_input_packet(&lean_bytes).expect("decode lean packet"); + assert!(decoded.pair_secret.is_empty()); + assert!(decoded.origin_transport_public_key.is_empty()); + assert_eq!(decoded.target_device_id, "peer-device"); + assert_eq!(decoded.origin_port, 47833); + } + + #[test] + fn credential_refresh_and_origin_cache_windows() { + let t0 = Instant::now(); + // Refresh: due when never sent, not due within the window, due after it. + assert!(credential_send_due(None, t0)); + assert!(!credential_send_due( + Some(t0), + t0 + INPUT_FULL_CRED_REFRESH - Duration::from_millis(1) + )); + assert!(credential_send_due(Some(t0), t0 + INPUT_FULL_CRED_REFRESH)); + + // Cache: never-seen is not fresh; within TTL is fresh; past TTL is not. + assert!(!origin_authorization_fresh(None, t0)); + assert!(origin_authorization_fresh( + Some(t0), + t0 + INPUT_ORIGIN_CACHE_TTL - Duration::from_millis(1) + )); + assert!(!origin_authorization_fresh( + Some(t0), + t0 + INPUT_ORIGIN_CACHE_TTL + )); + + // The refresh interval must stay under the cache TTL, or authorization + // would lapse between credentialled packets. + assert!(INPUT_FULL_CRED_REFRESH < INPUT_ORIGIN_CACHE_TTL); + } + #[test] fn input_packet_context_uses_stable_peer_origin_id() { let layout = layout_for_target_tests(); From cef30a555730043863617030a3840a797c6b5fa9 Mon Sep 17 00:00:00 2001 From: fc221 Date: Thu, 16 Jul 2026 16:00:16 +0800 Subject: [PATCH 11/12] feat(input): forward the mouse side (back/forward) buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mouse tester showed 后退/前进 stuck at 0: MouseButton only had Left/Right/Middle and the Windows capture hook ignored WM_XBUTTONDOWN/UP, so the side navigation buttons were never captured or injected. - MouseButton gains Back/Forward with their own mask bits - Windows capture handles WM_XBUTTON* and reads XBUTTON1(back)/XBUTTON2(forward) from the high word of mouseData - macOS injects them as OtherMouseDown/Up stamped with button number 3/4 (no double-click chaining — they are navigation, not click targets) - Windows injection (for the mac->win direction) uses MOUSEEVENTF_X* with the button in mouseData Both sides must run this build; an old peer can't decode the new wire variants. Caps->IME behaviour left as-is (⌃Space) per the user's choice. Co-Authored-By: Claude Fable 5 --- src-tauri/src/input.rs | 78 +++++++++++++++++++++++++++++----- src-tauri/src/shared_input.rs | 7 +++ src-tauri/src/windows_input.rs | 34 +++++++++------ 3 files changed, 96 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index c140365..984b4c3 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -2818,7 +2818,8 @@ fn set_control_clipboard_target( unsafe extern "system" fn windows_mouse_proc(code: i32, wparam: usize, lparam: isize) -> isize { use windows_sys::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, MSLLHOOKSTRUCT, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, - WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, + WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_XBUTTONDOWN, + WM_XBUTTONUP, }; if code < 0 { @@ -2838,7 +2839,12 @@ unsafe extern "system" fn windows_mouse_proc(code: i32, wparam: usize, lparam: i let handled = match message { WM_MOUSEMOVE => handle_windows_mouse_move(&context, event.pt.x as f64, event.pt.y as f64), WM_LBUTTONDOWN | WM_LBUTTONUP | WM_RBUTTONDOWN | WM_RBUTTONUP | WM_MBUTTONDOWN - | WM_MBUTTONUP => handle_windows_mouse_button(&context, message), + | WM_MBUTTONUP | WM_XBUTTONDOWN | WM_XBUTTONUP => { + // For the X (side) buttons the pressed button rides the high word of + // mouseData (XBUTTON1 = back, XBUTTON2 = forward); other buttons + // ignore it. + handle_windows_mouse_button(&context, message, event.mouseData) + } WM_MOUSEWHEEL | WM_MOUSEHWHEEL => handle_windows_scroll(&context, message, event.mouseData), _ => false, }; @@ -3209,9 +3215,10 @@ fn handle_windows_mouse_move(context: &WindowsCaptureContext, x: f64, y: f64) -> } #[cfg(target_os = "windows")] -fn handle_windows_mouse_button(context: &WindowsCaptureContext, message: u32) -> bool { +fn handle_windows_mouse_button(context: &WindowsCaptureContext, message: u32, mouse_data: u32) -> bool { use windows_sys::Win32::UI::WindowsAndMessaging::{ WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_RBUTTONDOWN, WM_RBUTTONUP, + WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1, }; let active = context @@ -3222,6 +3229,12 @@ fn handle_windows_mouse_button(context: &WindowsCaptureContext, message: u32) -> let Some(active_target) = active else { return false; }; + // WM_XBUTTON* packs which side button in the high word of mouseData. + let x_button = if (mouse_data >> 16) as u16 == XBUTTON1 as u16 { + MouseButton::Back + } else { + MouseButton::Forward + }; let (button, down) = match message { WM_LBUTTONDOWN => (MouseButton::Left, true), WM_LBUTTONUP => (MouseButton::Left, false), @@ -3229,6 +3242,8 @@ fn handle_windows_mouse_button(context: &WindowsCaptureContext, message: u32) -> WM_RBUTTONUP => (MouseButton::Right, false), WM_MBUTTONDOWN => (MouseButton::Middle, true), WM_MBUTTONUP => (MouseButton::Middle, false), + WM_XBUTTONDOWN => (x_button, true), + WM_XBUTTONUP => (x_button, false), _ => return false, }; @@ -5451,7 +5466,12 @@ fn inject_mouse_move(x: i32, y: i32, drag_button: Option) { let (event_type, mouse_button) = match drag_button { Some(MouseButton::Left) => (CGEventType::LeftMouseDragged, CGMouseButton::Left), Some(MouseButton::Right) => (CGEventType::RightMouseDragged, CGMouseButton::Right), - Some(MouseButton::Middle) => (CGEventType::OtherMouseDragged, CGMouseButton::Center), + // Middle and the side buttons are all "other" drags. button_from_mask + // never actually reports a side button as a drag, so this is only here + // for exhaustiveness. + Some(MouseButton::Middle | MouseButton::Back | MouseButton::Forward) => { + (CGEventType::OtherMouseDragged, CGMouseButton::Center) + } None => (CGEventType::MouseMoved, CGMouseButton::Left), }; @@ -5507,6 +5527,9 @@ impl MacClickTracker { MouseButton::Left => 0, MouseButton::Right => 1, MouseButton::Middle => 2, + // Side (back/forward) buttons are navigation, not click targets: + // no double-click chaining and no pressed-slot tracking. + MouseButton::Back | MouseButton::Forward => return i64::from(down), }; if down { @@ -5624,19 +5647,29 @@ fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) { let Ok(source) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { return; }; - let (event_type, mouse_button) = match (button, down) { - (MouseButton::Left, true) => (CGEventType::LeftMouseDown, CGMouseButton::Left), - (MouseButton::Left, false) => (CGEventType::LeftMouseUp, CGMouseButton::Left), - (MouseButton::Right, true) => (CGEventType::RightMouseDown, CGMouseButton::Right), - (MouseButton::Right, false) => (CGEventType::RightMouseUp, CGMouseButton::Right), - (MouseButton::Middle, true) => (CGEventType::OtherMouseDown, CGMouseButton::Center), - (MouseButton::Middle, false) => (CGEventType::OtherMouseUp, CGMouseButton::Center), + // Side buttons are "other" mouse events distinguished only by their button + // number (macOS: 2 = middle, 3 = back, 4 = forward); new_mouse_event has no + // CGMouseButton for 3/4, so create an Other event and stamp the number. + let (event_type, mouse_button, button_number) = match (button, down) { + (MouseButton::Left, true) => (CGEventType::LeftMouseDown, CGMouseButton::Left, None), + (MouseButton::Left, false) => (CGEventType::LeftMouseUp, CGMouseButton::Left, None), + (MouseButton::Right, true) => (CGEventType::RightMouseDown, CGMouseButton::Right, None), + (MouseButton::Right, false) => (CGEventType::RightMouseUp, CGMouseButton::Right, None), + (MouseButton::Middle, true) => (CGEventType::OtherMouseDown, CGMouseButton::Center, None), + (MouseButton::Middle, false) => (CGEventType::OtherMouseUp, CGMouseButton::Center, None), + (MouseButton::Back, true) => (CGEventType::OtherMouseDown, CGMouseButton::Center, Some(3)), + (MouseButton::Back, false) => (CGEventType::OtherMouseUp, CGMouseButton::Center, Some(3)), + (MouseButton::Forward, true) => (CGEventType::OtherMouseDown, CGMouseButton::Center, Some(4)), + (MouseButton::Forward, false) => (CGEventType::OtherMouseUp, CGMouseButton::Center, Some(4)), }; let point = CGPoint::new(x as f64, y as f64); let _ = CGDisplay::warp_mouse_cursor_position(point); if let Ok(event) = CGEvent::new_mouse_event(source, event_type, point, mouse_button) { + if let Some(number) = button_number { + event.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, number); + } event.set_integer_value_field( EventField::MOUSE_EVENT_CLICK_STATE, macos_click_state(button, down, x, y), @@ -6599,6 +6632,29 @@ mod tests { } } + #[test] + fn side_button_events_round_trip_on_the_wire() { + for button in [MouseButton::Back, MouseButton::Forward] { + let event = InputEvent::MouseButton { button, down: true }; + let encoded = rmp_serde::to_vec_named(&event).expect("encode side button"); + let decoded: InputEvent = rmp_serde::from_slice(&encoded).expect("decode side button"); + assert_eq!(decoded, InputEvent::MouseButton { button, down: true }); + } + // Distinct masks so a held side button never aliases another button. + let masks = [ + mouse_button_mask(MouseButton::Left), + mouse_button_mask(MouseButton::Right), + mouse_button_mask(MouseButton::Middle), + mouse_button_mask(MouseButton::Back), + mouse_button_mask(MouseButton::Forward), + ]; + for (i, a) in masks.iter().enumerate() { + for b in &masks[i + 1..] { + assert_ne!(a, b, "mouse button masks must be distinct"); + } + } + } + #[test] fn borrowed_packet_mirror_encodes_identically_to_input_packet() { let packet = InputPacket { diff --git a/src-tauri/src/shared_input.rs b/src-tauri/src/shared_input.rs index fa12139..4933933 100644 --- a/src-tauri/src/shared_input.rs +++ b/src-tauri/src/shared_input.rs @@ -4,6 +4,8 @@ use std::path::PathBuf; pub const LEFT_BUTTON_MASK: u64 = 1; pub const RIGHT_BUTTON_MASK: u64 = 1 << 1; pub const MIDDLE_BUTTON_MASK: u64 = 1 << 2; +pub const BACK_BUTTON_MASK: u64 = 1 << 3; +pub const FORWARD_BUTTON_MASK: u64 = 1 << 4; pub const INPUT_PIPE_PREFIX: &str = r"\\.\pipe\mykvm-input-s"; pub const INPUT_SERVICE_NAME: &str = "MyKVMInputService"; pub const INPUT_SERVICE_DISPLAY_NAME: &str = "MyKVM Lock Screen Input Service"; @@ -23,6 +25,9 @@ pub enum MouseButton { Left, Right, Middle, + // The side navigation buttons (Windows XBUTTON1/XBUTTON2, "back"/"forward"). + Back, + Forward, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -68,6 +73,8 @@ pub fn mouse_button_mask(button: MouseButton) -> u64 { MouseButton::Left => LEFT_BUTTON_MASK, MouseButton::Right => RIGHT_BUTTON_MASK, MouseButton::Middle => MIDDLE_BUTTON_MASK, + MouseButton::Back => BACK_BUTTON_MASK, + MouseButton::Forward => FORWARD_BUTTON_MASK, } } diff --git a/src-tauri/src/windows_input.rs b/src-tauri/src/windows_input.rs index 7349ee5..6cdbaa8 100644 --- a/src-tauri/src/windows_input.rs +++ b/src-tauri/src/windows_input.rs @@ -257,10 +257,13 @@ pub fn inject_mouse_move(x: i32, y: i32, _drag_button: Option) { } pub fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, - MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, - MOUSEINPUT, + use windows_sys::Win32::UI::{ + Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, + MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, + MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, + }, + WindowsAndMessaging::{XBUTTON1, XBUTTON2}, }; if x != 0 || y != 0 { @@ -272,13 +275,20 @@ pub fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) { // spawned injection thread on some desktops, which produced the "cursor // moves but cannot click" symptom. SendInput reports failures via its // return value and is the recommended injection API. - let flag = match (button, down) { - (MouseButton::Left, true) => MOUSEEVENTF_LEFTDOWN, - (MouseButton::Left, false) => MOUSEEVENTF_LEFTUP, - (MouseButton::Right, true) => MOUSEEVENTF_RIGHTDOWN, - (MouseButton::Right, false) => MOUSEEVENTF_RIGHTUP, - (MouseButton::Middle, true) => MOUSEEVENTF_MIDDLEDOWN, - (MouseButton::Middle, false) => MOUSEEVENTF_MIDDLEUP, + // + // The side buttons ride MOUSEEVENTF_X* with the button in mouseData + // (XBUTTON1 = back, XBUTTON2 = forward). + let (flag, mouse_data) = match (button, down) { + (MouseButton::Left, true) => (MOUSEEVENTF_LEFTDOWN, 0), + (MouseButton::Left, false) => (MOUSEEVENTF_LEFTUP, 0), + (MouseButton::Right, true) => (MOUSEEVENTF_RIGHTDOWN, 0), + (MouseButton::Right, false) => (MOUSEEVENTF_RIGHTUP, 0), + (MouseButton::Middle, true) => (MOUSEEVENTF_MIDDLEDOWN, 0), + (MouseButton::Middle, false) => (MOUSEEVENTF_MIDDLEUP, 0), + (MouseButton::Back, true) => (MOUSEEVENTF_XDOWN, XBUTTON1 as i32), + (MouseButton::Back, false) => (MOUSEEVENTF_XUP, XBUTTON1 as i32), + (MouseButton::Forward, true) => (MOUSEEVENTF_XDOWN, XBUTTON2 as i32), + (MouseButton::Forward, false) => (MOUSEEVENTF_XUP, XBUTTON2 as i32), }; let input = INPUT { @@ -287,7 +297,7 @@ pub fn inject_mouse_button(button: MouseButton, down: bool, x: i32, y: i32) { mi: MOUSEINPUT { dx: 0, dy: 0, - mouseData: 0, + mouseData: mouse_data as u32, dwFlags: flag, time: 0, dwExtraInfo: 0, From dfa8770e76fe813aebc6b53f79ac42453e4bc731 Mon Sep 17 00:00:00 2001 From: fc221 Date: Thu, 16 Jul 2026 16:35:45 +0800 Subject: [PATCH 12/12] fix(ci): precreate draft before parallel release builds --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 939f57c..decb7b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,6 +201,22 @@ jobs: git tag -a "${{ steps.version.outputs.tag }}" -m "MyKVM ${{ steps.version.outputs.tag }}" git push origin "${{ steps.version.outputs.tag }}" + - name: Create draft release + if: steps.version.outputs.should_release == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + CHANNEL: ${{ steps.version.outputs.channel }} + RELEASE_NOTES: ${{ steps.version.outputs.release_notes }} + run: | + set -euo pipefail + args=("$TAG" --draft --title "MyKVM $TAG" --notes "$RELEASE_NOTES" --target "$GITHUB_SHA") + if [[ "$CHANNEL" == "beta" ]]; then + args+=(--prerelease) + fi + gh release create "${args[@]}" --repo "$GITHUB_REPOSITORY" + build: name: Build ${{ matrix.name }} needs: prepare