mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-08 07:08:09 +03:00
feat(shortcuts): user-configurable keyboard shortcuts for session actions
Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
session actions. Bindings are stored in LocalConfig under
`keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
`pass_through` flags so flipping the master switch off is a hard stop.
Wire-up summary:
- src/keyboard/shortcuts.rs: matcher, default bindings, parity test against
flutter/test/fixtures/default_keyboard_shortcuts.json
- src/keyboard.rs: shortcut intercept in process_event{,_with_session},
feature-gated to `flutter`; runs before key swapping so users bind to
physical keys
- src/flutter_ffi.rs: main_reload_keyboard_shortcuts +
main_get_default_keyboard_shortcuts; reload_from_config seeded in main_init
- flutter/lib/common/widgets/keyboard_shortcuts/: shared config page body,
recording dialog, shortcut display formatter, action group registry
- flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart and
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart: platform
shells around the shared body
- flutter/lib/models/shortcut_model.dart: per-session ShortcutModel +
registerSessionShortcutActions for actions with no toolbar TToggleMenu /
TRadioMenu (fullscreen, switch display/tab, close tab, voice call, etc.)
- flutter/lib/common/widgets/toolbar.dart: optional `actionId` field on
TToggleMenu / TRadioMenu, plus per-helper auto-register pass that wires
tagged entries' existing onChanged into the ShortcutModel
- flutter/test/keyboard_shortcuts_test.dart + fixtures: cross-language
parity (default bindings, supported key vocabulary)
Design principles applied during review:
1. Additions are fine; modifications to original logic must be deliberate.
Tagging an existing TToggleMenu entry with `actionId:` is an addition.
Rewriting its onChanged to satisfy a new contract is a modification —
and was reverted for every case where the original click behavior was
working. Four closures were touched and then reverted (mobile View
Mode, Privacy mode multi-impl, Relative mouse mode, Reverse mouse
wheel); their shortcuts are wired via standalone closures in
shortcut_model.dart instead.
2. Toolbar auto-register is reserved for entries whose onChanged is
inherently self-flipping — typically `sessionToggleOption(name)` where
the named option is flipped in place and the input bool is unused. The
register pass passes `!menu.value` from registration time, which is
harmless under self-flipping but wrong for closures that consume the
input bool directly. Tagging a non-self-flipping entry forces a closure
rewrite; choose non-toolbar registration in that case.
3. When shortcuts are disabled, toolbar behavior must be bit-for-bit
unchanged. The matcher's `enabled`-gate already guarantees no
dispatch; the auto-register pass is left unconditional (its only effect
is HashMap operations on a separate ShortcutModel) so mid-session
enable works without a reconnect. The trade-off is intentional and
documented at the top of toolbarControls.
4. Comments stay terse. Rationale lives in one place — the doc comment of
the helper or registration site, not duplicated at every call site.
5. Where an existing helper needs a new optional behavior (e.g.
`_OptionCheckBox` gaining a tooltip slot), the new branch must reduce
to byte-identical output for existing callers (`trailing == null`
case → original `Expanded(Text)` layout). Verified.
6. Action IDs and labels stay consistent. Renamed `reset_cursor` →
`reset_canvas` so the action ID matches its user-facing label
("Reset canvas") and capability flag.
Out-of-scope but included:
- AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
Web target's hand-written TS client, since both are load-bearing for
any new FFI work.
- remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
monitors" / "Monitor #N"), unrelated to shortcuts but kept here.
This commit is contained in:
@@ -575,6 +575,7 @@ pub fn session_handle_flutter_key_event(
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let keyboard_mode = session.get_keyboard_mode();
|
||||
session.handle_flutter_key_event(
|
||||
session_id,
|
||||
&keyboard_mode,
|
||||
&character,
|
||||
usb_hid,
|
||||
@@ -595,6 +596,7 @@ pub fn session_handle_flutter_raw_key_event(
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let keyboard_mode = session.get_keyboard_mode();
|
||||
session.handle_flutter_raw_key_event(
|
||||
session_id,
|
||||
&keyboard_mode,
|
||||
&name,
|
||||
platform_code,
|
||||
@@ -1728,6 +1730,7 @@ pub fn cm_get_clients_length() -> usize {
|
||||
|
||||
pub fn main_init(app_dir: String, custom_client_config: String) {
|
||||
initialize(&app_dir, &custom_client_config);
|
||||
crate::keyboard::shortcuts::reload_from_config();
|
||||
}
|
||||
|
||||
pub fn main_device_id(id: String) {
|
||||
@@ -2247,6 +2250,17 @@ pub fn main_init_input_source() -> SyncReturn<()> {
|
||||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_reload_keyboard_shortcuts() -> SyncReturn<()> {
|
||||
crate::keyboard::shortcuts::reload_from_config();
|
||||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_get_default_keyboard_shortcuts() -> SyncReturn<String> {
|
||||
let bindings = crate::keyboard::shortcuts::default_bindings();
|
||||
let json = serde_json::to_string(&bindings).unwrap_or_default();
|
||||
SyncReturn(json)
|
||||
}
|
||||
|
||||
pub fn main_is_installed_lower_version() -> SyncReturn<bool> {
|
||||
SyncReturn(is_installed_lower_version())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{client::get_key_state, common::GrabState};
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use hbb_common::log;
|
||||
use hbb_common::message_proto::*;
|
||||
use hbb_common::SessionID;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use rdev::KeyCode;
|
||||
use rdev::{Event, EventType, Key};
|
||||
@@ -79,6 +80,8 @@ lazy_static::lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
pub mod shortcuts;
|
||||
|
||||
pub mod client {
|
||||
use super::*;
|
||||
|
||||
@@ -319,6 +322,32 @@ pub mod client {
|
||||
}
|
||||
|
||||
pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option<i32>) {
|
||||
// Shortcut intercept — must come before any wire encoding.
|
||||
// Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None
|
||||
// for KeyRelease and other non-press events), so flushed releases from
|
||||
// release_remote_keys pass straight through to the encode/forward path.
|
||||
//
|
||||
// NOTE: Shortcut matching intentionally happens BEFORE any key swapping
|
||||
// (swap_modifier_key) so that shortcuts bind to the physical keys pressed,
|
||||
// not the swapped keys. This makes shortcut setup intuitive: users bind
|
||||
// shortcuts to the actual keys they press, regardless of swap settings.
|
||||
// Key swapping only affects what gets sent to the remote.
|
||||
//
|
||||
// Gated on `feature = "flutter"` because the dispatch target
|
||||
// (`flutter::push_session_event`) is Flutter-only. Sciter builds never
|
||||
// call `reload_from_config`, so the cache stays disabled and the
|
||||
// matcher would no-op anyway — but we still skip the call entirely so
|
||||
// a hand-edited config can't silently swallow keys on a UI that has
|
||||
// no way to surface the action.
|
||||
//
|
||||
// `None` for session_id makes the helper resolve through
|
||||
// `flutter::get_cur_session_id()` — the rdev grab loop is process-wide
|
||||
// and has no per-event session context to thread.
|
||||
#[cfg(feature = "flutter")]
|
||||
if crate::keyboard::shortcuts::try_dispatch(None, event) {
|
||||
return;
|
||||
}
|
||||
|
||||
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
|
||||
if is_long_press(&event) {
|
||||
return;
|
||||
@@ -334,7 +363,20 @@ pub mod client {
|
||||
event: &Event,
|
||||
lock_modes: Option<i32>,
|
||||
session: &Session<T>,
|
||||
session_id: SessionID,
|
||||
) {
|
||||
// Shortcut intercept — see the long comment in `process_event` above
|
||||
// for the KeyPress-only / feature-gate rationale. The only difference
|
||||
// here is that the Flutter FFI path threads an explicit SessionID
|
||||
// through, so dispatch targets the exact tab the keystroke originated
|
||||
// from — no dependency on the global focus tracker.
|
||||
#[cfg(feature = "flutter")]
|
||||
if crate::keyboard::shortcuts::try_dispatch(Some(&session_id), event) {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
let _ = session_id;
|
||||
|
||||
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
|
||||
if is_long_press(&event) {
|
||||
return;
|
||||
|
||||
707
src/keyboard/shortcuts.rs
Normal file
707
src/keyboard/shortcuts.rs
Normal file
@@ -0,0 +1,707 @@
|
||||
//! Keyboard shortcuts for triggering session actions locally.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CACHE: RwLock<Arc<Bindings>> = RwLock::new(Arc::new(Bindings::default()));
|
||||
}
|
||||
|
||||
/// Registry of all valid action ids that may appear in `Binding.action`.
|
||||
/// Source-of-truth lives on the Flutter side (`flutter/lib/consts.dart`,
|
||||
/// `kShortcutAction*`); these mirror that vocabulary so Rust code can reach
|
||||
/// for them without re-stringifying.
|
||||
#[allow(dead_code)]
|
||||
pub mod action_id {
|
||||
pub const SEND_CTRL_ALT_DEL: &str = "send_ctrl_alt_del";
|
||||
pub const TOGGLE_FULLSCREEN: &str = "toggle_fullscreen";
|
||||
pub const SWITCH_DISPLAY_NEXT: &str = "switch_display_next";
|
||||
pub const SWITCH_DISPLAY_PREV: &str = "switch_display_prev";
|
||||
pub const SWITCH_DISPLAY_ALL: &str = "switch_display_all";
|
||||
pub const SCREENSHOT: &str = "screenshot";
|
||||
pub const INSERT_LOCK: &str = "insert_lock";
|
||||
pub const REFRESH: &str = "refresh";
|
||||
pub const TOGGLE_BLOCK_INPUT: &str = "toggle_block_input";
|
||||
pub const TOGGLE_RECORDING: &str = "toggle_recording";
|
||||
pub const SWITCH_SIDES: &str = "switch_sides";
|
||||
pub const CLOSE_TAB: &str = "close_tab";
|
||||
pub const TOGGLE_TOOLBAR: &str = "toggle_toolbar";
|
||||
pub const RESTART_REMOTE: &str = "restart_remote";
|
||||
pub const RESET_CANVAS: &str = "reset_canvas";
|
||||
pub const TOGGLE_MUTE: &str = "toggle_mute";
|
||||
pub const PIN_TOOLBAR: &str = "pin_toolbar";
|
||||
pub const VIEW_MODE_ORIGINAL: &str = "view_mode_original";
|
||||
pub const VIEW_MODE_ADAPTIVE: &str = "view_mode_adaptive";
|
||||
pub const TOGGLE_CHAT: &str = "toggle_chat";
|
||||
pub const TOGGLE_QUALITY_MONITOR: &str = "toggle_quality_monitor";
|
||||
pub const TOGGLE_SHOW_REMOTE_CURSOR: &str = "toggle_show_remote_cursor";
|
||||
pub const TOGGLE_SHOW_MY_CURSOR: &str = "toggle_show_my_cursor";
|
||||
pub const TOGGLE_DISABLE_CLIPBOARD: &str = "toggle_disable_clipboard";
|
||||
pub const PRIVACY_MODE_1: &str = "privacy_mode_1";
|
||||
pub const PRIVACY_MODE_2: &str = "privacy_mode_2";
|
||||
pub const KEYBOARD_MODE_MAP: &str = "keyboard_mode_map";
|
||||
pub const KEYBOARD_MODE_TRANSLATE: &str = "keyboard_mode_translate";
|
||||
pub const KEYBOARD_MODE_LEGACY: &str = "keyboard_mode_legacy";
|
||||
pub const CODEC_AUTO: &str = "codec_auto";
|
||||
pub const CODEC_VP8: &str = "codec_vp8";
|
||||
pub const CODEC_VP9: &str = "codec_vp9";
|
||||
pub const CODEC_AV1: &str = "codec_av1";
|
||||
pub const CODEC_H264: &str = "codec_h264";
|
||||
pub const CODEC_H265: &str = "codec_h265";
|
||||
pub const PLUG_OUT_ALL_VIRTUAL_DISPLAYS: &str = "plug_out_all_virtual_displays";
|
||||
pub const TOGGLE_RELATIVE_MOUSE_MODE: &str = "toggle_relative_mouse_mode";
|
||||
pub const TOGGLE_FOLLOW_REMOTE_CURSOR: &str = "toggle_follow_remote_cursor";
|
||||
pub const TOGGLE_FOLLOW_REMOTE_WINDOW: &str = "toggle_follow_remote_window";
|
||||
pub const TOGGLE_ZOOM_CURSOR: &str = "toggle_zoom_cursor";
|
||||
pub const TOGGLE_REVERSE_MOUSE_WHEEL: &str = "toggle_reverse_mouse_wheel";
|
||||
pub const TOGGLE_SWAP_LEFT_RIGHT_MOUSE: &str = "toggle_swap_left_right_mouse";
|
||||
pub const TOGGLE_LOCK_AFTER_SESSION_END: &str = "toggle_lock_after_session_end";
|
||||
pub const TOGGLE_TRUE_COLOR: &str = "toggle_true_color";
|
||||
pub const TOGGLE_SWAP_CTRL_CMD: &str = "toggle_swap_ctrl_cmd";
|
||||
pub const TOGGLE_ENABLE_FILE_COPY_PASTE: &str = "toggle_enable_file_copy_paste";
|
||||
pub const VIEW_MODE_CUSTOM: &str = "view_mode_custom";
|
||||
pub const IMAGE_QUALITY_BEST: &str = "image_quality_best";
|
||||
pub const IMAGE_QUALITY_BALANCED: &str = "image_quality_balanced";
|
||||
pub const IMAGE_QUALITY_LOW: &str = "image_quality_low";
|
||||
pub const SEND_CLIPBOARD_KEYSTROKES: &str = "send_clipboard_keystrokes";
|
||||
pub const TOGGLE_INPUT_SOURCE: &str = "toggle_input_source";
|
||||
pub const SWITCH_TAB_NEXT: &str = "switch_tab_next";
|
||||
pub const SWITCH_TAB_PREV: &str = "switch_tab_prev";
|
||||
pub const TOGGLE_VOICE_CALL: &str = "toggle_voice_call";
|
||||
pub const TOGGLE_VIEW_ONLY: &str = "toggle_view_only";
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Modifier {
|
||||
Primary,
|
||||
Ctrl,
|
||||
Alt,
|
||||
Shift,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Binding {
|
||||
pub action: String,
|
||||
pub mods: Vec<Modifier>,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct Bindings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Persistent companion to `enabled`: when true, the matcher returns early
|
||||
/// and every keystroke flows through to the remote (i.e. all bindings are
|
||||
/// suspended). Stored alongside `enabled` and `bindings` so a single
|
||||
/// reload refreshes both flags.
|
||||
#[serde(default)]
|
||||
pub pass_through: bool,
|
||||
#[serde(default)]
|
||||
pub bindings: Vec<Binding>,
|
||||
}
|
||||
|
||||
pub fn default_bindings() -> Vec<Binding> {
|
||||
let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift];
|
||||
// Defaults align with AnyDesk's M/S/I/C/Delete/Arrow/Digit conventions
|
||||
// where applicable; "P" for screenshot also matches AnyDesk.
|
||||
vec![
|
||||
Binding { action: action_id::SEND_CTRL_ALT_DEL.into(), mods: prefix(), key: "delete".into() },
|
||||
Binding { action: action_id::TOGGLE_FULLSCREEN.into(), mods: prefix(), key: "enter".into() },
|
||||
Binding { action: action_id::SWITCH_DISPLAY_NEXT.into(), mods: prefix(), key: "arrow_right".into() },
|
||||
Binding { action: action_id::SWITCH_DISPLAY_PREV.into(), mods: prefix(), key: "arrow_left".into() },
|
||||
Binding { action: action_id::SCREENSHOT.into(), mods: prefix(), key: "p".into() },
|
||||
Binding { action: action_id::TOGGLE_SHOW_REMOTE_CURSOR.into(), mods: prefix(), key: "m".into() },
|
||||
Binding { action: action_id::TOGGLE_MUTE.into(), mods: prefix(), key: "s".into() },
|
||||
Binding { action: action_id::TOGGLE_BLOCK_INPUT.into(), mods: prefix(), key: "i".into() },
|
||||
Binding { action: action_id::TOGGLE_CHAT.into(), mods: prefix(), key: "c".into() },
|
||||
]
|
||||
}
|
||||
|
||||
/// Match a normalized (key, modifiers) pair against the given bindings.
|
||||
/// Returns the matched action ID, or None when the matcher is off
|
||||
/// (`enabled == false`), suspended (`pass_through == true`), or no binding
|
||||
/// fires for this combo.
|
||||
///
|
||||
/// Defense-in-depth: bindings with an empty modifier list are skipped here
|
||||
/// even though the recording dialog refuses to save them. A hand-edited
|
||||
/// config (or a future writer-side bug) that lets an empty-mods binding
|
||||
/// through would otherwise turn that key's every press into a swallowed
|
||||
/// shortcut, breaking normal typing in the remote session — a much worse
|
||||
/// failure than the binding simply not firing.
|
||||
pub fn match_normalized<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
|
||||
if !b.enabled || b.pass_through {
|
||||
return None;
|
||||
}
|
||||
for binding in &b.bindings {
|
||||
if binding.mods.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if binding.key == key && mods_equal(&binding.mods, mods) {
|
||||
return Some(binding.action.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn normalize_modifiers(alt: bool, ctrl: bool, shift: bool, command: bool) -> Vec<Modifier> {
|
||||
// iOS shares Apple's keyboard semantics with macOS — recording dialog
|
||||
// already treats iOS as `_isMac`, so the matcher must too.
|
||||
//
|
||||
// AltGr conflation: `get_modifiers_state` ORs Alt and AltGr, so an
|
||||
// AltGr+key press satisfies `Modifier::Alt`. Theoretical collision only;
|
||||
// fix at `get_modifiers_state` if a real bug surfaces.
|
||||
let mut v = Vec::new();
|
||||
if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
if command { v.push(Modifier::Primary); }
|
||||
if ctrl { v.push(Modifier::Ctrl); }
|
||||
} else {
|
||||
if ctrl { v.push(Modifier::Primary); }
|
||||
}
|
||||
if alt { v.push(Modifier::Alt); }
|
||||
if shift { v.push(Modifier::Shift); }
|
||||
v
|
||||
}
|
||||
|
||||
/// Map an rdev::Event to a string key name, matching the storage schema.
|
||||
/// Returns None for events we don't intercept (modifier-only presses, releases, etc.).
|
||||
pub fn event_to_key_name(event: &rdev::Event) -> Option<String> {
|
||||
use rdev::{EventType, Key};
|
||||
let key = match event.event_type {
|
||||
EventType::KeyPress(k) => k,
|
||||
_ => return None,
|
||||
};
|
||||
Some(match key {
|
||||
Key::Delete => "delete".into(),
|
||||
Key::Backspace => "backspace".into(),
|
||||
Key::Tab => "tab".into(),
|
||||
Key::Space => "space".into(),
|
||||
Key::Home => "home".into(),
|
||||
Key::End => "end".into(),
|
||||
Key::PageUp => "page_up".into(),
|
||||
Key::PageDown => "page_down".into(),
|
||||
Key::Insert => "insert".into(),
|
||||
// Numpad Enter (`KpReturn`) shares the "enter" name with the main
|
||||
// Return key — matches the Web matcher (`NumpadEnter` -> "enter") and
|
||||
// matches user expectation that the two physical Enters are
|
||||
// interchangeable for shortcuts.
|
||||
Key::Return | Key::KpReturn => "enter".into(),
|
||||
Key::LeftArrow => "arrow_left".into(),
|
||||
Key::RightArrow => "arrow_right".into(),
|
||||
Key::UpArrow => "arrow_up".into(),
|
||||
Key::DownArrow => "arrow_down".into(),
|
||||
Key::KeyA => "a".into(),
|
||||
Key::KeyB => "b".into(),
|
||||
Key::KeyC => "c".into(),
|
||||
Key::KeyD => "d".into(),
|
||||
Key::KeyE => "e".into(),
|
||||
Key::KeyF => "f".into(),
|
||||
Key::KeyG => "g".into(),
|
||||
Key::KeyH => "h".into(),
|
||||
Key::KeyI => "i".into(),
|
||||
Key::KeyJ => "j".into(),
|
||||
Key::KeyK => "k".into(),
|
||||
Key::KeyL => "l".into(),
|
||||
Key::KeyM => "m".into(),
|
||||
Key::KeyN => "n".into(),
|
||||
Key::KeyO => "o".into(),
|
||||
Key::KeyP => "p".into(),
|
||||
Key::KeyQ => "q".into(),
|
||||
Key::KeyR => "r".into(),
|
||||
Key::KeyS => "s".into(),
|
||||
Key::KeyT => "t".into(),
|
||||
Key::KeyU => "u".into(),
|
||||
Key::KeyV => "v".into(),
|
||||
Key::KeyW => "w".into(),
|
||||
Key::KeyX => "x".into(),
|
||||
Key::KeyY => "y".into(),
|
||||
Key::KeyZ => "z".into(),
|
||||
Key::Num0 => "digit0".into(),
|
||||
Key::Num1 => "digit1".into(),
|
||||
Key::Num2 => "digit2".into(),
|
||||
Key::Num3 => "digit3".into(),
|
||||
Key::Num4 => "digit4".into(),
|
||||
Key::Num5 => "digit5".into(),
|
||||
Key::Num6 => "digit6".into(),
|
||||
Key::Num7 => "digit7".into(),
|
||||
Key::Num8 => "digit8".into(),
|
||||
Key::Num9 => "digit9".into(),
|
||||
Key::F1 => "f1".into(),
|
||||
Key::F2 => "f2".into(),
|
||||
Key::F3 => "f3".into(),
|
||||
Key::F4 => "f4".into(),
|
||||
Key::F5 => "f5".into(),
|
||||
Key::F6 => "f6".into(),
|
||||
Key::F7 => "f7".into(),
|
||||
Key::F8 => "f8".into(),
|
||||
Key::F9 => "f9".into(),
|
||||
Key::F10 => "f10".into(),
|
||||
Key::F11 => "f11".into(),
|
||||
Key::F12 => "f12".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read keyboard-shortcut bindings from `LocalConfig` and refresh the cache.
|
||||
///
|
||||
/// Empty or invalid JSON falls back to `Bindings::default()` (disabled, no
|
||||
/// bindings). Call this once at startup and again whenever the config is
|
||||
/// written.
|
||||
pub fn reload_from_config() {
|
||||
let raw = hbb_common::config::LocalConfig::get_option(LOCAL_CONFIG_KEY);
|
||||
let parsed = if raw.is_empty() {
|
||||
Bindings::default()
|
||||
} else {
|
||||
serde_json::from_str(&raw).unwrap_or_default()
|
||||
};
|
||||
if let Ok(mut w) = CACHE.write() {
|
||||
*w = Arc::new(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the currently cached bindings. Cheap (one atomic increment) —
|
||||
/// safe to call on every keystroke.
|
||||
pub fn current() -> Arc<Bindings> {
|
||||
CACHE
|
||||
.read()
|
||||
.map(|b| Arc::clone(&b))
|
||||
.unwrap_or_else(|_| Arc::new(Bindings::default()))
|
||||
}
|
||||
|
||||
/// Match an `rdev::Event` against the cached bindings. Returns the matched
|
||||
/// action id, or `None` if no binding fires. The Flutter side ignores unknown
|
||||
/// action ids (logged as "no handler"), so no whitelist check is needed here.
|
||||
///
|
||||
/// ── Two known minor warts. DO NOT add global state to "fix" either: ──
|
||||
///
|
||||
/// 1. Orphan KeyRelease forwarded to peer.
|
||||
/// When a shortcut matches we eat the KeyPress, but the matching
|
||||
/// KeyRelease (whose `event_type` returns None from `event_to_key_name`)
|
||||
/// still flows through to the peer. The remote sees a release for a
|
||||
/// press it never received. Every input server we forward to ignores
|
||||
/// releases for unpressed keys, so user-visible impact is nil — the
|
||||
/// pre-existing hard-coded screenshot-shortcut path had the same shape
|
||||
/// for years without a single bug report.
|
||||
///
|
||||
/// 2. OS auto-repeat re-dispatches a held shortcut.
|
||||
/// rdev does not expose an `is_repeat` flag, so a held combo
|
||||
/// (Cmd+Alt+Shift+P) would dispatch every ~30-50ms while the keys are
|
||||
/// down — toggle actions oscillate, screenshot fires many times. In
|
||||
/// practice the OS initial auto-repeat delay is ~250ms and a normal
|
||||
/// shortcut press is 50-100ms, so the user has to *deliberately* hold
|
||||
/// the combo to hit this. The Web side gets a free fix via the
|
||||
/// browser's `KeyboardEvent.repeat`; on native we accept the wart.
|
||||
///
|
||||
/// The "fix" for either would be a process-global `HashSet<rdev::Key>` (or
|
||||
/// equivalent) with paired insert-on-press / remove-on-release logic in
|
||||
/// both `process_event*` paths plus a clear-on-leave hook. The cost:
|
||||
///
|
||||
/// * Lock contention on the hot keystroke path.
|
||||
/// * Three input sources (rdev grab, Flutter raw key, Flutter USB HID)
|
||||
/// all converge to `rdev::Key`, so correctness depends on
|
||||
/// `rdev::key_from_code` / `rdev::usb_hid_key_from_code` /
|
||||
/// `rdev::get_win_key` agreeing on the same physical key — the project
|
||||
/// already has scattered swap_modifier_key / ControlLeft↔MetaLeft
|
||||
/// fixups for places where they historically *didn't* agree. Any new
|
||||
/// mismatch silently leaks the set; "shortcut stopped responding"
|
||||
/// after a stuck entry is a worse failure mode than "shortcut fired
|
||||
/// twice."
|
||||
/// * Leak risk on focus loss / disconnect, requiring a clear hook the
|
||||
/// callers must remember to invoke.
|
||||
/// * Two new code paths to keep in lockstep with two existing keyboard
|
||||
/// pipelines.
|
||||
///
|
||||
/// For two warts whose user-visible impact is nil-to-marginal, that
|
||||
/// trade-off goes the wrong way. Leave it. If a real user bug shows up
|
||||
/// here, revisit then with concrete repro — not pre-emptively.
|
||||
pub fn match_event(event: &rdev::Event) -> Option<String> {
|
||||
let bindings = current();
|
||||
if !bindings.enabled || bindings.pass_through {
|
||||
return None;
|
||||
}
|
||||
// Note: `match_normalized` re-checks both flags below — this short-circuit
|
||||
// is just to avoid the `event_to_key_name` + `get_modifiers_state` work
|
||||
// in the common bypass case.
|
||||
let key_name = event_to_key_name(event)?;
|
||||
let (alt, ctrl, shift, command) =
|
||||
crate::keyboard::client::get_modifiers_state(false, false, false, false);
|
||||
let mods = normalize_modifiers(alt, ctrl, shift, command);
|
||||
match_normalized(&key_name, &mods, &bindings).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Match `event` against the cached bindings; if it matched, push a
|
||||
/// `shortcut_triggered` Flutter session event and return `true` so the caller
|
||||
/// can `return` early. Returns `false` when no shortcut fired (caller should
|
||||
/// continue with normal key handling).
|
||||
///
|
||||
/// `session_id`:
|
||||
/// * `Some(&id)` — Flutter FFI path: dispatch to the exact session whose key
|
||||
/// event we're processing. No dependence on the global focus tracker.
|
||||
/// * `None` — rdev grab loop: the loop is process-wide and has no way to know
|
||||
/// which Flutter session id the keystroke was meant for, so route to the
|
||||
/// globally-current session via `flutter::get_cur_session_id()`.
|
||||
#[cfg(feature = "flutter")]
|
||||
pub fn try_dispatch(session_id: Option<&hbb_common::SessionID>, event: &rdev::Event) -> bool {
|
||||
let Some(action_id) = match_event(event) else {
|
||||
return false;
|
||||
};
|
||||
let resolved;
|
||||
let sid = match session_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
resolved = crate::flutter::get_cur_session_id();
|
||||
&resolved
|
||||
}
|
||||
};
|
||||
crate::flutter::push_session_event(sid, "shortcut_triggered", vec![("action", &action_id)]);
|
||||
true
|
||||
}
|
||||
|
||||
fn mods_bits(m: &[Modifier]) -> u8 {
|
||||
let mut bits = 0u8;
|
||||
for x in m {
|
||||
bits |= match x {
|
||||
Modifier::Primary => 1,
|
||||
Modifier::Alt => 2,
|
||||
Modifier::Shift => 4,
|
||||
// macOS users can bind shortcuts that use Control independently
|
||||
// of Command. On Win/Linux this variant should never appear in a
|
||||
// saved binding (`normalize_modifiers` collapses Ctrl into
|
||||
// Primary), but we still give it a distinct bit so a hand-edited
|
||||
// config can't accidentally collide with another modifier.
|
||||
Modifier::Ctrl => 8,
|
||||
};
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn mods_equal(a: &[Modifier], b: &[Modifier]) -> bool {
|
||||
mods_bits(a) == mods_bits(b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_press(k: rdev::Key) -> rdev::Event {
|
||||
rdev::Event {
|
||||
time: std::time::SystemTime::now(),
|
||||
unicode: None,
|
||||
platform_code: 0,
|
||||
position_code: 0,
|
||||
event_type: rdev::EventType::KeyPress(k),
|
||||
usb_hid: 0,
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extra_data: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_to_key_name_handles_f_keys() {
|
||||
use rdev::Key;
|
||||
assert_eq!(event_to_key_name(&make_press(Key::F1)), Some("f1".into()));
|
||||
assert_eq!(event_to_key_name(&make_press(Key::F5)), Some("f5".into()));
|
||||
assert_eq!(event_to_key_name(&make_press(Key::F12)), Some("f12".into()));
|
||||
}
|
||||
|
||||
/// Cross-language parity for default bindings. The fixture file is the
|
||||
/// shared source of truth — Dart has a mirror test against the same file
|
||||
/// (`kDefaultShortcutBindings matches fixture` in
|
||||
/// `flutter/test/keyboard_shortcuts_test.dart`). Any drift on either
|
||||
/// side breaks one of the two tests.
|
||||
#[test]
|
||||
fn default_bindings_match_fixture_json() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../flutter/test/fixtures/default_keyboard_shortcuts.json"
|
||||
))
|
||||
.expect("fixture is valid JSON");
|
||||
let actual: serde_json::Value =
|
||||
serde_json::to_value(default_bindings()).expect("serialize defaults");
|
||||
assert_eq!(
|
||||
fixture, actual,
|
||||
"default_bindings() drifted from \
|
||||
flutter/test/fixtures/default_keyboard_shortcuts.json — update \
|
||||
shortcuts.rs, the fixture, and Dart kDefaultShortcutBindings together"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_to_key_name_treats_numpad_enter_as_enter() {
|
||||
use rdev::{Event, EventType, Key};
|
||||
let make = |k: Key| Event {
|
||||
time: std::time::SystemTime::now(),
|
||||
unicode: None,
|
||||
platform_code: 0,
|
||||
position_code: 0,
|
||||
event_type: EventType::KeyPress(k),
|
||||
usb_hid: 0,
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extra_data: 0,
|
||||
};
|
||||
assert_eq!(event_to_key_name(&make(Key::Return)), Some("enter".into()));
|
||||
assert_eq!(event_to_key_name(&make(Key::KpReturn)), Some("enter".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindings_round_trip_json() {
|
||||
let json = r#"{
|
||||
"enabled": true,
|
||||
"bindings": [
|
||||
{"action": "send_ctrl_alt_del", "mods": ["primary","alt","shift"], "key": "delete"},
|
||||
{"action": "toggle_fullscreen", "mods": ["primary","alt","shift"], "key": "enter"}
|
||||
]
|
||||
}"#;
|
||||
let parsed: Bindings = serde_json::from_str(json).expect("parse");
|
||||
assert!(parsed.enabled);
|
||||
assert_eq!(parsed.bindings.len(), 2);
|
||||
assert_eq!(parsed.bindings[0].action, "send_ctrl_alt_del");
|
||||
assert_eq!(parsed.bindings[0].key, "delete");
|
||||
|
||||
let serialized = serde_json::to_string(&parsed).expect("serialize");
|
||||
let reparsed: Bindings = serde_json::from_str(&serialized).expect("reparse");
|
||||
assert_eq!(parsed, reparsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_design_doc() {
|
||||
let defaults = default_bindings();
|
||||
let actions: Vec<&str> = defaults.iter().map(|b| b.action.as_str()).collect();
|
||||
assert!(actions.contains(&action_id::SEND_CTRL_ALT_DEL));
|
||||
assert!(actions.contains(&action_id::TOGGLE_FULLSCREEN));
|
||||
assert!(actions.contains(&action_id::SWITCH_DISPLAY_NEXT));
|
||||
assert!(actions.contains(&action_id::SWITCH_DISPLAY_PREV));
|
||||
assert!(actions.contains(&action_id::SCREENSHOT));
|
||||
assert!(actions.contains(&action_id::TOGGLE_SHOW_REMOTE_CURSOR));
|
||||
assert!(actions.contains(&action_id::TOGGLE_MUTE));
|
||||
assert!(actions.contains(&action_id::TOGGLE_BLOCK_INPUT));
|
||||
assert!(actions.contains(&action_id::TOGGLE_CHAT));
|
||||
// every default binding includes the three-modifier prefix
|
||||
for b in &defaults {
|
||||
assert!(b.mods.contains(&Modifier::Primary));
|
||||
assert!(b.mods.contains(&Modifier::Alt));
|
||||
assert!(b.mods.contains(&Modifier::Shift));
|
||||
}
|
||||
}
|
||||
|
||||
fn match_for_test<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
|
||||
match_normalized(key, mods, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_pass_through() {
|
||||
let bindings = Bindings {
|
||||
enabled: true,
|
||||
pass_through: true,
|
||||
bindings: default_bindings(),
|
||||
};
|
||||
let result = match_normalized(
|
||||
"p",
|
||||
&[Modifier::Primary, Modifier::Alt, Modifier::Shift],
|
||||
&bindings,
|
||||
);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_disabled() {
|
||||
let bindings = Bindings { enabled: false, pass_through: false, bindings: default_bindings() };
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_screenshot_when_enabled() {
|
||||
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, Some(action_id::SCREENSHOT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_modifiers_partial() {
|
||||
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
|
||||
// missing Shift
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_does_not_fire_on_extra_unbound_keys() {
|
||||
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
|
||||
let result = match_for_test("z", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_handles_duplicate_modifiers_in_input() {
|
||||
// A user-edited config could contain duplicate modifiers; the matcher must
|
||||
// treat the modifier list as a set, not a multiset.
|
||||
let bindings = Bindings {
|
||||
enabled: true,
|
||||
pass_through: false,
|
||||
bindings: vec![Binding {
|
||||
action: "x".into(),
|
||||
mods: vec![Modifier::Primary, Modifier::Alt],
|
||||
key: "a".into(),
|
||||
}],
|
||||
};
|
||||
// Caller passes Primary twice — must not match a binding with Primary+Alt.
|
||||
assert_eq!(
|
||||
match_normalized("a", &[Modifier::Primary, Modifier::Primary], &bindings),
|
||||
None,
|
||||
);
|
||||
// Caller passes Primary+Alt with one duplicate — should still match.
|
||||
assert_eq!(
|
||||
match_normalized("a", &[Modifier::Primary, Modifier::Alt, Modifier::Alt], &bindings),
|
||||
Some("x"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_normalization_primary_resolves_per_os() {
|
||||
// On Win/Linux: pressing Ctrl satisfies Primary
|
||||
let mods = normalize_modifiers(/*alt=*/true, /*ctrl=*/true, /*shift=*/true, /*command=*/false);
|
||||
if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
// On Apple platforms Ctrl is NOT primary
|
||||
assert!(!mods.contains(&Modifier::Primary));
|
||||
assert!(mods.contains(&Modifier::Ctrl));
|
||||
} else {
|
||||
assert!(mods.contains(&Modifier::Primary));
|
||||
}
|
||||
assert!(mods.contains(&Modifier::Alt));
|
||||
assert!(mods.contains(&Modifier::Shift));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_normalization_command_is_primary_on_apple() {
|
||||
let mods = normalize_modifiers(true, false, true, /*command=*/true);
|
||||
if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
assert!(mods.contains(&Modifier::Primary));
|
||||
} else {
|
||||
// On Win/Linux Command/Meta is NOT primary
|
||||
assert!(!mods.contains(&Modifier::Primary));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_refuses_zero_modifier_bindings() {
|
||||
// Defense-in-depth: a hand-edited config with empty `mods` must NOT
|
||||
// turn every plain "P" press into a screenshot shortcut, which would
|
||||
// swallow all typing in the remote session. The recording dialog
|
||||
// already refuses to save such bindings, but the matcher must hold
|
||||
// the line independently.
|
||||
let bindings = Bindings {
|
||||
enabled: true,
|
||||
pass_through: false,
|
||||
bindings: vec![Binding {
|
||||
action: "screenshot".into(),
|
||||
mods: vec![],
|
||||
key: "p".into(),
|
||||
}],
|
||||
};
|
||||
assert_eq!(match_normalized("p", &[], &bindings), None);
|
||||
// Even with extra modifiers held by the user, a zero-mod binding
|
||||
// still doesn't match (no shape of held modifiers can equal the
|
||||
// empty saved set after the empty-check skips the entry).
|
||||
assert_eq!(
|
||||
match_normalized("p", &[Modifier::Primary], &bindings),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-language parity for the full set of shortcut-bindable key
|
||||
/// names (not just the defaults). The fixture lists every name the
|
||||
/// matcher accepts; this test verifies the (rdev::Key → name) round-trip
|
||||
/// covers exactly that set. Dart has a mirror test against the same
|
||||
/// fixture (`logicalKeyName covers the supported-keys fixture` in
|
||||
/// `flutter/test/keyboard_shortcuts_test.dart`).
|
||||
///
|
||||
/// Adding a key requires updates in three places: the fixture, this
|
||||
/// table, and the Dart `logicalKeyName` — that's the price of the
|
||||
/// parity guarantee. Drift on any side breaks one of the two tests.
|
||||
#[test]
|
||||
fn supported_keys_match_fixture() {
|
||||
use rdev::Key;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let table: &[(&str, Key)] = &[
|
||||
("a", Key::KeyA), ("b", Key::KeyB), ("c", Key::KeyC),
|
||||
("d", Key::KeyD), ("e", Key::KeyE), ("f", Key::KeyF),
|
||||
("g", Key::KeyG), ("h", Key::KeyH), ("i", Key::KeyI),
|
||||
("j", Key::KeyJ), ("k", Key::KeyK), ("l", Key::KeyL),
|
||||
("m", Key::KeyM), ("n", Key::KeyN), ("o", Key::KeyO),
|
||||
("p", Key::KeyP), ("q", Key::KeyQ), ("r", Key::KeyR),
|
||||
("s", Key::KeyS), ("t", Key::KeyT), ("u", Key::KeyU),
|
||||
("v", Key::KeyV), ("w", Key::KeyW), ("x", Key::KeyX),
|
||||
("y", Key::KeyY), ("z", Key::KeyZ),
|
||||
("digit0", Key::Num0), ("digit1", Key::Num1),
|
||||
("digit2", Key::Num2), ("digit3", Key::Num3),
|
||||
("digit4", Key::Num4), ("digit5", Key::Num5),
|
||||
("digit6", Key::Num6), ("digit7", Key::Num7),
|
||||
("digit8", Key::Num8), ("digit9", Key::Num9),
|
||||
("f1", Key::F1), ("f2", Key::F2), ("f3", Key::F3),
|
||||
("f4", Key::F4), ("f5", Key::F5), ("f6", Key::F6),
|
||||
("f7", Key::F7), ("f8", Key::F8), ("f9", Key::F9),
|
||||
("f10", Key::F10), ("f11", Key::F11), ("f12", Key::F12),
|
||||
("delete", Key::Delete),
|
||||
("backspace", Key::Backspace),
|
||||
("tab", Key::Tab),
|
||||
("space", Key::Space),
|
||||
("enter", Key::Return),
|
||||
("enter", Key::KpReturn),
|
||||
("arrow_left", Key::LeftArrow),
|
||||
("arrow_right", Key::RightArrow),
|
||||
("arrow_up", Key::UpArrow),
|
||||
("arrow_down", Key::DownArrow),
|
||||
("home", Key::Home),
|
||||
("end", Key::End),
|
||||
("page_up", Key::PageUp),
|
||||
("page_down", Key::PageDown),
|
||||
("insert", Key::Insert),
|
||||
];
|
||||
|
||||
// Round-trip: every entry in the table must map through
|
||||
// event_to_key_name to its declared name.
|
||||
for (name, key) in table {
|
||||
assert_eq!(
|
||||
event_to_key_name(&make_press(*key)).as_deref(),
|
||||
Some(*name),
|
||||
"rdev::Key::{:?} should map to {:?}",
|
||||
key, name,
|
||||
);
|
||||
}
|
||||
|
||||
// The set of names produced by the table must equal the fixture.
|
||||
let actual: BTreeSet<&str> = table.iter().map(|(n, _)| *n).collect();
|
||||
let fixture_raw: Vec<String> = serde_json::from_str(include_str!(
|
||||
"../../flutter/test/fixtures/supported_shortcut_keys.json"
|
||||
))
|
||||
.expect("fixture is valid JSON");
|
||||
let expected: BTreeSet<&str> =
|
||||
fixture_raw.iter().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"event_to_key_name vocabulary drifted from \
|
||||
flutter/test/fixtures/supported_shortcut_keys.json — update \
|
||||
shortcuts.rs, the fixture, and Dart logicalKeyName together"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_handles_missing_and_invalid_json() {
|
||||
// empty (no value set) → defaults
|
||||
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), String::new());
|
||||
reload_from_config();
|
||||
let b = current();
|
||||
assert!(!b.enabled);
|
||||
assert!(b.bindings.is_empty());
|
||||
|
||||
// invalid JSON → defaults (no panic)
|
||||
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), "not json".into());
|
||||
reload_from_config();
|
||||
let b = current();
|
||||
assert!(!b.enabled);
|
||||
}
|
||||
}
|
||||
@@ -743,5 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "显示名称"),
|
||||
("password-hidden-tip", "永久密码已设置(已隐藏)"),
|
||||
("preset-password-in-use-tip", "当前使用预设密码"),
|
||||
("Keyboard Shortcuts", "键盘快捷键"),
|
||||
("Configure shortcuts...", "配置快捷键..."),
|
||||
("Enable keyboard shortcuts in remote session", "在远程会话中启用键盘快捷键"),
|
||||
("shortcut-page-description", "为下列每项会话操作绑定一个组合键。每个绑定至少需要包含一个修饰符。"),
|
||||
("shortcut-passthrough-tip", "开启后,所有已绑定的组合键都会原样转发到远端。适合在某个组合键与远端需要使用的快捷键冲突时打开。"),
|
||||
("Pass-through to remote", "穿透到远端"),
|
||||
("Reset to defaults", "恢复默认设置"),
|
||||
("shortcut-reset-confirm-tip", "这将以默认快捷键替换所有当前绑定。是否继续?"),
|
||||
("Monitor", "显示器"),
|
||||
("Keyboard", "键盘"),
|
||||
("Toggle fullscreen", "切换全屏"),
|
||||
("Switch to next display", "切换到下一个显示器"),
|
||||
("Switch to previous display", "切换到上一个显示器"),
|
||||
("All monitors", "所有显示器"),
|
||||
("Monitor #{}", "{} 号显示器"),
|
||||
("Switch to next tab", "切换到下一个标签"),
|
||||
("Switch to previous tab", "切换到上一个标签"),
|
||||
("Toggle session recording", "切换会话录制"),
|
||||
("Close tab", "关闭标签页"),
|
||||
("Toggle toolbar", "切换工具栏可见性"),
|
||||
("Toggle input source", "切换输入源"),
|
||||
("Edit", "编辑"),
|
||||
("Save", "保存"),
|
||||
("Set Shortcut", "设置快捷键"),
|
||||
("shortcut-recording-instruction", "请按下您想使用的组合键。"),
|
||||
("shortcut-recording-press-keys-tip", "请按下组合键..."),
|
||||
("shortcut-must-include-modifiers", "必须至少包含一个修饰符:{}"),
|
||||
("shortcut-already-bound-to", "已绑定到"),
|
||||
("Replace", "替换"),
|
||||
("Valid", "有效"),
|
||||
("shortcut-mobile-physical-keyboard-tip", "录制需要使用物理键盘,不支持软键盘。"),
|
||||
("shortcut-key-not-supported", "“{}” 不能用作快捷键。"),
|
||||
("On", "开"),
|
||||
("Off", "关"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -274,5 +274,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"),
|
||||
("password-hidden-tip", "Permanent password is set (hidden)."),
|
||||
("preset-password-in-use-tip", "Preset password is currently in use."),
|
||||
("shortcut-page-description", "Bind a key combination to each session action below. Each binding must include at least one modifier."),
|
||||
("shortcut-passthrough-tip", "When on, every bound combination is forwarded to the remote. Useful when a binding collides with something you need on the remote."),
|
||||
("shortcut-reset-confirm-tip", "This will replace all current bindings with the default set. Continue?"),
|
||||
("shortcut-recording-instruction", "Press the key combination you want to use."),
|
||||
("shortcut-recording-press-keys-tip", "Press a key combination..."),
|
||||
("shortcut-must-include-modifiers", "Must include at least one modifier: {}"),
|
||||
("shortcut-already-bound-to", "Already bound to"),
|
||||
("shortcut-mobile-physical-keyboard-tip", "Recording requires a physical keyboard. Soft keyboards are not supported."),
|
||||
("shortcut-key-not-supported", "\"{}\" can't be used as a shortcut."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use hbb_common::{
|
||||
sync::mpsc,
|
||||
time::{Duration as TokioDuration, Instant},
|
||||
},
|
||||
whoami, Stream,
|
||||
whoami, SessionID, Stream,
|
||||
};
|
||||
use rdev::{Event, EventType::*, KeyCode};
|
||||
#[cfg(all(feature = "vram", feature = "flutter"))]
|
||||
@@ -913,6 +913,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(any(target_os = "ios"))]
|
||||
pub fn handle_flutter_raw_key_event(
|
||||
&self,
|
||||
_session_id: SessionID,
|
||||
_keyboard_mode: &str,
|
||||
_name: &str,
|
||||
_platform_code: i32,
|
||||
@@ -925,6 +926,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
pub fn handle_flutter_raw_key_event(
|
||||
&self,
|
||||
session_id: SessionID,
|
||||
keyboard_mode: &str,
|
||||
name: &str,
|
||||
platform_code: i32,
|
||||
@@ -936,6 +938,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
self._handle_key_flutter_simulation(keyboard_mode, platform_code, down_or_up);
|
||||
} else {
|
||||
self._handle_raw_key_non_flutter_simulation(
|
||||
session_id,
|
||||
keyboard_mode,
|
||||
platform_code,
|
||||
position_code,
|
||||
@@ -948,6 +951,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
fn _handle_raw_key_non_flutter_simulation(
|
||||
&self,
|
||||
session_id: SessionID,
|
||||
keyboard_mode: &str,
|
||||
platform_code: i32,
|
||||
position_code: i32,
|
||||
@@ -981,11 +985,18 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extra_data: 0,
|
||||
};
|
||||
keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self);
|
||||
keyboard::client::process_event_with_session(
|
||||
keyboard_mode,
|
||||
&event,
|
||||
Some(lock_modes),
|
||||
self,
|
||||
session_id,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn handle_flutter_key_event(
|
||||
&self,
|
||||
session_id: SessionID,
|
||||
keyboard_mode: &str,
|
||||
character: &str,
|
||||
usb_hid: i32,
|
||||
@@ -996,6 +1007,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
self._handle_key_flutter_simulation(keyboard_mode, usb_hid, down_or_up);
|
||||
} else {
|
||||
self._handle_key_non_flutter_simulation(
|
||||
session_id,
|
||||
keyboard_mode,
|
||||
character,
|
||||
usb_hid,
|
||||
@@ -1031,6 +1043,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
|
||||
fn _handle_key_non_flutter_simulation(
|
||||
&self,
|
||||
session_id: SessionID,
|
||||
keyboard_mode: &str,
|
||||
character: &str,
|
||||
usb_hid: i32,
|
||||
@@ -1092,7 +1105,13 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extra_data: 0,
|
||||
};
|
||||
keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self);
|
||||
keyboard::client::process_event_with_session(
|
||||
keyboard_mode,
|
||||
&event,
|
||||
Some(lock_modes),
|
||||
self,
|
||||
session_id,
|
||||
);
|
||||
}
|
||||
|
||||
// flutter only TODO new input
|
||||
|
||||
Reference in New Issue
Block a user