fix(keyboard): wayland clipboard input prompt (#14700)

* fix(keyboard): wayland clipboard input prompt

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): Simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): clipboard input, remove unused code

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): Simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): dialog, better enableAndContinue

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): input dialog consent

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): prompt text

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): text input

1. Use `keysym` for the installed version if possible.
2. Use the clipboard if the string cannot be fully handled by `keysym`.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): input prompt dialog

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): translations

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): dialog, title type

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): better decode_utf8_prefix()

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): better process_chr()

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): unit tests

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): input prompt dialog, no icon

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): input dialog, Toast show the result

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): input dialog, showToast() on persist failed

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): input prompt, better dialog

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(wayland): input prompt dialog, translations

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(input): better wayland clipboard input prompt

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(input): wayland clipboard, link external app

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(input): trivial changes

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(input): wayland clipboard input, dialog content

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(input): tranlsations

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(input): translations

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(input): translations

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(input): translations

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-06-02 16:06:35 +08:00
committed by GitHub
parent 00032854eb
commit 3217125dd3
60 changed files with 1144 additions and 103 deletions

View File

@@ -2,7 +2,7 @@ use super::*;
#[cfg(not(target_os = "android"))]
use crate::clipboard::clipboard_listener;
#[cfg(not(target_os = "android"))]
pub use crate::clipboard::{check_clipboard, ClipboardContext, ClipboardSide};
pub use crate::clipboard::{ClipboardContext, ClipboardSide};
pub use crate::clipboard::{CLIPBOARD_INTERVAL as INTERVAL, CLIPBOARD_NAME as NAME};
#[cfg(windows)]
use crate::ipc::{self, ClipboardFile, ClipboardNonFile, Data};
@@ -109,6 +109,62 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> {
Ok(())
}
#[cfg(target_os = "linux")]
const WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES: usize =
super::input_service::WAYLAND_CLIPBOARD_INPUT_MAX_TEXT_CHARS * 4;
#[cfg(target_os = "linux")]
fn decode_utf8_prefix(bytes: &[u8]) -> Option<String> {
let end = bytes.len().min(WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES);
let slice = &bytes[..end];
match std::str::from_utf8(slice) {
Ok(text) => Some(text.to_owned()),
Err(e) => {
if e.error_len().is_some() {
return None;
}
let valid_up_to = e.valid_up_to();
std::str::from_utf8(&slice[..valid_up_to])
.ok()
.map(ToOwned::to_owned)
}
}
}
#[cfg(target_os = "linux")]
fn decode_text_clipboard(clipboard: &Clipboard) -> Option<String> {
if clipboard.format.enum_value() != Ok(ClipboardFormat::Text) {
return None;
}
if clipboard.compress {
let bytes = hbb_common::compress::decompress(&clipboard.content);
return decode_utf8_prefix(&bytes);
}
decode_utf8_prefix(&clipboard.content)
}
#[cfg(target_os = "linux")]
fn should_skip_wayland_clipboard_sync(msg: &Message) -> bool {
if crate::platform::linux::is_x11() {
return false;
}
let is_recent_wayland_input = |clipboard: &Clipboard| -> bool {
let Some(text) = decode_text_clipboard(clipboard) else {
return false;
};
super::input_service::is_recent_wayland_clipboard_input(&text)
};
match &msg.union {
Some(message::Union::Clipboard(clipboard)) => is_recent_wayland_input(clipboard),
Some(message::Union::MultiClipboards(multi_clipboards)) => multi_clipboards
.clipboards
.iter()
.any(is_recent_wayland_input),
_ => false,
}
}
#[cfg(not(target_os = "android"))]
impl Handler {
#[cfg(feature = "unix-file-copy-paste")]
@@ -172,7 +228,19 @@ impl Handler {
}
}
check_clipboard(&mut self.ctx, ClipboardSide::Host, false)
#[cfg(target_os = "linux")]
{
let msg = crate::clipboard::peek_clipboard(&mut self.ctx, ClipboardSide::Host, false)?;
if should_skip_wayland_clipboard_sync(&msg) {
log::debug!("Skip clipboard sync for recent Wayland keyboard injection");
return None;
}
return Some(msg);
}
#[cfg(not(target_os = "linux"))]
{
crate::clipboard::check_clipboard(&mut self.ctx, ClipboardSide::Host, false)
}
}
// Read clipboard data from cm using ipc.
@@ -272,3 +340,46 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> {
CLIPBOARD_SERVICE_OK.store(false, Ordering::SeqCst);
Ok(())
}
#[cfg(test)]
#[cfg(target_os = "linux")]
mod tests {
use super::{decode_utf8_prefix, WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES};
#[test]
fn decode_utf8_prefix_returns_text_for_valid_utf8() {
let text = "hello-مرحبا";
assert_eq!(decode_utf8_prefix(text.as_bytes()), Some(text.to_owned()));
}
#[test]
fn decode_utf8_prefix_returns_none_for_invalid_utf8_sequence() {
let bytes = b"ab\xffcd";
assert_eq!(decode_utf8_prefix(bytes), None);
}
#[test]
fn decode_utf8_prefix_trims_incomplete_utf8_suffix() {
let bytes = vec![b'a', 0xE4, 0xB8];
assert_eq!(decode_utf8_prefix(&bytes), Some("a".to_owned()));
}
#[test]
fn decode_utf8_prefix_applies_max_bytes_limit() {
let bytes = vec![b'a'; WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES + 8];
let result = decode_utf8_prefix(&bytes).expect("expected decoded prefix");
assert_eq!(result.len(), WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES);
}
#[test]
fn decode_utf8_prefix_keeps_utf8_boundary_when_limited() {
let mut bytes = vec![b'a'; WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES - 1];
bytes.extend_from_slice("ا".as_bytes());
let result = decode_utf8_prefix(&bytes).expect("expected decoded prefix");
assert_eq!(
result.len(),
WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES - 1
);
assert!(result.chars().all(|c| c == 'a'));
}
}

View File

@@ -457,6 +457,12 @@ lazy_static::lazy_static! {
static ref RELATIVE_MOUSE_CONNS: Arc<Mutex<std::collections::HashSet<i32>>> = Default::default();
}
#[cfg(target_os = "linux")]
lazy_static::lazy_static! {
static ref WAYLAND_CLIPBOARD_INPUT_RECORDS: Arc<Mutex<Vec<(Instant, String)>>> =
Default::default();
}
#[inline]
fn set_relative_mouse_active(conn: i32, active: bool) {
let mut lock = RELATIVE_MOUSE_CONNS.lock().unwrap();
@@ -1594,15 +1600,28 @@ fn need_to_uppercase(en: &mut Enigo) -> bool {
}
fn process_chr(en: &mut Enigo, chr: u32, down: bool, _hotkey: bool) {
// On Wayland with uinput mode, use clipboard for character input
// On Wayland with uinput mode:
// - ASCII printable: input via key events (custom keyboard path, e.g. portal keysym)
// - Non-ASCII: input via clipboard paste
#[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) {
if let Ok(c) = char::try_from(chr) {
if is_ascii_printable(c) {
if down {
en.key_down(Key::Layout(c)).ok();
} else {
en.key_up(Key::Layout(c));
}
} else if down {
input_char_via_clipboard_server(en, c);
}
} else {
log::warn!(
"Ignore invalid unicode scalar in Wayland+uinput path: {}",
chr
);
}
return;
}
@@ -1637,11 +1656,17 @@ fn process_chr(en: &mut Enigo, chr: u32, down: bool, _hotkey: bool) {
}
fn process_unicode(en: &mut Enigo, chr: u32) {
// On Wayland with uinput mode, use clipboard for character input
// On Wayland with uinput mode:
// - ASCII printable: input via key sequence (custom keyboard path)
// - Non-ASCII: input via clipboard paste
#[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);
if is_ascii_printable(c) {
en.key_sequence(&c.to_string());
} else {
input_char_via_clipboard_server(en, c);
}
}
return;
}
@@ -1652,10 +1677,16 @@ fn process_unicode(en: &mut Enigo, chr: u32) {
}
fn process_seq(en: &mut Enigo, sequence: &str) {
// On Wayland with uinput mode, use clipboard for text input
// On Wayland with uinput mode:
// - pure ASCII printable sequence: input via key sequence (custom keyboard path)
// - any non-ASCII present: input whole sequence via clipboard to preserve order
#[cfg(target_os = "linux")]
if !crate::platform::linux::is_x11() && wayland_use_uinput() {
input_text_via_clipboard_server(en, sequence);
if sequence.chars().all(is_ascii_printable) {
en.key_sequence(sequence);
} else {
input_text_via_clipboard_server(en, sequence);
}
return;
}
@@ -1668,40 +1699,103 @@ fn process_seq(en: &mut Enigo, sequence: &str) {
/// this delay may be insufficient, but there is no reliable alternative mechanism.
#[cfg(target_os = "linux")]
const CLIPBOARD_SYNC_DELAY_MS: u64 = 50;
#[cfg(target_os = "linux")]
const WAYLAND_CLIPBOARD_INPUT_FILTER_WINDOW: Duration = Duration::from_secs(1);
#[cfg(target_os = "linux")]
const WAYLAND_CLIPBOARD_INPUT_MAX_RECORDS: usize = 256;
#[cfg(target_os = "linux")]
pub(super) const WAYLAND_CLIPBOARD_INPUT_MAX_TEXT_CHARS: usize = 1024;
#[cfg(target_os = "linux")]
fn cleanup_wayland_clipboard_input_records(records: &mut Vec<(Instant, String)>, now: Instant) {
records.retain(|(created_at, _)| {
now.saturating_duration_since(*created_at) <= WAYLAND_CLIPBOARD_INPUT_FILTER_WINDOW
});
let len = records.len();
if len > WAYLAND_CLIPBOARD_INPUT_MAX_RECORDS {
records.drain(0..(len - WAYLAND_CLIPBOARD_INPUT_MAX_RECORDS));
}
}
#[cfg(target_os = "linux")]
#[inline]
fn normalize_wayland_clipboard_input_text(text: &str) -> String {
text.chars()
.take(WAYLAND_CLIPBOARD_INPUT_MAX_TEXT_CHARS)
.collect()
}
#[cfg(target_os = "linux")]
#[inline]
fn get_wayland_clipboard_input_normalized_text(text: &str) -> Option<String> {
let normalized = normalize_wayland_clipboard_input_text(text);
if normalized.is_empty() {
return None;
}
Some(normalized)
}
#[cfg(target_os = "linux")]
#[inline]
fn record_wayland_clipboard_input_for_sync_filter(text: &str) -> Option<(Instant, String)> {
if text.is_empty() || crate::platform::linux::is_x11() {
return None;
}
let normalized = get_wayland_clipboard_input_normalized_text(text)?;
let now = Instant::now();
let mut records = WAYLAND_CLIPBOARD_INPUT_RECORDS.lock().unwrap();
cleanup_wayland_clipboard_input_records(&mut records, now);
records.push((now, normalized.clone()));
Some((now, normalized))
}
#[cfg(target_os = "linux")]
#[inline]
fn rollback_wayland_clipboard_input_record(record: (Instant, String)) {
let (created_at, normalized) = record;
let now = Instant::now();
let mut records = WAYLAND_CLIPBOARD_INPUT_RECORDS.lock().unwrap();
cleanup_wayland_clipboard_input_records(&mut records, now);
if let Some(pos) = records
.iter()
.rposition(|(record_created_at, record_normalized)| {
*record_created_at == created_at && *record_normalized == normalized
})
{
records.remove(pos);
}
}
#[cfg(target_os = "linux")]
pub(super) fn is_recent_wayland_clipboard_input(text: &str) -> bool {
if text.is_empty() || crate::platform::linux::is_x11() {
return false;
}
let Some(normalized) = get_wayland_clipboard_input_normalized_text(text) else {
return false;
};
let now = Instant::now();
let mut records = WAYLAND_CLIPBOARD_INPUT_RECORDS.lock().unwrap();
cleanup_wayland_clipboard_input_records(&mut records, now);
records
.iter()
.any(|(_, record_normalized)| record_normalized == &normalized)
}
/// 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);
if let Err(e) = crate::clipboard::set_text_clipboard_with_owner_sync(
text,
crate::clipboard::ClipboardSide::Host,
) {
log::error!(
"set_clipboard_content: failed to set clipboard with owner marker: {:?}",
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
}
@@ -1714,7 +1808,11 @@ fn set_clipboard_content(text: &str) -> bool {
#[cfg(target_os = "linux")]
#[inline]
pub(super) fn set_clipboard_for_paste_sync(text: &str) -> bool {
let record = record_wayland_clipboard_input_for_sync_filter(text);
if !set_clipboard_content(text) {
if let Some(record) = record {
rollback_wayland_clipboard_input_record(record);
}
return false;
}
std::thread::sleep(std::time::Duration::from_millis(CLIPBOARD_SYNC_DELAY_MS));
@@ -1916,49 +2014,53 @@ 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.
// On Wayland:
// - uinput mode (--service): keep clipboard handling in this process because
// clipboard is unreliable in root service context.
// - rdp_input mode (--server): forward sequence to custom keyboard handler so
// ASCII can use Portal keysym and non-ASCII can use clipboard.
#[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));
}
if wayland_use_rdp_input() {
release_shift_for_char_input(&mut en);
en.key_sequence(seq);
return;
}
// Normal text input: release Shift and use clipboard
release_shift_for_char_input(&mut en);
if wayland_use_uinput() {
// 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;
}
input_text_via_clipboard_server(&mut en, seq);
return;
// Normal text input: release Shift and use clipboard
release_shift_for_char_input(&mut en);
if seq.chars().all(is_ascii_printable) {
en.key_sequence(seq);
} else {
input_text_via_clipboard_server(&mut en, seq);
}
return;
}
}
// Fr -> US

View File

@@ -118,6 +118,23 @@ pub mod client {
}
fn key_sequence(&mut self, s: &str) {
if s.is_empty() {
return;
}
// Keep ordering deterministic:
// - pure ASCII printable: send via Portal keysym
// - any non-ASCII present (including mixed ASCII/non-ASCII): send whole
// sequence via clipboard as one atomic paste
let ascii_only = s.chars().all(|c| {
let keysym = char_to_keysym(c);
can_input_via_keysym(c, keysym)
});
if !ascii_only {
input_text_via_clipboard(s, self.conn.clone(), &self.session);
return;
}
for c in s.chars() {
let keysym = char_to_keysym(c);
// ASCII characters: use keysym
@@ -128,9 +145,6 @@ pub mod client {
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);
}
}
}
@@ -167,8 +181,7 @@ pub mod client {
// 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)
{
if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) {
log::error!("Failed to send keysym up: {:?}", e);
}
}