diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 8146e0d6f..ec05c987f 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1861,8 +1861,18 @@ class _KeyboardMenu extends StatelessWidget { continue; } - if (pi.isWayland && mode.key != kKeyMapMode) { - continue; + if (pi.isWayland) { + // Legacy mode is hidden on desktop control side because dead keys + // don't work properly on Wayland. When the control side is mobile, + // Legacy mode is used automatically (mobile always sends Legacy events). + if (mode.key == kKeyLegacyMode) { + continue; + } + // Translate mode requires server >= 1.4.6. + if (mode.key == kKeyTranslateMode && + versionCmp(pi.version, '1.4.6') < 0) { + continue; + } } var text = translate(mode.menu); diff --git a/libs/enigo/src/linux/nix_impl.rs b/libs/enigo/src/linux/nix_impl.rs index 902d77948..c16be3469 100644 --- a/libs/enigo/src/linux/nix_impl.rs +++ b/libs/enigo/src/linux/nix_impl.rs @@ -261,6 +261,8 @@ impl KeyboardControllable for Enigo { } else { if let Some(keyboard) = &mut self.custom_keyboard { keyboard.key_sequence(sequence) + } else { + log::warn!("Enigo::key_sequence: no custom_keyboard set for Wayland!"); } } } @@ -277,6 +279,7 @@ impl KeyboardControllable for Enigo { if let Some(keyboard) = &mut self.custom_keyboard { keyboard.key_down(key) } else { + log::warn!("Enigo::key_down: no custom_keyboard set for Wayland!"); Ok(()) } } @@ -290,13 +293,24 @@ impl KeyboardControllable for Enigo { } else { if let Some(keyboard) = &mut self.custom_keyboard { keyboard.key_up(key) + } else { + log::warn!("Enigo::key_up: no custom_keyboard set for Wayland!"); } } } fn key_click(&mut self, key: Key) { - if self.tfc_key_click(key).is_err() { - self.key_down(key).ok(); - self.key_up(key); + if self.is_x11 { + // X11: try tfc first, then fallback to key_down/key_up + if self.tfc_key_click(key).is_err() { + self.key_down(key).ok(); + self.key_up(key); + } + } else { + if let Some(keyboard) = &mut self.custom_keyboard { + keyboard.key_click(key); + } else { + log::warn!("Enigo::key_click: no custom_keyboard set for Wayland!"); + } } } } diff --git a/src/server/input_service.rs b/src/server/input_service.rs index b1c2d66b6..fb8441dde 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -111,6 +111,10 @@ struct Input { const KEY_CHAR_START: u64 = 9999; +// XKB keycode for Insert key (evdev KEY_INSERT code 110 + 8 for XKB offset) +#[cfg(target_os = "linux")] +const XKB_KEY_INSERT: u16 = evdev::Key::KEY_INSERT.code() + 8; + #[derive(Clone, Default)] pub struct MouseCursorSub { inner: ConnInner, @@ -1105,8 +1109,12 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { // Clamp delta to prevent extreme/malicious values from reaching OS APIs. // This matches the Flutter client's kMaxRelativeMouseDelta constant. const MAX_RELATIVE_MOUSE_DELTA: i32 = 10000; - let dx = evt.x.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA); - let dy = evt.y.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA); + let dx = evt + .x + .clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA); + let dy = evt + .y + .clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA); en.mouse_move_relative(dx, dy); // Get actual cursor position after relative movement for tracking if let Some((x, y)) = crate::get_cursor_pos() { @@ -1465,20 +1473,26 @@ fn map_keyboard_mode(evt: &KeyEvent) { // Wayland #[cfg(target_os = "linux")] if !crate::platform::linux::is_x11() { - let mut en = ENIGO.lock().unwrap(); - let code = evt.chr() as u16; - - if evt.down { - en.key_down(enigo::Key::Raw(code)).ok(); - } else { - en.key_up(enigo::Key::Raw(code)); - } + wayland_send_raw_key(evt.chr() as u16, evt.down); return; } sim_rdev_rawkey_position(evt.chr() as _, evt.down); } +/// Send raw keycode on Wayland via the active backend (uinput or RemoteDesktop portal). +/// The keycode is expected to be a Linux keycode (evdev code + 8 for X11 compatibility). +#[cfg(target_os = "linux")] +#[inline] +fn wayland_send_raw_key(code: u16, down: bool) { + let mut en = ENIGO.lock().unwrap(); + if down { + en.key_down(enigo::Key::Raw(code)).ok(); + } else { + en.key_up(enigo::Key::Raw(code)); + } +} + #[cfg(target_os = "macos")] fn add_flags_to_enigo(en: &mut Enigo, key_event: &KeyEvent) { // When long-pressed the command key, then press and release @@ -1559,6 +1573,20 @@ fn need_to_uppercase(en: &mut Enigo) -> bool { } fn process_chr(en: &mut Enigo, chr: u32, down: bool) { + // On Wayland with uinput mode, use clipboard for character input + #[cfg(target_os = "linux")] + if !crate::platform::linux::is_x11() && wayland_use_uinput() { + // Skip clipboard for hotkeys (Ctrl/Alt/Meta pressed) + if !is_hotkey_modifier_pressed(en) { + if down { + if let Ok(c) = char::try_from(chr) { + input_char_via_clipboard_server(en, c); + } + } + return; + } + } + let key = char_value_to_key(chr); if down { @@ -1578,15 +1606,136 @@ fn process_chr(en: &mut Enigo, chr: u32, down: bool) { } fn process_unicode(en: &mut Enigo, chr: u32) { + // On Wayland with uinput mode, use clipboard for character input + #[cfg(target_os = "linux")] + if !crate::platform::linux::is_x11() && wayland_use_uinput() { + if let Ok(c) = char::try_from(chr) { + input_char_via_clipboard_server(en, c); + } + return; + } + if let Ok(chr) = char::try_from(chr) { en.key_sequence(&chr.to_string()); } } fn process_seq(en: &mut Enigo, sequence: &str) { + // On Wayland with uinput mode, use clipboard for text input + #[cfg(target_os = "linux")] + if !crate::platform::linux::is_x11() && wayland_use_uinput() { + input_text_via_clipboard_server(en, sequence); + return; + } + en.key_sequence(&sequence); } +/// Delay in milliseconds to wait for clipboard to sync on Wayland. +/// This is an empirical value — Wayland provides no callback or event to confirm +/// clipboard content has been received by the compositor. Under heavy system load, +/// this delay may be insufficient, but there is no reliable alternative mechanism. +#[cfg(target_os = "linux")] +const CLIPBOARD_SYNC_DELAY_MS: u64 = 50; + +/// Internal: Set clipboard content without delay. +/// Returns true if clipboard was set successfully. +#[cfg(target_os = "linux")] +fn set_clipboard_content(text: &str) -> bool { + use arboard::{Clipboard, LinuxClipboardKind, SetExtLinux}; + + let mut clipboard = match Clipboard::new() { + Ok(cb) => cb, + Err(e) => { + log::error!("set_clipboard_content: failed to create clipboard: {:?}", e); + return false; + } + }; + + // Set both CLIPBOARD and PRIMARY selections + // Terminal uses PRIMARY for Shift+Insert, GUI apps use CLIPBOARD + if let Err(e) = clipboard + .set() + .clipboard(LinuxClipboardKind::Clipboard) + .text(text.to_owned()) + { + log::error!("set_clipboard_content: failed to set CLIPBOARD: {:?}", e); + return false; + } + if let Err(e) = clipboard + .set() + .clipboard(LinuxClipboardKind::Primary) + .text(text.to_owned()) + { + log::warn!("set_clipboard_content: failed to set PRIMARY: {:?}", e); + // Continue anyway, CLIPBOARD might work + } + + true +} + +/// Set clipboard content for paste operation (sync version for use in blocking contexts). +/// +/// Note: The original clipboard content is intentionally NOT restored after paste. +/// Restoring clipboard could cause race conditions where subsequent keystrokes +/// might accidentally paste the old clipboard content instead of the intended input. +/// This trade-off prioritizes input reliability over preserving clipboard state. +#[cfg(target_os = "linux")] +#[inline] +pub(super) fn set_clipboard_for_paste_sync(text: &str) -> bool { + if !set_clipboard_content(text) { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(CLIPBOARD_SYNC_DELAY_MS)); + true +} + +/// Check if a character is ASCII printable (0x20-0x7E). +#[cfg(target_os = "linux")] +#[inline] +pub(super) fn is_ascii_printable(c: char) -> bool { + c as u32 >= 0x20 && c as u32 <= 0x7E +} + +/// Input a single character via clipboard + Shift+Insert in server process. +#[cfg(target_os = "linux")] +#[inline] +fn input_char_via_clipboard_server(en: &mut Enigo, chr: char) { + input_text_via_clipboard_server(en, &chr.to_string()); +} + +/// Input text via clipboard + Shift+Insert in server process. +/// Shift+Insert is more universal than Ctrl+V, works in both GUI apps and terminals. +/// +/// Note: Clipboard content is NOT restored after paste - see `set_clipboard_for_paste_sync` for rationale. +#[cfg(target_os = "linux")] +fn input_text_via_clipboard_server(en: &mut Enigo, text: &str) { + if text.is_empty() { + return; + } + if !set_clipboard_for_paste_sync(text) { + return; + } + + // Use ENIGO's custom_keyboard directly to avoid creating new IPC connections + // which would cause excessive logging and keyboard device creation/destruction + if en.key_down(Key::Shift).is_err() { + log::error!("input_text_via_clipboard_server: failed to press Shift, skipping paste"); + return; + } + if en.key_down(Key::Raw(XKB_KEY_INSERT)).is_err() { + log::error!("input_text_via_clipboard_server: failed to press Insert, releasing Shift"); + en.key_up(Key::Shift); + return; + } + en.key_up(Key::Raw(XKB_KEY_INSERT)); + en.key_up(Key::Shift); + + // Brief delay to allow the target application to process the paste event. + // Empirical value — no reliable synchronization mechanism exists on Wayland. + std::thread::sleep(std::time::Duration::from_millis(20)); +} + #[cfg(not(target_os = "macos"))] fn release_keys(en: &mut Enigo, to_release: &Vec) { for key in to_release { @@ -1621,6 +1770,64 @@ fn is_function_key(ck: &EnumOrUnknown) -> bool { return res; } +/// Check if any hotkey modifier (Ctrl/Alt/Meta) is currently pressed. +/// Used to detect hotkey combinations like Ctrl+C, Alt+Tab, etc. +/// +/// Note: Shift is intentionally NOT checked here. Shift+character produces a different +/// character (e.g., Shift+a → 'A'), which is normal text input, not a hotkey. +/// Shift is only relevant as a hotkey modifier when combined with Ctrl/Alt/Meta +/// (e.g., Ctrl+Shift+Z), in which case this function already returns true via Ctrl. +#[cfg(target_os = "linux")] +#[inline] +fn is_hotkey_modifier_pressed(en: &mut Enigo) -> bool { + get_modifier_state(Key::Control, en) + || get_modifier_state(Key::RightControl, en) + || get_modifier_state(Key::Alt, en) + || get_modifier_state(Key::RightAlt, en) + || get_modifier_state(Key::Meta, en) + || get_modifier_state(Key::RWin, en) +} + +/// Release Shift keys before character input in Legacy/Translate mode. +/// In these modes, the character has already been converted by the client, +/// so we should input it directly without Shift modifier affecting the result. +/// +/// Note: Does NOT release Shift if hotkey modifiers (Ctrl/Alt/Meta) are pressed, +/// to preserve combinations like Ctrl+Shift+Z. +#[cfg(target_os = "linux")] +fn release_shift_for_char_input(en: &mut Enigo) { + // Don't release Shift if hotkey modifiers (Ctrl/Alt/Meta) are pressed. + // This preserves combinations like Ctrl+Shift+Z. + if is_hotkey_modifier_pressed(en) { + return; + } + + // In translate mode, the client has already converted the keystroke to a character + // (e.g., Shift+a → 'A'). We release Shift here so the server inputs the character + // directly without Shift affecting the result. + // + // Shift is intentionally NOT restored after input — the client will send an explicit + // Shift key_up event when the user physically releases Shift. Restoring it here would + // cause a brief Shift re-press that could interfere with the next input event. + + let is_x11 = crate::platform::linux::is_x11(); + + if get_modifier_state(Key::Shift, en) { + if !is_x11 { + en.key_up(Key::Shift); + } else { + simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft)); + } + } + if get_modifier_state(Key::RightShift, en) { + if !is_x11 { + en.key_up(Key::RightShift); + } else { + simulate_(&EventType::KeyRelease(RdevKey::ShiftRight)); + } + } +} + fn legacy_keyboard_mode(evt: &KeyEvent) { #[cfg(windows)] crate::platform::windows::try_change_desktop(); @@ -1640,11 +1847,24 @@ fn legacy_keyboard_mode(evt: &KeyEvent) { process_control_key(&mut en, &ck, down) } Some(key_event::Union::Chr(chr)) => { + // For character input in Legacy mode, we need to release Shift first. + // The character has already been converted by the client, so we should + // input it directly without Shift modifier affecting the result. + // Only Ctrl/Alt/Meta should be kept for hotkeys like Ctrl+C. + #[cfg(target_os = "linux")] + release_shift_for_char_input(&mut en); + let record_key = chr as u64 + KEY_CHAR_START; record_pressed_key(KeysDown::EnigoKey(record_key), down); process_chr(&mut en, chr, down) } - Some(key_event::Union::Unicode(chr)) => process_unicode(&mut en, chr), + Some(key_event::Union::Unicode(chr)) => { + // Same as Chr: release Shift for Unicode input + #[cfg(target_os = "linux")] + release_shift_for_char_input(&mut en); + + process_unicode(&mut en, chr) + } Some(key_event::Union::Seq(ref seq)) => process_seq(&mut en, seq), _ => {} } @@ -1665,6 +1885,51 @@ fn translate_process_code(code: u32, down: bool) { fn translate_keyboard_mode(evt: &KeyEvent) { match &evt.union { Some(key_event::Union::Seq(seq)) => { + // On Wayland, handle character input directly in this (--server) process using clipboard. + // This function runs in the --server process (logged-in user session), which has + // WAYLAND_DISPLAY and XDG_RUNTIME_DIR — so clipboard operations work here. + // + // Why not let it go through uinput IPC: + // 1. For uinput mode: the uinput service thread runs in the --service (root) process, + // which typically lacks user session environment. Clipboard operations there are + // unreliable. Handling clipboard here avoids that issue. + // 2. For RDP input mode: Portal's notify_keyboard_keysym API interprets keysyms + // based on its internal modifier state, which may not match our released state. + // Using clipboard bypasses this issue entirely. + #[cfg(target_os = "linux")] + if !crate::platform::linux::is_x11() { + let mut en = ENIGO.lock().unwrap(); + + // Check if this is a hotkey (Ctrl/Alt/Meta pressed) + // For hotkeys, we send character-based key events via Enigo instead of + // using the clipboard. This relies on the local keyboard layout for + // mapping characters to physical keys. + // This assumes client and server use the same keyboard layout (common case). + // Note: For non-Latin keyboards (e.g., Arabic), hotkeys may not work + // correctly if the character cannot be mapped to a key via KEY_MAP_LAYOUT. + // This is a known limitation - most common hotkeys (Ctrl+A/C/V/Z) use Latin + // characters which are mappable on most keyboard layouts. + if is_hotkey_modifier_pressed(&mut en) { + // For hotkeys, send character-based key events via Enigo. + // This relies on the local keyboard layout mapping (KEY_MAP_LAYOUT). + for chr in seq.chars() { + if !is_ascii_printable(chr) { + log::warn!( + "Hotkey with non-ASCII character may not work correctly on non-Latin keyboard layouts" + ); + } + en.key_click(Key::Layout(chr)); + } + return; + } + + // Normal text input: release Shift and use clipboard + release_shift_for_char_input(&mut en); + + input_text_via_clipboard_server(&mut en, seq); + return; + } + // Fr -> US // client: Shift + & => 1(send to remote) // remote: Shift + 1 => ! @@ -1682,11 +1947,16 @@ fn translate_keyboard_mode(evt: &KeyEvent) { #[cfg(target_os = "linux")] let simulate_win_hot_key = false; if !simulate_win_hot_key { - if get_modifier_state(Key::Shift, &mut en) { - simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft)); - } - if get_modifier_state(Key::RightShift, &mut en) { - simulate_(&EventType::KeyRelease(RdevKey::ShiftRight)); + #[cfg(target_os = "linux")] + release_shift_for_char_input(&mut en); + #[cfg(target_os = "windows")] + { + if get_modifier_state(Key::Shift, &mut en) { + simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft)); + } + if get_modifier_state(Key::RightShift, &mut en) { + simulate_(&EventType::KeyRelease(RdevKey::ShiftRight)); + } } } for chr in seq.chars() { @@ -1706,7 +1976,16 @@ fn translate_keyboard_mode(evt: &KeyEvent) { Some(key_event::Union::Chr(..)) => { #[cfg(target_os = "windows")] translate_process_code(evt.chr(), evt.down); - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] + { + if !crate::platform::linux::is_x11() { + // Wayland: use uinput to send raw keycode + wayland_send_raw_key(evt.chr() as u16, evt.down); + } else { + sim_rdev_rawkey_position(evt.chr() as _, evt.down); + } + } + #[cfg(target_os = "macos")] sim_rdev_rawkey_position(evt.chr() as _, evt.down); } Some(key_event::Union::Unicode(..)) => { @@ -1717,7 +1996,11 @@ fn translate_keyboard_mode(evt: &KeyEvent) { simulate_win2win_hotkey(*code, evt.down); } _ => { - log::debug!("Unreachable. Unexpected key event {:?}", &evt); + log::debug!( + "Unreachable. Unexpected key event (mode={:?}, down={:?})", + &evt.mode, + &evt.down + ); } } } diff --git a/src/server/rdp_input.rs b/src/server/rdp_input.rs index d9e11aca4..5348f2f24 100644 --- a/src/server/rdp_input.rs +++ b/src/server/rdp_input.rs @@ -1,7 +1,8 @@ -use crate::uinput::service::map_key; +use super::input_service::set_clipboard_for_paste_sync; +use crate::uinput::service::{can_input_via_keysym, char_to_keysym, map_key}; use dbus::{blocking::SyncConnection, Path}; use enigo::{Key, KeyboardControllable, MouseButton, MouseControllable}; -use hbb_common::ResultType; +use hbb_common::{log, ResultType}; use scrap::wayland::pipewire::{get_portal, PwStreamInfo}; use scrap::wayland::remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop as remote_desktop_portal; use std::collections::HashMap; @@ -19,14 +20,74 @@ pub mod client { const PRESSED_DOWN_STATE: u32 = 1; const PRESSED_UP_STATE: u32 = 0; + /// Modifier key state tracking for RDP input. + /// Portal API doesn't provide a way to query key state, so we track it ourselves. + #[derive(Default)] + struct ModifierState { + shift_left: bool, + shift_right: bool, + ctrl_left: bool, + ctrl_right: bool, + alt_left: bool, + alt_right: bool, + meta_left: bool, + meta_right: bool, + } + + impl ModifierState { + fn update(&mut self, key: &Key, down: bool) { + match key { + Key::Shift => self.shift_left = down, + Key::RightShift => self.shift_right = down, + Key::Control => self.ctrl_left = down, + Key::RightControl => self.ctrl_right = down, + Key::Alt => self.alt_left = down, + Key::RightAlt => self.alt_right = down, + Key::Meta | Key::Super | Key::Windows | Key::Command => self.meta_left = down, + Key::RWin => self.meta_right = down, + // Handle raw keycodes for modifier keys (Linux evdev codes + 8) + // In translate mode, modifier keys may be sent as Chr events with raw keycodes. + // The +8 offset converts evdev codes to X11/XKB keycodes. + Key::Raw(code) => { + const EVDEV_OFFSET: u16 = 8; + const KEY_LEFTSHIFT: u16 = evdev::Key::KEY_LEFTSHIFT.code() + EVDEV_OFFSET; + const KEY_RIGHTSHIFT: u16 = evdev::Key::KEY_RIGHTSHIFT.code() + EVDEV_OFFSET; + const KEY_LEFTCTRL: u16 = evdev::Key::KEY_LEFTCTRL.code() + EVDEV_OFFSET; + const KEY_RIGHTCTRL: u16 = evdev::Key::KEY_RIGHTCTRL.code() + EVDEV_OFFSET; + const KEY_LEFTALT: u16 = evdev::Key::KEY_LEFTALT.code() + EVDEV_OFFSET; + const KEY_RIGHTALT: u16 = evdev::Key::KEY_RIGHTALT.code() + EVDEV_OFFSET; + const KEY_LEFTMETA: u16 = evdev::Key::KEY_LEFTMETA.code() + EVDEV_OFFSET; + const KEY_RIGHTMETA: u16 = evdev::Key::KEY_RIGHTMETA.code() + EVDEV_OFFSET; + match *code { + KEY_LEFTSHIFT => self.shift_left = down, + KEY_RIGHTSHIFT => self.shift_right = down, + KEY_LEFTCTRL => self.ctrl_left = down, + KEY_RIGHTCTRL => self.ctrl_right = down, + KEY_LEFTALT => self.alt_left = down, + KEY_RIGHTALT => self.alt_right = down, + KEY_LEFTMETA => self.meta_left = down, + KEY_RIGHTMETA => self.meta_right = down, + _ => {} + } + } + _ => {} + } + } + } + pub struct RdpInputKeyboard { conn: Arc, session: Path<'static>, + modifier_state: ModifierState, } impl RdpInputKeyboard { pub fn new(conn: Arc, session: Path<'static>) -> ResultType { - Ok(Self { conn, session }) + Ok(Self { + conn, + session, + modifier_state: ModifierState::default(), + }) } } @@ -39,29 +100,192 @@ pub mod client { self } - fn get_key_state(&mut self, _: Key) -> bool { - // no api for this - false + fn get_key_state(&mut self, key: Key) -> bool { + // Use tracked modifier state for supported keys + match key { + Key::Shift => self.modifier_state.shift_left, + Key::RightShift => self.modifier_state.shift_right, + Key::Control => self.modifier_state.ctrl_left, + Key::RightControl => self.modifier_state.ctrl_right, + Key::Alt => self.modifier_state.alt_left, + Key::RightAlt => self.modifier_state.alt_right, + Key::Meta | Key::Super | Key::Windows | Key::Command => { + self.modifier_state.meta_left + } + Key::RWin => self.modifier_state.meta_right, + _ => false, + } } fn key_sequence(&mut self, s: &str) { for c in s.chars() { - let key = Key::Layout(c); - let _ = handle_key(true, key, self.conn.clone(), &self.session); - let _ = handle_key(false, key, self.conn.clone(), &self.session); + let keysym = char_to_keysym(c); + // ASCII characters: use keysym + if can_input_via_keysym(c, keysym) { + if let Err(e) = send_keysym(keysym, true, self.conn.clone(), &self.session) { + log::error!("Failed to send keysym down: {:?}", e); + } + if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) { + log::error!("Failed to send keysym up: {:?}", e); + } + } else { + // Non-ASCII: use clipboard + input_text_via_clipboard(&c.to_string(), self.conn.clone(), &self.session); + } } } fn key_down(&mut self, key: Key) -> enigo::ResultType { - handle_key(true, key, self.conn.clone(), &self.session)?; + if let Key::Layout(chr) = key { + let keysym = char_to_keysym(chr); + // ASCII characters: use keysym + if can_input_via_keysym(chr, keysym) { + send_keysym(keysym, true, self.conn.clone(), &self.session)?; + } else { + // Non-ASCII: use clipboard (complete key press in key_down) + input_text_via_clipboard(&chr.to_string(), self.conn.clone(), &self.session); + } + } else { + handle_key(true, key.clone(), self.conn.clone(), &self.session)?; + // Update modifier state only after successful send — + // if handle_key fails, we don't want stale "pressed" state + // affecting subsequent key event decisions. + self.modifier_state.update(&key, true); + } Ok(()) } + fn key_up(&mut self, key: Key) { - let _ = handle_key(false, key, self.conn.clone(), &self.session); + // Intentionally asymmetric with key_down: update state BEFORE sending. + // On release, we always mark as released even if the send fails below, + // to avoid permanently stuck-modifier state in our tracker. The trade-off + // (tracker says "released" while OS may still have it pressed) is acceptable + // because such failures are rare and subsequent events will resynchronize. + self.modifier_state.update(&key, false); + + if let Key::Layout(chr) = key { + // ASCII characters: send keysym up if we also sent it on key_down + let keysym = char_to_keysym(chr); + if can_input_via_keysym(chr, keysym) { + if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) + { + log::error!("Failed to send keysym up: {:?}", e); + } + } + // Non-ASCII: already handled completely in key_down via clipboard paste, + // no corresponding release needed (clipboard paste is an atomic operation) + } else { + if let Err(e) = handle_key(false, key, self.conn.clone(), &self.session) { + log::error!("Failed to handle key up: {:?}", e); + } + } } + fn key_click(&mut self, key: Key) { - let _ = handle_key(true, key, self.conn.clone(), &self.session); - let _ = handle_key(false, key, self.conn.clone(), &self.session); + if let Key::Layout(chr) = key { + let keysym = char_to_keysym(chr); + // ASCII characters: use keysym + if can_input_via_keysym(chr, keysym) { + if let Err(e) = send_keysym(keysym, true, self.conn.clone(), &self.session) { + log::error!("Failed to send keysym down: {:?}", e); + } + if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) { + log::error!("Failed to send keysym up: {:?}", e); + } + } else { + // Non-ASCII: use clipboard + input_text_via_clipboard(&chr.to_string(), self.conn.clone(), &self.session); + } + } else { + if let Err(e) = handle_key(true, key.clone(), self.conn.clone(), &self.session) { + log::error!("Failed to handle key down: {:?}", e); + } else { + // Only mark modifier as pressed if key-down was actually delivered + self.modifier_state.update(&key, true); + } + // Always mark as released to avoid stuck-modifier state + self.modifier_state.update(&key, false); + if let Err(e) = handle_key(false, key, self.conn.clone(), &self.session) { + log::error!("Failed to handle key up: {:?}", e); + } + } + } + } + + /// Input text via clipboard + Shift+Insert. + /// Shift+Insert is more universal than Ctrl+V, works in both GUI apps and terminals. + /// + /// Note: Clipboard content is NOT restored after paste - see `set_clipboard_for_paste_sync` for rationale. + fn input_text_via_clipboard(text: &str, conn: Arc, session: &Path<'static>) { + if text.is_empty() { + return; + } + if !set_clipboard_for_paste_sync(text) { + return; + } + + let portal = get_portal(&conn); + let shift_keycode = evdev::Key::KEY_LEFTSHIFT.code() as i32; + let insert_keycode = evdev::Key::KEY_INSERT.code() as i32; + + // Send Shift+Insert (universal paste shortcut) + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + shift_keycode, + PRESSED_DOWN_STATE, + ) { + log::error!("input_text_via_clipboard: failed to press Shift: {:?}", e); + return; + } + + // Press Insert + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + insert_keycode, + PRESSED_DOWN_STATE, + ) { + log::error!("input_text_via_clipboard: failed to press Insert: {:?}", e); + // Still try to release Shift. + // Note: clipboard has already been set by set_clipboard_for_paste_sync but paste + // never happened. We don't attempt to restore the previous clipboard contents + // because reading the clipboard on Wayland requires focus/permission. + let _ = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ); + return; + } + + // Release Insert + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + insert_keycode, + PRESSED_UP_STATE, + ) { + log::error!( + "input_text_via_clipboard: failed to release Insert: {:?}", + e + ); + } + + // Release Shift + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ) { + log::error!("input_text_via_clipboard: failed to release Shift: {:?}", e); } } @@ -196,6 +420,39 @@ pub mod client { } } + /// Send a keysym via RemoteDesktop portal. + fn send_keysym( + keysym: i32, + down: bool, + conn: Arc, + session: &Path<'static>, + ) -> ResultType<()> { + let state: u32 = if down { + PRESSED_DOWN_STATE + } else { + PRESSED_UP_STATE + }; + let portal = get_portal(&conn); + log::trace!( + "send_keysym: calling notify_keyboard_keysym, keysym={:#x}, state={}", + keysym, + state + ); + match remote_desktop_portal::notify_keyboard_keysym( + &portal, + session, + HashMap::new(), + keysym, + state, + ) { + Ok(_) => { + log::trace!("send_keysym: notify_keyboard_keysym succeeded"); + Ok(()) + } + Err(e) => Err(e.into()), + } + } + fn get_raw_evdev_keycode(key: u16) -> i32 { // 8 is the offset between xkb and evdev let mut key = key as i32 - 8; @@ -231,22 +488,86 @@ pub mod client { } _ => { if let Ok((key, is_shift)) = map_key(&key) { - if is_shift { - remote_desktop_portal::notify_keyboard_keycode( + let shift_keycode = evdev::Key::KEY_LEFTSHIFT.code() as i32; + if down { + // Press: Shift down first, then key down + if is_shift { + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + shift_keycode, + state, + ) { + log::error!("handle_key: failed to press Shift: {:?}", e); + return Err(e.into()); + } + } + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( &portal, &session, HashMap::new(), - evdev::Key::KEY_LEFTSHIFT.code() as i32, + key.code() as i32, state, - )?; + ) { + log::error!("handle_key: failed to press key: {:?}", e); + // Best-effort: release Shift if it was pressed + if is_shift { + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ) { + log::warn!( + "handle_key: best-effort Shift release also failed: {:?}", + e + ); + } + } + return Err(e.into()); + } + } else { + // Release: key up first, then Shift up + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + key.code() as i32, + PRESSED_UP_STATE, + ) { + log::error!("handle_key: failed to release key: {:?}", e); + // Best-effort: still try to release Shift + if is_shift { + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ) { + log::warn!( + "handle_key: best-effort Shift release also failed: {:?}", + e + ); + } + } + return Err(e.into()); + } + if is_shift { + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ) { + log::error!("handle_key: failed to release Shift: {:?}", e); + return Err(e.into()); + } + } } - remote_desktop_portal::notify_keyboard_keycode( - &portal, - &session, - HashMap::new(), - key.code() as i32, - state, - )?; } } } diff --git a/src/server/uinput.rs b/src/server/uinput.rs index 894ce82f9..a808b4aaa 100644 --- a/src/server/uinput.rs +++ b/src/server/uinput.rs @@ -90,6 +90,13 @@ pub mod client { } fn key_sequence(&mut self, sequence: &str) { + // Sequence events are normally handled in the --server process before reaching here. + // Forward via IPC as a fallback — input_text_wayland can still handle ASCII chars + // via keysym/uinput, though non-ASCII will be skipped (no clipboard in --service). + log::debug!( + "UInputKeyboard::key_sequence called (len={})", + sequence.len() + ); allow_err!(self.send(Data::Keyboard(DataKeyboard::Sequence(sequence.to_string())))); } @@ -178,6 +185,9 @@ pub mod client { pub mod service { use super::*; use hbb_common::lazy_static; + use scrap::wayland::{ + pipewire::RDP_SESSION_INFO, remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop, + }; use std::{collections::HashMap, sync::Mutex}; lazy_static::lazy_static! { @@ -309,6 +319,9 @@ pub mod service { ('/', (evdev::Key::KEY_SLASH, false)), (';', (evdev::Key::KEY_SEMICOLON, false)), ('\'', (evdev::Key::KEY_APOSTROPHE, false)), + // Space is intentionally in both KEY_MAP_LAYOUT (char-to-evdev for text input) + // and KEY_MAP (Key::Space for key events). Both maps serve different lookup paths. + (' ', (evdev::Key::KEY_SPACE, false)), // Shift + key ('A', (evdev::Key::KEY_A, true)), @@ -364,6 +377,155 @@ pub mod service { static ref RESOLUTION: Mutex<((i32, i32), (i32, i32))> = Mutex::new(((0, 0), (0, 0))); } + /// Input text on Wayland using layout-independent methods. + /// ASCII chars (0x20-0x7E): Portal keysym or uinput fallback + /// Non-ASCII chars: skipped — this runs in the --service (root) process where clipboard + /// operations are unreliable (typically no user session environment). + /// Non-ASCII input is normally handled by the --server process via input_text_via_clipboard_server. + fn input_text_wayland(text: &str, keyboard: &mut VirtualDevice) { + let portal_info = { + let session_info = RDP_SESSION_INFO.lock().unwrap(); + session_info + .as_ref() + .map(|info| (info.conn.clone(), info.session.clone())) + }; + + for c in text.chars() { + let keysym = char_to_keysym(c); + if can_input_via_keysym(c, keysym) { + // Try Portal first — down+up on the same channel + if let Some((ref conn, ref session)) = portal_info { + let portal = scrap::wayland::pipewire::get_portal(conn); + if portal + .notify_keyboard_keysym(session, HashMap::new(), keysym, 1) + .is_ok() + { + if let Err(e) = + portal.notify_keyboard_keysym(session, HashMap::new(), keysym, 0) + { + log::warn!( + "input_text_wayland: portal key-up failed for keysym {:#x}: {:?}", + keysym, + e + ); + } + continue; + } + } + // Portal unavailable or failed, fallback to uinput (down+up together) + let key = enigo::Key::Layout(c); + if let Ok((evdev_key, is_shift)) = map_key(&key) { + let mut shift_pressed = false; + if is_shift { + let shift_down = + InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1); + if keyboard.emit(&[shift_down]).is_ok() { + shift_pressed = true; + } else { + log::warn!("input_text_wayland: failed to press Shift for '{}'", c); + } + } + let key_down = InputEvent::new(EventType::KEY, evdev_key.code(), 1); + let key_up = InputEvent::new(EventType::KEY, evdev_key.code(), 0); + allow_err!(keyboard.emit(&[key_down, key_up])); + if shift_pressed { + let shift_up = + InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 0); + allow_err!(keyboard.emit(&[shift_up])); + } + } + } else { + log::debug!("Skipping non-ASCII character in uinput service (no clipboard access)"); + } + } + } + + /// Send a single key down or up event for a Layout character. + /// Used by KeyDown/KeyUp to maintain correct press/release semantics. + /// `down`: true for key press, false for key release. + fn input_char_wayland_key_event(chr: char, down: bool, keyboard: &mut VirtualDevice) { + let keysym = char_to_keysym(chr); + let portal_state: u32 = if down { 1 } else { 0 }; + + if can_input_via_keysym(chr, keysym) { + let portal_info = { + let session_info = RDP_SESSION_INFO.lock().unwrap(); + session_info + .as_ref() + .map(|info| (info.conn.clone(), info.session.clone())) + }; + if let Some((ref conn, ref session)) = portal_info { + let portal = scrap::wayland::pipewire::get_portal(conn); + if portal + .notify_keyboard_keysym(session, HashMap::new(), keysym, portal_state) + .is_ok() + { + return; + } + } + // Portal unavailable or failed, fallback to uinput + let key = enigo::Key::Layout(chr); + if let Ok((evdev_key, is_shift)) = map_key(&key) { + if down { + // Press: Shift↓ (if needed) → Key↓ + if is_shift { + let shift_down = + InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1); + if let Err(e) = keyboard.emit(&[shift_down]) { + log::warn!("input_char_wayland_key_event: failed to press Shift for '{}': {:?}", chr, e); + } + } + let key_down = InputEvent::new(EventType::KEY, evdev_key.code(), 1); + allow_err!(keyboard.emit(&[key_down])); + } else { + // Release: Key↑ → Shift↑ (if needed) + let key_up = InputEvent::new(EventType::KEY, evdev_key.code(), 0); + allow_err!(keyboard.emit(&[key_up])); + if is_shift { + let shift_up = + InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 0); + if let Err(e) = keyboard.emit(&[shift_up]) { + log::warn!("input_char_wayland_key_event: failed to release Shift for '{}': {:?}", chr, e); + } + } + } + } + } else { + // Non-ASCII: no reliable down/up semantics available. + // Clipboard paste is atomic and handled elsewhere. + log::debug!( + "Skipping non-ASCII character key {} in uinput service", + if down { "down" } else { "up" } + ); + } + } + + /// Check if character can be input via keysym (ASCII printable with valid keysym). + #[inline] + pub(crate) fn can_input_via_keysym(c: char, keysym: i32) -> bool { + // ASCII printable: 0x20 (space) to 0x7E (tilde) + (c as u32 >= 0x20 && c as u32 <= 0x7E) && keysym != 0 + } + + /// Convert a Unicode character to X11 keysym. + pub(crate) fn char_to_keysym(c: char) -> i32 { + let codepoint = c as u32; + if codepoint == 0 { + // Null character has no keysym + 0 + } else if (0x20..=0x7E).contains(&codepoint) { + // ASCII printable (0x20-0x7E): keysym == Unicode codepoint + codepoint as i32 + } else if (0xA0..=0xFF).contains(&codepoint) { + // Latin-1 supplement (0xA0-0xFF): keysym == Unicode codepoint (per X11 keysym spec) + codepoint as i32 + } else { + // Everything else (control chars 0x01-0x1F, DEL 0x7F, and all other non-ASCII Unicode): + // keysym = 0x01000000 | codepoint (X11 Unicode keysym encoding) + (0x0100_0000 | codepoint) as i32 + } + } + fn create_uinput_keyboard() -> ResultType { // TODO: ensure keys here let mut keys = AttributeSet::::new(); @@ -390,13 +552,13 @@ pub mod service { pub fn map_key(key: &enigo::Key) -> ResultType<(evdev::Key, bool)> { if let Some(k) = KEY_MAP.get(&key) { - log::trace!("mapkey {:?}, get {:?}", &key, &k); + log::trace!("mapkey matched in KEY_MAP, evdev={:?}", &k); return Ok((k.clone(), false)); } else { match key { enigo::Key::Layout(c) => { if let Some((k, is_shift)) = KEY_MAP_LAYOUT.get(&c) { - log::trace!("mapkey {:?}, get {:?}", &key, k); + log::trace!("mapkey Layout matched, evdev={:?}", k); return Ok((k.clone(), is_shift.clone())); } } @@ -421,41 +583,68 @@ pub mod service { keyboard: &mut VirtualDevice, data: &DataKeyboard, ) { - log::trace!("handle_keyboard {:?}", &data); + let data_desc = match data { + DataKeyboard::Sequence(seq) => format!("Sequence(len={})", seq.len()), + DataKeyboard::KeyDown(Key::Layout(_)) + | DataKeyboard::KeyUp(Key::Layout(_)) + | DataKeyboard::KeyClick(Key::Layout(_)) => "Layout()".to_string(), + _ => format!("{:?}", data), + }; + log::trace!("handle_keyboard received: {}", data_desc); match data { - DataKeyboard::Sequence(_seq) => { - // ignore + DataKeyboard::Sequence(seq) => { + // Normally handled by --server process (input_text_via_clipboard_server). + // Fallback: input_text_wayland handles ASCII via keysym/uinput; + // non-ASCII will be skipped (no clipboard access in --service process). + if !seq.is_empty() { + input_text_wayland(seq, keyboard); + } } DataKeyboard::KeyDown(enigo::Key::Raw(code)) => { - let down_event = InputEvent::new(EventType::KEY, *code - 8, 1); - allow_err!(keyboard.emit(&[down_event])); - } - DataKeyboard::KeyUp(enigo::Key::Raw(code)) => { - let up_event = InputEvent::new(EventType::KEY, *code - 8, 0); - allow_err!(keyboard.emit(&[up_event])); - } - DataKeyboard::KeyDown(key) => { - if let Ok((k, is_shift)) = map_key(key) { - if is_shift { - let down_event = - InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1); - allow_err!(keyboard.emit(&[down_event])); - } - let down_event = InputEvent::new(EventType::KEY, k.code(), 1); + if *code < 8 { + log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code); + } else { + let down_event = InputEvent::new(EventType::KEY, *code - 8, 1); allow_err!(keyboard.emit(&[down_event])); } } - DataKeyboard::KeyUp(key) => { - if let Ok((k, _)) = map_key(key) { - let up_event = InputEvent::new(EventType::KEY, k.code(), 0); + DataKeyboard::KeyUp(enigo::Key::Raw(code)) => { + if *code < 8 { + log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code); + } else { + let up_event = InputEvent::new(EventType::KEY, *code - 8, 0); allow_err!(keyboard.emit(&[up_event])); } } + DataKeyboard::KeyDown(key) => { + if let Key::Layout(chr) = key { + input_char_wayland_key_event(*chr, true, keyboard); + } else { + if let Ok((k, _is_shift)) = map_key(key) { + let down_event = InputEvent::new(EventType::KEY, k.code(), 1); + allow_err!(keyboard.emit(&[down_event])); + } + } + } + DataKeyboard::KeyUp(key) => { + if let Key::Layout(chr) = key { + input_char_wayland_key_event(*chr, false, keyboard); + } else { + if let Ok((k, _)) = map_key(key) { + let up_event = InputEvent::new(EventType::KEY, k.code(), 0); + allow_err!(keyboard.emit(&[up_event])); + } + } + } DataKeyboard::KeyClick(key) => { - if let Ok((k, _)) = map_key(key) { - let down_event = InputEvent::new(EventType::KEY, k.code(), 1); - let up_event = InputEvent::new(EventType::KEY, k.code(), 0); - allow_err!(keyboard.emit(&[down_event, up_event])); + if let Key::Layout(chr) = key { + input_text_wayland(&chr.to_string(), keyboard); + } else { + if let Ok((k, _is_shift)) = map_key(key) { + let down_event = InputEvent::new(EventType::KEY, k.code(), 1); + let up_event = InputEvent::new(EventType::KEY, k.code(), 0); + allow_err!(keyboard.emit(&[down_event, up_event])); + } } } DataKeyboard::GetKeyState(key) => { @@ -580,9 +769,13 @@ pub mod service { } fn spawn_keyboard_handler(mut stream: Connection) { + log::debug!("spawn_keyboard_handler: new keyboard handler connection"); tokio::spawn(async move { let mut keyboard = match create_uinput_keyboard() { - Ok(keyboard) => keyboard, + Ok(keyboard) => { + log::debug!("UInput keyboard device created successfully"); + keyboard + } Err(e) => { log::error!("Failed to create keyboard {}", e); return; @@ -602,6 +795,7 @@ pub mod service { handle_keyboard(&mut stream, &mut keyboard, &data).await; } _ => { + log::warn!("Unexpected data type in keyboard handler"); } } }