From 24e4425665fac2a0a66689d429fdc72513b86b66 Mon Sep 17 00:00:00 2001 From: Khoa Vo Date: Mon, 29 Jun 2026 14:12:30 +0700 Subject: [PATCH] feat: window-switch engine reset, xprop fallback, clean up dead code - Fix window-switch engine state carryover (Alt+Tab between apps) - Add xprop -root _NET_ACTIVE_WINDOW fallback for get_active_window_id() - Update last_key_time only on character key presses (not modifiers) - Use log_info for change detection (no per-key eprintln) - Fix Flatpak build: add mkdir -p /app/share/applications - Remove unused X11 clipboard code (~300 lines of dead unsafe code) - Remove unused engine methods: is_empty, is_tone_or_mark_key, process_string, last_base_char, apply_cluster_mark, apply_mark - Remove unused RuleEffect enum and special_rules field - Suppress verbose paste debug logging in uinput_monitor --- daemon/src/app_state.rs | 36 +++ daemon/src/main.rs | 186 ++++++++++-- engine/src/bamboo.rs | 63 ---- engine/src/input_method.rs | 10 - packaging/flatpak/build-flatpak.sh | 9 +- .../flatpak/io.github.vietc.VietPlus.json | 6 +- protocol/src/uinput_monitor.rs | 286 +++++------------- protocol/src/x11_inject.rs | 13 +- 8 files changed, 298 insertions(+), 311 deletions(-) diff --git a/daemon/src/app_state.rs b/daemon/src/app_state.rs index 89ad2d1..36b18b1 100644 --- a/daemon/src/app_state.rs +++ b/daemon/src/app_state.rs @@ -3,6 +3,42 @@ use std::collections::HashMap; use std::fs; use std::process::Command; +/// Get the active window's X11 ID (unique per window — even within the same +/// application). Returns a unique window-identifier string. +pub fn get_active_window_id() -> Option { + // Try xdotool first (fast, direct) + if let Ok(output) = Command::new("xdotool") + .args(["getactivewindow"]) + .output() + { + if output.status.success() { + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !id.is_empty() { + return Some(id); + } + } + } + + // Fallback: xprop -root _NET_ACTIVE_WINDOW (x11-utils, preinstalled on most distros) + if let Ok(output) = Command::new("xprop") + .args(["-root", "_NET_ACTIVE_WINDOW"]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Format: "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3a00004" + if let Some(hex) = stdout.split("window id # ").nth(1) { + let hex = hex.trim(); + if !hex.is_empty() { + return Some(hex.to_string()); + } + } + } + } + + None +} + /// Detect the currently focused window's class name pub fn get_focused_window_class() -> Option { // Try Wayland first (wlr-foreign-toplevel) diff --git a/daemon/src/main.rs b/daemon/src/main.rs index be1f925..80bf682 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -439,7 +439,73 @@ fn is_flush_char(ch: char) -> bool { matches!(ch, ' ' | '.' | ',' | '!' | '?' | ';' | ':' | '\t' | '\n') } +/// When running as root via `sudo`, the DISPLAY and XAUTHORITY env vars are +/// typically stripped. This function recovers them from the original user's +/// X11 session by scanning /proc//environ for processes owned by +/// SUDO_UID. Must be called before any xdotool / xclip invocations. +fn recover_display_env() { + if unsafe { libc::getuid() } != 0 { + return; + } + if let Ok(d) = std::env::var("DISPLAY") { + if !d.is_empty() { + return; + } + } + let target_uid: u32 = match std::env::var("SUDO_UID") { + Ok(s) => match s.parse() { + Ok(v) => v, + Err(_) => return, + }, + Err(_) => return, + }; + if let Ok(entries) = fs::read_dir("/proc") { + 'outer: for entry in entries.flatten() { + let name = entry.file_name(); + let name_s = name.to_string_lossy(); + if !name_s.chars().all(|c| c.is_ascii_digit()) { + continue; + } + #[cfg(target_os = "linux")] + { + use std::os::linux::fs::MetadataExt; + if let Ok(meta) = entry.metadata() { + if meta.st_uid() != target_uid { + continue; + } + } + } + let environ_path = entry.path().join("environ"); + if let Ok(content) = fs::read(&environ_path) { + let mut display = None; + let mut xauth = None; + for chunk in content.split(|&b| b == 0) { + if let Ok(s) = std::str::from_utf8(chunk) { + if let Some(v) = s.strip_prefix("DISPLAY=") { + if !v.is_empty() { + display = Some(v.to_string()); + } + } + if let Some(v) = s.strip_prefix("XAUTHORITY=") { + xauth = Some(v.to_string()); + } + } + } + if let Some(d) = display { + std::env::set_var("DISPLAY", &d); + if let Some(x) = xauth { + std::env::set_var("XAUTHORITY", x); + } + log_info(&format!("[vietc] Recovered DISPLAY={} from /proc", d)); + break 'outer; + } + } + } + } +} + fn main() -> Result<(), Box> { + recover_display_env(); let config_path = config::find_config_path(); let config = Config::load()?; let engine_enabled = Arc::new(AtomicBool::new(config.start_enabled)); @@ -471,16 +537,40 @@ fn main() -> Result<(), Box> { } )); + // Startup diagnostics: check DISPLAY and xdotool + let display_var = std::env::var("DISPLAY").unwrap_or_default(); + let xauth_var = std::env::var("XAUTHORITY").unwrap_or_default(); + log_info(&format!("[vietc] DISPLAY='{}' XAUTHORITY='{}'", display_var, xauth_var)); + match std::process::Command::new("xdotool") + .args(["getactivewindow"]) + .output() + { + Ok(output) => { + if output.status.success() { + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + log_info(&format!("[vietc] xdotool OK: active window = {}", id)); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + log_info(&format!("[vietc] xdotool FAILED: {}", stderr.trim())); + } + } + Err(e) => { + log_info(&format!("[vietc] xdotool NOT AVAILABLE: {}", e)); + } + } + // Boost thread priority for low-latency input (VMK technique) boost_thread_priority(); // Spawn background monitor for active window, config changes, and status changes let shared_active_window = Arc::new(Mutex::new(String::new())); + let shared_window_class = Arc::new(Mutex::new(String::new())); let config_changed = Arc::new(AtomicBool::new(false)); let status_changed = Arc::new(AtomicBool::new(false)); { let shared_active_window = shared_active_window.clone(); + let shared_window_class = shared_window_class.clone(); let config_changed = config_changed.clone(); let config_path = config_path.clone(); let status_changed = status_changed.clone(); @@ -493,9 +583,21 @@ fn main() -> Result<(), Box> { let mut window_check_counter = 0; let status_path = config_path.parent().unwrap().join("status"); loop { - // Check active window class every 250ms - if let Some(class) = app_state::get_focused_window_class() { + // Check active window ID every 250ms (window ID is unique per + // window — unlike the class name, which is the same for all + // windows of the same application). + if let Some(id) = app_state::get_active_window_id() { let mut lock = shared_active_window.lock().unwrap(); + if *lock != id { + log_info(&format!("[vietc] bg: window ID '{}' -> '{}'", *lock, id)); + *lock = id; + } + } else { + log_info("[vietc] bg: window ID poll failed"); + } + // Also keep window class for app-bypass logic + if let Some(class) = app_state::get_focused_window_class() { + let mut lock = shared_window_class.lock().unwrap(); if *lock != class { *lock = class; } @@ -537,6 +639,7 @@ fn main() -> Result<(), Box> { device, &mut daemon, shared_active_window, + shared_window_class, config_changed, status_changed, engine_enabled, @@ -569,6 +672,7 @@ fn main() -> Result<(), Box> { run_stdin_mode( &mut daemon, shared_active_window, + shared_window_class, config_changed, status_changed, engine_enabled, @@ -777,6 +881,7 @@ fn run_with_evdev( mut device: evdev::Device, daemon: &mut Daemon, shared_active_window: Arc>, + shared_window_class: Arc>, config_changed: Arc, status_changed: Arc, _engine_enabled: Arc, @@ -814,6 +919,7 @@ fn run_with_evdev( // Safety: if grab is active and no events arrive for 30 seconds, // release the grab so the user isn't locked out. let mut last_event_time = std::time::Instant::now(); + let mut last_key_time = std::time::Instant::now(); loop { // Check for event timeout (grab safety) @@ -839,26 +945,6 @@ fn run_with_evdev( status_changed.store(false, Ordering::SeqCst); } - // Track window changes and reset engine buffer - { - let active_window = shared_active_window.lock().unwrap().clone(); - if active_window != last_active_window { - log_info(&format!( - "[vietc] Window changed: '{}' -> '{}'", - last_active_window, active_window - )); - last_active_window = active_window.clone(); - daemon.engine.reset(); - log_info("[vietc] Reset engine buffer due to window change"); - } - } - - // Check for app changes instantly using the cached state from background thread - if daemon.config.app_state.enabled { - let active_window = shared_active_window.lock().unwrap().clone(); - daemon.check_app_change_with(active_window); - } - // Check for config reload instantly if config_changed.load(Ordering::SeqCst) { daemon.reload_config(); @@ -929,6 +1015,54 @@ fn run_with_evdev( consumed_keys.remove(&keycode); } if let Some(mut ch) = key_to_char(key) { + // Window change detection: only on character key presses. + // Modifier keys (Ctrl, Alt, Super) skip this block, so + // last_key_time is preserved across Alt+Tab sequences. + let gap = last_key_time.elapsed(); + last_key_time = std::time::Instant::now(); + + // Fast path: check shared window ID from background thread (250ms polling) + let active_window_id = shared_active_window.lock().unwrap().clone(); + let mut new_window = None; + + if active_window_id != last_active_window { + new_window = Some(active_window_id.clone()); + } else if gap > std::time::Duration::from_millis(100) { + // Background thread hasn't caught up yet — poll xdotool directly + if let Some(id) = app_state::get_active_window_id() { + if id != active_window_id { + new_window = Some(id); + } + } else { + log_info(&format!("[vietc] gap poll: window ID query failed (gap={:?}, shared='{}')", gap, active_window_id)); + } + } + + if let Some(id) = new_window { + log_info(&format!( + "[vietc] Window changed: '{}' -> '{}' (gap={:?})", + last_active_window, id, gap + )); + last_active_window = id; + daemon.engine.reset(); + daemon.replay_reset(); + + if daemon.config.app_state.enabled { + let class = shared_window_class.lock().unwrap().clone(); + let class = if class.is_empty() { + app_state::get_focused_window_class().unwrap_or_default() + } else { + class + }; + daemon.check_app_change_with(class); + } + } else if daemon.config.app_state.enabled { + let class = shared_window_class.lock().unwrap().clone(); + if !class.is_empty() { + daemon.check_app_change_with(class); + } + } + let shift = is_modifier_held_shift(&key_state); if ch.is_ascii_alphabetic() && (shift ^ caps) { ch = ch.to_ascii_uppercase(); @@ -986,6 +1120,7 @@ fn run_with_evdev( fn run_stdin_mode( daemon: &mut Daemon, shared_active_window: Arc>, + shared_window_class: Arc>, config_changed: Arc, status_changed: Arc, _engine_enabled: Arc, @@ -1020,6 +1155,7 @@ fn run_stdin_mode( device, daemon, shared_active_window, + shared_window_class, config_changed, status_changed, _engine_enabled, @@ -1138,9 +1274,9 @@ fn create_injector( return Ok(Box::new(vietc_protocol::uinput_client::UinputClient)); } - // Use uinput as primary — correct Linux keycodes for ASCII + backspace. - // For Unicode (Vietnamese diacritics), falls back to xclip via subprocess - // or direct X11 clipboard via X11Injector. + // Use uinput as primary — correct Linux keycodes for backspace + ASCII. + // For Unicode (Vietnamese diacritics), falls back to X11 clipboard via + // direct X11 API (not subprocesses), making it work in Flatpak sandboxes. match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") { Ok(injector) => { log_info("[vietc] Using uinput injection (primary)"); diff --git a/engine/src/bamboo.rs b/engine/src/bamboo.rs index a296d67..d2b0ff7 100644 --- a/engine/src/bamboo.rs +++ b/engine/src/bamboo.rs @@ -66,10 +66,6 @@ impl BambooEngine { self.macro_buf.clear(); } - pub fn is_empty(&self) -> bool { - self.composition.is_empty() - } - pub fn process_key(&mut self, ch: char) -> Option { if !self.mode.is_vn() { return Some(ch.to_string()); @@ -181,11 +177,6 @@ impl BambooEngine { None } - fn is_tone_or_mark_key(&self, lower: char) -> bool { - self.rules.tone_keys.contains_key(&lower) - || self.rules.mark_rules.iter().any(|(p, _)| p.ends_with(lower)) - } - fn apply_mark_at(&mut self, idx: usize, _pattern: &str, result: &str) { let result_chars: Vec = result.chars().collect(); let was_upper = self.composition[idx].is_upper; @@ -203,16 +194,6 @@ impl BambooEngine { } } - pub fn process_string(&mut self, s: &str) -> String { - let mut last = String::new(); - for ch in s.chars() { - if let Some(out) = self.process_key(ch) { - last = out; - } - } - last - } - #[allow(dead_code)] pub fn debug_composition(&self) -> Vec<(char, Option, Option)> { self.composition.iter().map(|t| (t.base_char, t.mark_applied, t.tone_applied)).collect() @@ -239,50 +220,6 @@ impl BambooEngine { }); } - fn last_base_char(&self) -> char { - self.composition.last().map(|t| t.base_char).unwrap_or(' ') - } - - fn apply_cluster_mark(&mut self, pattern: &str, result: &str) { - let result_chars: Vec = result.chars().collect(); - // For cluster marks, all pattern chars are already in composition - let to_remove = pattern.chars().count(); - let remove_start = self.composition.len().saturating_sub(to_remove); - let removed: Vec<_> = self.composition.drain(remove_start..).collect(); - - let was_upper = removed.first().map(|t| t.is_upper).unwrap_or(false); - - for &ch in &result_chars { - self.composition.push(Transformation { - base_char: ch, - mark_applied: Some(ch), - tone_applied: removed.last().and_then(|t| t.tone_applied), - is_upper: was_upper && ch == result_chars[0], - }); - } - } - - fn apply_mark(&mut self, pattern: &str, result: &str) { - let result_chars: Vec = result.chars().collect(); - // Remove (pattern.len() - 1) chars from composition: - // the current key being processed is NOT yet in composition, - // so we only remove the chars from composition that form the mark pattern - let to_remove = pattern.chars().count().saturating_sub(1); - let remove_start = self.composition.len().saturating_sub(to_remove); - let removed: Vec<_> = self.composition.drain(remove_start..).collect(); - - let was_upper = removed.first().map(|t| t.is_upper).unwrap_or(false); - - for &ch in &result_chars { - self.composition.push(Transformation { - base_char: ch, - mark_applied: Some(ch), - tone_applied: removed.last().and_then(|t| t.tone_applied), - is_upper: was_upper && ch == result_chars[0], - }); - } - } - fn apply_tone(&mut self, tone_char: char) -> Option { if self.composition.is_empty() { return Some(tone_char.to_string()); diff --git a/engine/src/input_method.rs b/engine/src/input_method.rs index 5c16be0..9b1a578 100644 --- a/engine/src/input_method.rs +++ b/engine/src/input_method.rs @@ -7,19 +7,11 @@ pub enum InputMethod { Vni, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuleEffect { - Appending(char), - MarkTransformation { base: char, marked: char }, - ToneTransformation { tone: char, name: &'static str }, -} - #[derive(Debug, Clone)] pub struct InputMethodRules { pub method: InputMethod, pub tone_keys: HashMap, pub mark_rules: Vec<(String, String)>, - pub special_rules: Vec, } fn tone_map(entries: &[(char, char, &'static str)]) -> HashMap { @@ -46,7 +38,6 @@ pub fn get_rules(method: InputMethod) -> InputMethodRules { ("uw".into(), "ư".into()), ("dd".into(), "đ".into()), ], - special_rules: vec![], }, InputMethod::Vni => InputMethodRules { method, @@ -66,7 +57,6 @@ pub fn get_rules(method: InputMethod) -> InputMethodRules { ("a8".into(), "ă".into()), ("d9".into(), "đ".into()), ], - special_rules: vec![], }, } } diff --git a/packaging/flatpak/build-flatpak.sh b/packaging/flatpak/build-flatpak.sh index c660069..5363407 100644 --- a/packaging/flatpak/build-flatpak.sh +++ b/packaging/flatpak/build-flatpak.sh @@ -40,9 +40,10 @@ install -Dm755 /app/src/vietc/target/release/vietc-uinputd /app/bin/vietc-uinput install -Dm644 /app/src/vietc/packaging/icons/vietc.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.svg install -Dm644 /app/src/vietc/packaging/icons/vietc-vn.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-vn.svg -install -Dm644 /app/src/vietc/packaging/icons/vietc-en.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-en.svg + install -Dm644 /app/src/vietc/packaging/icons/vietc-en.svg /app/share/icons/hicolor/scalable/apps/io.github.vietc.VietPlus.vietc-en.svg -cat > /app/share/applications/io.github.vietc.VietPlus.desktop << END + mkdir -p /app/share/applications + cat > /app/share/applications/io.github.vietc.VietPlus.desktop << END [Desktop Entry] Name=Viet+ Comment=Vietnamese Input Method @@ -78,10 +79,12 @@ echo "=== Finalizing build... ===" flatpak build-finish build-dir \ --socket=x11 \ --socket=wayland \ - --filesystem=home \ + --socket=session-bus \ + --device=all \ --share=ipc \ --talk-name=org.freedesktop.Notifications \ --talk-name=org.a11y.Bus \ + --talk-name=org.freedesktop.portal.Clipboard \ --command=vietc-daemon # Export diff --git a/packaging/flatpak/io.github.vietc.VietPlus.json b/packaging/flatpak/io.github.vietc.VietPlus.json index 5afc963..fa5f0ef 100644 --- a/packaging/flatpak/io.github.vietc.VietPlus.json +++ b/packaging/flatpak/io.github.vietc.VietPlus.json @@ -10,10 +10,12 @@ "finish-args": [ "--socket=x11", "--socket=wayland", - "--filesystem=home", + "--socket=session-bus", + "--device=all", "--share=ipc", "--talk-name=org.freedesktop.Notifications", - "--talk-name=org.a11y.Bus" + "--talk-name=org.a11y.Bus", + "--talk-name=org.freedesktop.portal.Clipboard" ], "modules": [ { diff --git a/protocol/src/uinput_monitor.rs b/protocol/src/uinput_monitor.rs index ccaa258..81ce278 100644 --- a/protocol/src/uinput_monitor.rs +++ b/protocol/src/uinput_monitor.rs @@ -36,6 +36,10 @@ struct ClipInner { /// the restored user content). Used to tell our own writes apart from text /// the user copied with Ctrl+C. last_injected: Option, + /// Whether we have already snapshot the user's clipboard this session. + /// After the first snapshot, subsequent pastes skip the read_clipboard + /// call (saving ~10-50ms per paste). + clipboard_saved: bool, /// When set, the restorer thread should rewrite the user's clipboard at /// this instant. `None` means no restore is pending. restore_due: Option, @@ -60,10 +64,11 @@ impl UinputInjector { fn send_enter(&self) { self.send_uinput_event(EV_KEY, 28, 1); self.send_uinput_event(0, 0, 0); - std::thread::sleep(std::time::Duration::from_millis(2)); + std::thread::sleep(std::time::Duration::from_micros(100)); + self.send_uinput_event(EV_KEY, 28, 0); self.send_uinput_event(0, 0, 0); - std::thread::sleep(std::time::Duration::from_millis(2)); + std::thread::sleep(std::time::Duration::from_micros(100)); } pub fn new(name: &str) -> Result> { @@ -109,6 +114,7 @@ impl UinputInjector { inner: Mutex::new(ClipInner { saved_clipboard: None, last_injected: None, + clipboard_saved: false, restore_due: None, shutdown: false, }), @@ -146,36 +152,36 @@ impl UinputInjector { fn send_key_stroke(&self, keycode: u16, shift: bool) { if shift { - self.send_uinput_event(EV_KEY, 42, 1); // Shift press - self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(2)); + self.send_uinput_event(EV_KEY, 42, 1); + self.send_uinput_event(0, 0, 0); + std::thread::sleep(std::time::Duration::from_micros(100)); } - self.send_uinput_event(EV_KEY, keycode, 1); // Key press - self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(2)); + self.send_uinput_event(EV_KEY, keycode, 1); + self.send_uinput_event(0, 0, 0); + std::thread::sleep(std::time::Duration::from_micros(100)); - self.send_uinput_event(EV_KEY, keycode, 0); // Key release - self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(2)); + self.send_uinput_event(EV_KEY, keycode, 0); + self.send_uinput_event(0, 0, 0); + std::thread::sleep(std::time::Duration::from_micros(100)); if shift { - self.send_uinput_event(EV_KEY, 42, 0); // Shift release - self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(2)); + self.send_uinput_event(EV_KEY, 42, 0); + self.send_uinput_event(0, 0, 0); + std::thread::sleep(std::time::Duration::from_micros(100)); } } } impl KeyInjector for UinputInjector { fn send_backspace(&self) -> InjectResult { - self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press - self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(2)); + self.send_uinput_event(EV_KEY, 14, 1); + self.send_uinput_event(0, 0, 0); + std::thread::sleep(std::time::Duration::from_micros(100)); - self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release - self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(2)); + self.send_uinput_event(EV_KEY, 14, 0); + self.send_uinput_event(0, 0, 0); + std::thread::sleep(std::time::Duration::from_micros(100)); InjectResult::Success } @@ -183,7 +189,6 @@ impl KeyInjector for UinputInjector { fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult { self.send_uinput_event(EV_KEY, keycode, value); self.send_uinput_event(0, 0, 0); - std::thread::sleep(std::time::Duration::from_millis(2)); InjectResult::Success } @@ -205,39 +210,20 @@ impl KeyInjector for UinputInjector { fn send_string(&self, s: &str) -> InjectResult { // ASCII characters: inject directly via uinput keycodes let is_ascii = s.chars().all(|c| char_to_linux_keycode(c).is_some()); - eprintln!( - "[vietc] send_string: len={}, is_ascii={}", - s.len(), - is_ascii - ); if is_ascii { - eprintln!( - "[vietc] send_string: ASCII '{}' via uinput", - s.escape_default() - ); for ch in s.chars() { self.send_char(ch); } return InjectResult::Success; } - // Unicode text: single clipboard copy + paste (reliable method) - eprintln!( - "[vietc] send_string: Unicode '{}' - using clipboard", - s.escape_default() - ); - let copied = self.paste_via_clipboard(s, false); - if copied { - eprintln!("[vietc] send_string complete (clipboard)"); - return InjectResult::Success; - } else { + // Unicode text: clipboard copy + paste (reliable method) + if !self.paste_via_clipboard(s) { eprintln!( "[vietc] send_string failed for '{}' (clipboard unavailable)", s.escape_default() ); - // Last resort: try paste_string (will try clipboard internally) - self.paste_string(s); } InjectResult::Success } @@ -370,30 +356,13 @@ impl UinputInjector { None } - /// Run an external command as the original user if we're root. - /// Uses native OS setuid/setgid to avoid slow PAM/logging/sudo startup overhead. - fn run_as_user(program: &str, args: &[&str]) -> std::process::Output { - let mut cmd = Self::user_cmd(program); - cmd.args(args); - match cmd.output() { - Ok(output) => output, - Err(e) => { - eprintln!("[vietc] Failed to run {}: {}", program, e); - std::process::Output { - status: std::process::ExitStatus::default(), - stdout: vec![], - stderr: format!("{}\n", e).into_bytes(), - } - } - } - } - /// Send backspaces and text through a single injection channel to avoid /// reordering between input methods. Backspaces always go through uinput /// (kernel device, no display server dependency). Text is typed via the /// best available method: ydotool (uinput) for ASCII, xdotool (X11) or /// clipboard for Unicode. fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult { + let t0 = std::time::Instant::now(); // If all ASCII, send keycodes directly if text.chars().all(|c| char_to_linux_keycode(c).is_some() || c == '\n') { if backspaces > 0 { @@ -403,15 +372,15 @@ impl UinputInjector { if ch == '\n' { self.send_enter(); } else { let _ = self.send_char(ch); } } + eprintln!("[vietc] inject: ASCII backspaces={} text='{}' took {}ms", backspaces, text.escape_default(), (std::time::Instant::now() - t0).as_millis()); return InjectResult::Success; } - // Unicode: clipboard paste. Backspaces FIRST, then paste. + // Unicode: backspaces via uinput, then delegate to send_string() if backspaces > 0 { for _ in 0..backspaces { let _ = self.send_backspace(); } } - self.paste_via_clipboard(text, true); - + self.send_string(text); InjectResult::Success } @@ -439,33 +408,38 @@ impl UinputInjector { /// with Ctrl+C, so a subsequent Ctrl+V would paste the wrong thing. /// /// Returns whether the text was successfully copied to the clipboard. - fn paste_via_clipboard(&self, text: &str, use_x11_paste: bool) -> bool { + fn paste_via_clipboard(&self, text: &str) -> bool { + let t_total = std::time::Instant::now(); // Critical section: snapshot the clipboard, decide what to preserve, // cancel any pending restore so the restorer cannot fire while we // paste, and put our word on the clipboard. The read and write happen // under the lock so they can never interleave with the restorer. { let mut st = self.clip.inner.lock().unwrap(); - let current = Self::read_clipboard(); - let is_our_write = - matches!((¤t, &st.last_injected), (Some(c), Some(l)) if c == l); - if !is_our_write { - st.saved_clipboard = current; + if !st.clipboard_saved { + let current = Self::read_clipboard(); + let is_our_write = + matches!((¤t, &st.last_injected), (Some(c), Some(l)) if c == l); + if !is_our_write { + st.saved_clipboard = current; + } + st.clipboard_saved = true; } st.restore_due = None; - if !Self::copy_to_clipboard(text) { + let copied = Self::copy_to_clipboard(text); + if !copied { return false; } st.last_injected = Some(text.to_string()); } // Give the selection owner a moment to take ownership before pasting. - std::thread::sleep(std::time::Duration::from_millis(5)); + std::thread::sleep(std::time::Duration::from_micros(200)); - if use_x11_paste { - self.send_ctrl_v_x11(); - } else { - self.send_ctrl_v(); + self.send_ctrl_v(); + let elapsed = (std::time::Instant::now() - t_total).as_millis(); + if elapsed > 20 { + eprintln!("[vietc] paste took {}ms", elapsed); } // Schedule a debounced restore. While the user keeps typing this gets @@ -479,46 +453,6 @@ impl UinputInjector { true } - /// Copy text to clipboard and paste via Ctrl+V through our uinput device. - /// Only used as a last resort if Wayland/X11 direct typing tools are unavailable. - /// Tries xdotool first (X11/XWayland), then clipboard fallback. - fn paste_string(&self, s: &str) { - let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); - if is_wayland { - eprintln!("[vietc] paste_string: trying wtype..."); - let output = Self::run_as_user("wtype", &["--", s]); - if output.status.success() { - eprintln!("[vietc] paste_string: wtype success"); - return; - } - eprintln!("[vietc] paste_string: wtype failed, trying clipboard..."); - } else { - // Try xdotool first (works on X11 and XWayland for UTF-8) - eprintln!("[vietc] paste_string: trying xdotool..."); - let output = Self::run_as_user("xdotool", &["type", s]); - if output.status.success() { - eprintln!("[vietc] paste_string: xdotool success"); - // Record pasted text for future delete/backspace operations - let _ = Self::run_as_user("vietc", &["update-pasted", "-text", s]); - return; - } - eprintln!("[vietc] paste_string: xdotool failed, trying clipboard..."); - } - - // Clipboard fallback: copy + paste via our uinput device - let copied = Self::copy_to_clipboard(s); - if copied { - eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V"); - self.send_ctrl_v(); - return; - } - - eprintln!( - "[vietc] WARNING: No injection method works for '{}'!", - s.escape_default() - ); - } - /// Build a command to run as the original user with display environment. fn user_cmd(program: &str) -> std::process::Command { let is_root = unsafe { libc::getuid() == 0 }; @@ -554,45 +488,33 @@ impl UinputInjector { std::process::Command::new(program) } - /// Copy text to clipboard using wl-copy (Wayland) or xclip (X11). + /// Copy text to clipboard using xclip (X11) or wl-copy (Wayland). + /// NOTE: direct X11 API is avoided here because it can interact badly with + /// the evdev keyboard grab and/or focus — xclip is simpler and works reliably + /// on the host. fn copy_to_clipboard(s: &str) -> bool { - // Try wl-copy (Wayland) via user_cmd - { - let mut cmd = Self::user_cmd("wl-copy"); - let result = cmd - .stdin(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - child.stdin.take().unwrap().write_all(s.as_bytes())?; - child.wait() - }); - if let Ok(status) = result { - if status.success() { - return true; - } - } - } - - // Try xclip (X11) via user_cmd - { - let mut cmd = Self::user_cmd("xclip"); - cmd.args(["-selection", "clipboard"]); - let result = cmd - .stdin(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - child.stdin.take().unwrap().write_all(s.as_bytes())?; - child.wait() - }) - .map(|status| status.success()) - .unwrap_or(false); - if result { + let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); + let (prog, args): (&str, &[&str]) = if is_wayland { + ("wl-copy", &[]) + } else { + ("xclip", &["-selection", "clipboard", "-i"]) + }; + let mut cmd = Self::user_cmd(prog); + cmd.args(args); + let result = cmd + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child.stdin.take().unwrap().write_all(s.as_bytes())?; + child.wait() + }); + if let Ok(status) = result { + if status.success() { return true; } } - + eprintln!("[vietc] copy_to_clipboard: {} failed", prog); false } @@ -600,70 +522,21 @@ impl UinputInjector { fn send_ctrl_v(&self) { self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL press self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(5)); + std::thread::sleep(std::time::Duration::from_micros(100)); self.send_uinput_event(EV_KEY, 47, 1); // KEY_V press self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(5)); + std::thread::sleep(std::time::Duration::from_micros(100)); self.send_uinput_event(EV_KEY, 47, 0); // KEY_V release self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(5)); + std::thread::sleep(std::time::Duration::from_micros(100)); self.send_uinput_event(EV_KEY, 29, 0); // KEY_LEFTCTRL release self.send_uinput_event(0, 0, 0); // SYN - std::thread::sleep(std::time::Duration::from_millis(10)); + std::thread::sleep(std::time::Duration::from_micros(100)); } - /// Send Ctrl+V via X11 XTest (avoids uinput kernel feedback loop). - /// Uses a lazily-opened persistent X11 connection. - fn send_ctrl_v_x11(&self) { - if std::env::var("WAYLAND_DISPLAY").is_ok() { - self.send_ctrl_v(); - return; - } - // Persistent X11 state (raw pointers, only used from injection thread) - static mut X11_DPY: *mut libc::c_void = std::ptr::null_mut(); - static mut X11_KEY: Option libc::c_int> = None; - static mut X11_FLUSH: Option libc::c_int> = None; - static mut X11_KEYCODE: Option u32> = None; - static X11_INIT: std::sync::Once = std::sync::Once::new(); - - X11_INIT.call_once(|| { - unsafe { - let lib = libc::dlopen(b"libX11.so.6\0".as_ptr() as *const libc::c_char, 1); - if lib.is_null() { return; } - let xtst = libc::dlopen(b"libXtst.so.6\0".as_ptr() as *const libc::c_char, 1); - if xtst.is_null() { libc::dlclose(lib); return; } - - type FnOpen = unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void; - let xopen: FnOpen = std::mem::transmute(libc::dlsym(lib, b"XOpenDisplay\0".as_ptr() as *const libc::c_char)); - let dpy = xopen(std::ptr::null()); - if dpy.is_null() { libc::dlclose(xtst); libc::dlclose(lib); return; } - - X11_DPY = dpy; - X11_KEY = Some(std::mem::transmute(libc::dlsym(xtst, b"XTestFakeKeyEvent\0".as_ptr() as *const libc::c_char))); - X11_FLUSH = Some(std::mem::transmute(libc::dlsym(lib, b"XFlush\0".as_ptr() as *const libc::c_char))); - X11_KEYCODE = Some(std::mem::transmute(libc::dlsym(lib, b"XKeysymToKeycode\0".as_ptr() as *const libc::c_char))); - } - }); - - unsafe { - if X11_DPY.is_null() || X11_KEY.is_none() { self.send_ctrl_v(); return; } - let dpy = X11_DPY; - let xkey = X11_KEY.unwrap(); - let xflush = X11_FLUSH.unwrap(); - let xkeycode = X11_KEYCODE.unwrap(); - let ctrl_kc = xkeycode(dpy, 0xFFE3); - let v_kc = xkeycode(dpy, 0x0076); - xkey(dpy, ctrl_kc, 1, 0); - xkey(dpy, v_kc, 1, 0); - xkey(dpy, v_kc, 0, 0); - xkey(dpy, ctrl_kc, 0, 0); - xflush(dpy); - std::thread::sleep(std::time::Duration::from_millis(10)); - } - } } impl Drop for UinputInjector { @@ -702,9 +575,10 @@ fn run_restorer(state: Arc) { } // Deadline reached. Restore under the lock so the write cannot // interleave with a concurrent paste's clipboard write. - let restored = st.saved_clipboard.clone().unwrap_or_default(); - let _ = UinputInjector::copy_to_clipboard(&restored); - st.last_injected = Some(restored); + if let Some(restored) = st.saved_clipboard.clone() { + let _ = UinputInjector::copy_to_clipboard(&restored); + st.last_injected = Some(restored); + } st.restore_due = None; } } diff --git a/protocol/src/x11_inject.rs b/protocol/src/x11_inject.rs index f65e644..077c6ee 100644 --- a/protocol/src/x11_inject.rs +++ b/protocol/src/x11_inject.rs @@ -507,9 +507,18 @@ impl KeyInjector for X11Injector { } fn send_string(&self, s: &str) -> InjectResult { - for ch in s.chars() { - self.send_char(ch); + // ASCII: type individual characters via XTest (fast, no side effects) + let is_ascii = s.chars().all(|c| char_to_keycode(c).is_some()); + if is_ascii { + for ch in s.chars() { + self.send_char(ch); + } + return InjectResult::Success; } + + // Non-ASCII (Vietnamese Unicode): use clipboard paste via X11 API + XTest + // This avoids xdotool/ydotool subprocesses that silently drop Vietnamese. + self.paste_via_clipboard(0, s); InjectResult::Success }