From c69b2a60e6aa41d96f3517f74cc3428be9486371 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 23 Sep 2026 14:44:03 +0800 Subject: [PATCH] fix(keyboard): shortcuts, keep fired keys per session FIRED_KEYS was process-global while dispatch is per session. Closing or entering any session cleared the fired keys of all of them, so a key still held in another session leaked its release to that remote, and a key fired in one session made the same key in another session look like a repeat. Key the fired set by session: try_dispatch resolves the session first and only looks at that session's keys, and session_close clears only the closing session. Entering a session view no longer clears anything: it is a pointer enter, and a fired key may still be physically held across it. A key whose release is missed otherwise heals itself: its next press is consumed as a repeat and that release removes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D --- src/flutter_ffi.rs | 3 +- src/keyboard/shortcuts.rs | 108 ++++++++++++++++++++++++++++---------- 2 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index aa7234451..fdf840d85 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -263,7 +263,7 @@ pub fn will_session_close_close_session(session_id: SessionID) -> SyncReturn SyncRetur // so that two windows viewing the same peer get distinct grab owners. let window_id = _session_id.as_u128(); if _enter { - crate::keyboard::shortcuts::clear_fired_keys(); set_cur_session_id_(_session_id, &keyboard_mode); crate::keyboard::client::change_grab_status( crate::common::GrabState::Run, diff --git a/src/keyboard/shortcuts.rs b/src/keyboard/shortcuts.rs index 9c3a42704..60652f887 100644 --- a/src/keyboard/shortcuts.rs +++ b/src/keyboard/shortcuts.rs @@ -312,19 +312,22 @@ pub fn match_event(event: &rdev::Event) -> Option { #[cfg(feature = "flutter")] lazy_static::lazy_static! { - /// Physical keys whose press fired a shortcut and whose release has not - /// arrived yet. Every event of such a key belongs to RustDesk until its - /// release, whatever the modifiers do meanwhile: auto repeats and the - /// release are consumed and never reach the remote. - static ref FIRED_KEYS: std::sync::Mutex> = - Default::default(); + /// Per session, the physical keys whose press fired a shortcut and whose + /// release has not arrived yet. Every event of such a key belongs to + /// RustDesk until its release, whatever the modifiers do meanwhile: auto + /// repeats and the release are consumed and never reach the remote. + static ref FIRED_KEYS: std::sync::Mutex< + std::collections::HashMap>, + > = Default::default(); } -/// Forget the fired keys whose release will never arrive, e.g. because focus -/// left the session while they were held. +/// Forget the fired keys of a session that is going away. +/// +/// A key whose release is missed in other ways stays recorded: its next press +/// is consumed as a repeat and its release then removes it. #[cfg(feature = "flutter")] -pub fn clear_fired_keys() { - FIRED_KEYS.lock().unwrap().clear(); +pub fn clear_fired_keys(session_id: &hbb_common::SessionID) { + FIRED_KEYS.lock().unwrap().remove(session_id); } /// Match `event` against the cached bindings; if it matched, push a @@ -350,17 +353,6 @@ pub fn try_dispatch( send: impl Fn(&base::message_proto::KeyEvent), ) -> bool { use rdev::EventType; - { - let mut fired = FIRED_KEYS.lock().unwrap(); - match event.event_type { - EventType::KeyPress(k) if fired.contains(&k) => return true, - EventType::KeyRelease(k) if fired.remove(&k) => return true, - _ => {} - } - } - let Some(action_id) = match_event(event) else { - return false; - }; let resolved; let sid = match session_id { Some(id) => id, @@ -369,9 +361,27 @@ pub fn try_dispatch( &resolved } }; + { + let mut all = FIRED_KEYS.lock().unwrap(); + if let Some(fired) = all.get_mut(sid) { + match event.event_type { + EventType::KeyPress(k) if fired.contains(&k) => return true, + EventType::KeyRelease(k) if fired.remove(&k) => { + if fired.is_empty() { + all.remove(sid); + } + return true; + } + _ => {} + } + } + } + let Some(action_id) = match_event(event) else { + return false; + }; release_remote_keys(keyboard_mode, &peer(), &send); if let EventType::KeyPress(k) = event.event_type { - FIRED_KEYS.lock().unwrap().insert(k); + FIRED_KEYS.lock().unwrap().entry(*sid).or_default().insert(k); } crate::flutter::push_session_event(sid, "shortcut_triggered", vec![("action", &action_id)]); true @@ -816,9 +826,9 @@ mod tests { // was not already fired before the call. let fired = std::cell::RefCell::new(Vec::new()); let dispatch = |e: &rdev::Event| { - let before = FIRED_KEYS.lock().unwrap().clone(); + let before = fired_keys_of(&SID_A); let action = match_event(e); - let hit = try_dispatch(None, e, "map", || "windows".into(), |_| {}); + let hit = try_dispatch(Some(&SID_A), e, "map", || "windows".into(), |_| {}); if let rdev::EventType::KeyPress(k) = e.event_type { if hit && !before.contains(&k) { fired.borrow_mut().push(action.unwrap_or_default()); @@ -839,7 +849,7 @@ mod tests { assert!(dispatch(&make_press(Key::KeyP)), "P repeat is consumed"); assert!(dispatch(&make_release(Key::KeyP)), "P release is consumed"); assert!(dispatch(&make_release(Key::KeyC)), "C release is consumed"); - assert!(FIRED_KEYS.lock().unwrap().is_empty()); + assert!(fired_keys_of(&SID_A).is_empty()); assert_eq!( *fired.borrow(), vec![action_id::SCREENSHOT.to_owned(), action_id::TOGGLE_CHAT.to_owned()] @@ -856,7 +866,8 @@ mod tests { let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let chord = enable_defaults_and_hold_chord(); - let dispatch = |e: &rdev::Event| try_dispatch(None, e, "map", || "windows".into(), |_| {}); + let dispatch = + |e: &rdev::Event| try_dispatch(Some(&SID_A), e, "map", || "windows".into(), |_| {}); assert!(dispatch(&make_press(Key::KeyP))); release_chord(chord); @@ -866,6 +877,48 @@ mod tests { assert!(!dispatch(&make_release(Key::KeyP))); } + /// Fired keys are owned per session: closing another session keeps them, + /// and the same physical key in another session is not taken as a repeat. + #[cfg(feature = "flutter")] + #[test] + fn dispatch_keeps_fired_keys_per_session() { + use rdev::Key; + + let _guard = CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let chord = enable_defaults_and_hold_chord(); + let dispatch = |sid: &hbb_common::SessionID, e: &rdev::Event| { + try_dispatch(Some(sid), e, "map", || "windows".into(), |_| {}) + }; + + assert!(dispatch(&SID_A, &make_press(Key::KeyP))); + release_chord(chord); + assert!( + !dispatch(&SID_B, &make_press(Key::KeyP)), + "P in session B is not a repeat of session A's P" + ); + assert!(!dispatch(&SID_B, &make_release(Key::KeyP))); + clear_fired_keys(&SID_B); + assert!(dispatch(&SID_A, &make_press(Key::KeyP)), "A's repeat survives B's close"); + assert!(dispatch(&SID_A, &make_release(Key::KeyP)), "A's release survives B's close"); + assert!(fired_keys_of(&SID_A).is_empty()); + + let chord = enable_defaults_and_hold_chord(); + assert!(dispatch(&SID_A, &make_press(Key::KeyP))); + release_chord(chord); + clear_fired_keys(&SID_A); + assert!(!dispatch(&SID_A, &make_release(Key::KeyP)), "closing A drops its keys"); + } + + #[cfg(feature = "flutter")] + const SID_A: hbb_common::SessionID = hbb_common::SessionID::from_u128(0xA); + #[cfg(feature = "flutter")] + const SID_B: hbb_common::SessionID = hbb_common::SessionID::from_u128(0xB); + + #[cfg(feature = "flutter")] + fn fired_keys_of(sid: &hbb_common::SessionID) -> std::collections::HashSet { + FIRED_KEYS.lock().unwrap().get(sid).cloned().unwrap_or_default() + } + #[cfg(feature = "flutter")] fn enable_defaults_and_hold_chord() -> [rdev::Key; 3] { use base::message_proto::KeyboardMode; @@ -876,7 +929,8 @@ mod tests { pass_through: false, bindings: default_bindings(), }); - clear_fired_keys(); + clear_fired_keys(&SID_A); + clear_fired_keys(&SID_B); let primary = if cfg!(any(target_os = "macos", target_os = "ios")) { Key::MetaLeft } else {