feat: window-switch engine reset, xprop fallback, clean up dead code
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build packages (push) Blocked by required conditions

- 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
This commit is contained in:
Khoa Vo 2026-06-29 14:12:30 +07:00
parent a714dca0be
commit 24e4425665
8 changed files with 298 additions and 311 deletions

View file

@ -3,6 +3,42 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::process::Command; 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<String> {
// 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 /// Detect the currently focused window's class name
pub fn get_focused_window_class() -> Option<String> { pub fn get_focused_window_class() -> Option<String> {
// Try Wayland first (wlr-foreign-toplevel) // Try Wayland first (wlr-foreign-toplevel)

View file

@ -439,7 +439,73 @@ fn is_flush_char(ch: char) -> bool {
matches!(ch, ' ' | '.' | ',' | '!' | '?' | ';' | ':' | '\t' | '\n') 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/<pid>/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<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
recover_display_env();
let config_path = config::find_config_path(); let config_path = config::find_config_path();
let config = Config::load()?; let config = Config::load()?;
let engine_enabled = Arc::new(AtomicBool::new(config.start_enabled)); let engine_enabled = Arc::new(AtomicBool::new(config.start_enabled));
@ -471,16 +537,40 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
)); ));
// 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 for low-latency input (VMK technique)
boost_thread_priority(); boost_thread_priority();
// Spawn background monitor for active window, config changes, and status changes // Spawn background monitor for active window, config changes, and status changes
let shared_active_window = Arc::new(Mutex::new(String::new())); 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 config_changed = Arc::new(AtomicBool::new(false));
let status_changed = Arc::new(AtomicBool::new(false)); let status_changed = Arc::new(AtomicBool::new(false));
{ {
let shared_active_window = shared_active_window.clone(); let shared_active_window = shared_active_window.clone();
let shared_window_class = shared_window_class.clone();
let config_changed = config_changed.clone(); let config_changed = config_changed.clone();
let config_path = config_path.clone(); let config_path = config_path.clone();
let status_changed = status_changed.clone(); let status_changed = status_changed.clone();
@ -493,9 +583,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut window_check_counter = 0; let mut window_check_counter = 0;
let status_path = config_path.parent().unwrap().join("status"); let status_path = config_path.parent().unwrap().join("status");
loop { loop {
// Check active window class every 250ms // Check active window ID every 250ms (window ID is unique per
if let Some(class) = app_state::get_focused_window_class() { // 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(); 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 { if *lock != class {
*lock = class; *lock = class;
} }
@ -537,6 +639,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
device, device,
&mut daemon, &mut daemon,
shared_active_window, shared_active_window,
shared_window_class,
config_changed, config_changed,
status_changed, status_changed,
engine_enabled, engine_enabled,
@ -569,6 +672,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
run_stdin_mode( run_stdin_mode(
&mut daemon, &mut daemon,
shared_active_window, shared_active_window,
shared_window_class,
config_changed, config_changed,
status_changed, status_changed,
engine_enabled, engine_enabled,
@ -777,6 +881,7 @@ fn run_with_evdev(
mut device: evdev::Device, mut device: evdev::Device,
daemon: &mut Daemon, daemon: &mut Daemon,
shared_active_window: Arc<Mutex<String>>, shared_active_window: Arc<Mutex<String>>,
shared_window_class: Arc<Mutex<String>>,
config_changed: Arc<AtomicBool>, config_changed: Arc<AtomicBool>,
status_changed: Arc<AtomicBool>, status_changed: Arc<AtomicBool>,
_engine_enabled: Arc<AtomicBool>, _engine_enabled: Arc<AtomicBool>,
@ -814,6 +919,7 @@ fn run_with_evdev(
// Safety: if grab is active and no events arrive for 30 seconds, // Safety: if grab is active and no events arrive for 30 seconds,
// release the grab so the user isn't locked out. // release the grab so the user isn't locked out.
let mut last_event_time = std::time::Instant::now(); let mut last_event_time = std::time::Instant::now();
let mut last_key_time = std::time::Instant::now();
loop { loop {
// Check for event timeout (grab safety) // Check for event timeout (grab safety)
@ -839,26 +945,6 @@ fn run_with_evdev(
status_changed.store(false, Ordering::SeqCst); 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 // Check for config reload instantly
if config_changed.load(Ordering::SeqCst) { if config_changed.load(Ordering::SeqCst) {
daemon.reload_config(); daemon.reload_config();
@ -929,6 +1015,54 @@ fn run_with_evdev(
consumed_keys.remove(&keycode); consumed_keys.remove(&keycode);
} }
if let Some(mut ch) = key_to_char(key) { 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); let shift = is_modifier_held_shift(&key_state);
if ch.is_ascii_alphabetic() && (shift ^ caps) { if ch.is_ascii_alphabetic() && (shift ^ caps) {
ch = ch.to_ascii_uppercase(); ch = ch.to_ascii_uppercase();
@ -986,6 +1120,7 @@ fn run_with_evdev(
fn run_stdin_mode( fn run_stdin_mode(
daemon: &mut Daemon, daemon: &mut Daemon,
shared_active_window: Arc<Mutex<String>>, shared_active_window: Arc<Mutex<String>>,
shared_window_class: Arc<Mutex<String>>,
config_changed: Arc<AtomicBool>, config_changed: Arc<AtomicBool>,
status_changed: Arc<AtomicBool>, status_changed: Arc<AtomicBool>,
_engine_enabled: Arc<AtomicBool>, _engine_enabled: Arc<AtomicBool>,
@ -1020,6 +1155,7 @@ fn run_stdin_mode(
device, device,
daemon, daemon,
shared_active_window, shared_active_window,
shared_window_class,
config_changed, config_changed,
status_changed, status_changed,
_engine_enabled, _engine_enabled,
@ -1138,9 +1274,9 @@ fn create_injector(
return Ok(Box::new(vietc_protocol::uinput_client::UinputClient)); return Ok(Box::new(vietc_protocol::uinput_client::UinputClient));
} }
// Use uinput as primary — correct Linux keycodes for ASCII + backspace. // Use uinput as primary — correct Linux keycodes for backspace + ASCII.
// For Unicode (Vietnamese diacritics), falls back to xclip via subprocess // For Unicode (Vietnamese diacritics), falls back to X11 clipboard via
// or direct X11 clipboard via X11Injector. // direct X11 API (not subprocesses), making it work in Flatpak sandboxes.
match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") { match vietc_protocol::uinput_monitor::UinputInjector::new("vietc") {
Ok(injector) => { Ok(injector) => {
log_info("[vietc] Using uinput injection (primary)"); log_info("[vietc] Using uinput injection (primary)");

View file

@ -66,10 +66,6 @@ impl BambooEngine {
self.macro_buf.clear(); self.macro_buf.clear();
} }
pub fn is_empty(&self) -> bool {
self.composition.is_empty()
}
pub fn process_key(&mut self, ch: char) -> Option<String> { pub fn process_key(&mut self, ch: char) -> Option<String> {
if !self.mode.is_vn() { if !self.mode.is_vn() {
return Some(ch.to_string()); return Some(ch.to_string());
@ -181,11 +177,6 @@ impl BambooEngine {
None 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) { fn apply_mark_at(&mut self, idx: usize, _pattern: &str, result: &str) {
let result_chars: Vec<char> = result.chars().collect(); let result_chars: Vec<char> = result.chars().collect();
let was_upper = self.composition[idx].is_upper; 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)] #[allow(dead_code)]
pub fn debug_composition(&self) -> Vec<(char, Option<char>, Option<char>)> { pub fn debug_composition(&self) -> Vec<(char, Option<char>, Option<char>)> {
self.composition.iter().map(|t| (t.base_char, t.mark_applied, t.tone_applied)).collect() 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<char> = 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<char> = 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<String> { fn apply_tone(&mut self, tone_char: char) -> Option<String> {
if self.composition.is_empty() { if self.composition.is_empty() {
return Some(tone_char.to_string()); return Some(tone_char.to_string());

View file

@ -7,19 +7,11 @@ pub enum InputMethod {
Vni, 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)] #[derive(Debug, Clone)]
pub struct InputMethodRules { pub struct InputMethodRules {
pub method: InputMethod, pub method: InputMethod,
pub tone_keys: HashMap<char, (char, &'static str)>, pub tone_keys: HashMap<char, (char, &'static str)>,
pub mark_rules: Vec<(String, String)>, pub mark_rules: Vec<(String, String)>,
pub special_rules: Vec<RuleEffect>,
} }
fn tone_map(entries: &[(char, char, &'static str)]) -> HashMap<char, (char, &'static str)> { fn tone_map(entries: &[(char, char, &'static str)]) -> HashMap<char, (char, &'static str)> {
@ -46,7 +38,6 @@ pub fn get_rules(method: InputMethod) -> InputMethodRules {
("uw".into(), "ư".into()), ("uw".into(), "ư".into()),
("dd".into(), "đ".into()), ("dd".into(), "đ".into()),
], ],
special_rules: vec![],
}, },
InputMethod::Vni => InputMethodRules { InputMethod::Vni => InputMethodRules {
method, method,
@ -66,7 +57,6 @@ pub fn get_rules(method: InputMethod) -> InputMethodRules {
("a8".into(), "ă".into()), ("a8".into(), "ă".into()),
("d9".into(), "đ".into()), ("d9".into(), "đ".into()),
], ],
special_rules: vec![],
}, },
} }
} }

View file

@ -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.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-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] [Desktop Entry]
Name=Viet+ Name=Viet+
Comment=Vietnamese Input Method Comment=Vietnamese Input Method
@ -78,10 +79,12 @@ echo "=== Finalizing build... ==="
flatpak build-finish build-dir \ flatpak build-finish build-dir \
--socket=x11 \ --socket=x11 \
--socket=wayland \ --socket=wayland \
--filesystem=home \ --socket=session-bus \
--device=all \
--share=ipc \ --share=ipc \
--talk-name=org.freedesktop.Notifications \ --talk-name=org.freedesktop.Notifications \
--talk-name=org.a11y.Bus \ --talk-name=org.a11y.Bus \
--talk-name=org.freedesktop.portal.Clipboard \
--command=vietc-daemon --command=vietc-daemon
# Export # Export

View file

@ -10,10 +10,12 @@
"finish-args": [ "finish-args": [
"--socket=x11", "--socket=x11",
"--socket=wayland", "--socket=wayland",
"--filesystem=home", "--socket=session-bus",
"--device=all",
"--share=ipc", "--share=ipc",
"--talk-name=org.freedesktop.Notifications", "--talk-name=org.freedesktop.Notifications",
"--talk-name=org.a11y.Bus" "--talk-name=org.a11y.Bus",
"--talk-name=org.freedesktop.portal.Clipboard"
], ],
"modules": [ "modules": [
{ {

View file

@ -36,6 +36,10 @@ struct ClipInner {
/// the restored user content). Used to tell our own writes apart from text /// the restored user content). Used to tell our own writes apart from text
/// the user copied with Ctrl+C. /// the user copied with Ctrl+C.
last_injected: Option<String>, last_injected: Option<String>,
/// 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 /// When set, the restorer thread should rewrite the user's clipboard at
/// this instant. `None` means no restore is pending. /// this instant. `None` means no restore is pending.
restore_due: Option<Instant>, restore_due: Option<Instant>,
@ -60,10 +64,11 @@ impl UinputInjector {
fn send_enter(&self) { fn send_enter(&self) {
self.send_uinput_event(EV_KEY, 28, 1); self.send_uinput_event(EV_KEY, 28, 1);
self.send_uinput_event(0, 0, 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));
self.send_uinput_event(EV_KEY, 28, 0); self.send_uinput_event(EV_KEY, 28, 0);
self.send_uinput_event(0, 0, 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<Self, Box<dyn std::error::Error>> { pub fn new(name: &str) -> Result<Self, Box<dyn std::error::Error>> {
@ -109,6 +114,7 @@ impl UinputInjector {
inner: Mutex::new(ClipInner { inner: Mutex::new(ClipInner {
saved_clipboard: None, saved_clipboard: None,
last_injected: None, last_injected: None,
clipboard_saved: false,
restore_due: None, restore_due: None,
shutdown: false, shutdown: false,
}), }),
@ -146,36 +152,36 @@ impl UinputInjector {
fn send_key_stroke(&self, keycode: u16, shift: bool) { fn send_key_stroke(&self, keycode: u16, shift: bool) {
if shift { if shift {
self.send_uinput_event(EV_KEY, 42, 1); // Shift press self.send_uinput_event(EV_KEY, 42, 1);
self.send_uinput_event(0, 0, 0); // SYN 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, keycode, 1); // Key press self.send_uinput_event(EV_KEY, keycode, 1);
self.send_uinput_event(0, 0, 0); // SYN 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, keycode, 0); // Key release self.send_uinput_event(EV_KEY, keycode, 0);
self.send_uinput_event(0, 0, 0); // SYN 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));
if shift { if shift {
self.send_uinput_event(EV_KEY, 42, 0); // Shift release self.send_uinput_event(EV_KEY, 42, 0);
self.send_uinput_event(0, 0, 0); // SYN 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));
} }
} }
} }
impl KeyInjector for UinputInjector { impl KeyInjector for UinputInjector {
fn send_backspace(&self) -> InjectResult { fn send_backspace(&self) -> InjectResult {
self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press self.send_uinput_event(EV_KEY, 14, 1);
self.send_uinput_event(0, 0, 0); // SYN 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, 14, 0); // KEY_BACKSPACE release self.send_uinput_event(EV_KEY, 14, 0);
self.send_uinput_event(0, 0, 0); // SYN 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));
InjectResult::Success InjectResult::Success
} }
@ -183,7 +189,6 @@ impl KeyInjector for UinputInjector {
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult { fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
self.send_uinput_event(EV_KEY, keycode, value); self.send_uinput_event(EV_KEY, keycode, value);
self.send_uinput_event(0, 0, 0); self.send_uinput_event(0, 0, 0);
std::thread::sleep(std::time::Duration::from_millis(2));
InjectResult::Success InjectResult::Success
} }
@ -205,39 +210,20 @@ impl KeyInjector for UinputInjector {
fn send_string(&self, s: &str) -> InjectResult { fn send_string(&self, s: &str) -> InjectResult {
// ASCII characters: inject directly via uinput keycodes // ASCII characters: inject directly via uinput keycodes
let is_ascii = s.chars().all(|c| char_to_linux_keycode(c).is_some()); 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 { if is_ascii {
eprintln!(
"[vietc] send_string: ASCII '{}' via uinput",
s.escape_default()
);
for ch in s.chars() { for ch in s.chars() {
self.send_char(ch); self.send_char(ch);
} }
return InjectResult::Success; return InjectResult::Success;
} }
// Unicode text: single clipboard copy + paste (reliable method) // Unicode text: clipboard copy + paste (reliable method)
eprintln!( if !self.paste_via_clipboard(s) {
"[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 {
eprintln!( eprintln!(
"[vietc] send_string failed for '{}' (clipboard unavailable)", "[vietc] send_string failed for '{}' (clipboard unavailable)",
s.escape_default() s.escape_default()
); );
// Last resort: try paste_string (will try clipboard internally)
self.paste_string(s);
} }
InjectResult::Success InjectResult::Success
} }
@ -370,30 +356,13 @@ impl UinputInjector {
None 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 /// Send backspaces and text through a single injection channel to avoid
/// reordering between input methods. Backspaces always go through uinput /// reordering between input methods. Backspaces always go through uinput
/// (kernel device, no display server dependency). Text is typed via the /// (kernel device, no display server dependency). Text is typed via the
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or /// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
/// clipboard for Unicode. /// clipboard for Unicode.
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult { fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
let t0 = std::time::Instant::now();
// If all ASCII, send keycodes directly // If all ASCII, send keycodes directly
if text.chars().all(|c| char_to_linux_keycode(c).is_some() || c == '\n') { if text.chars().all(|c| char_to_linux_keycode(c).is_some() || c == '\n') {
if backspaces > 0 { if backspaces > 0 {
@ -403,15 +372,15 @@ impl UinputInjector {
if ch == '\n' { self.send_enter(); } if ch == '\n' { self.send_enter(); }
else { let _ = self.send_char(ch); } 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; return InjectResult::Success;
} }
// Unicode: clipboard paste. Backspaces FIRST, then paste. // Unicode: backspaces via uinput, then delegate to send_string()
if backspaces > 0 { if backspaces > 0 {
for _ in 0..backspaces { let _ = self.send_backspace(); } for _ in 0..backspaces { let _ = self.send_backspace(); }
} }
self.paste_via_clipboard(text, true); self.send_string(text);
InjectResult::Success InjectResult::Success
} }
@ -439,33 +408,38 @@ impl UinputInjector {
/// with Ctrl+C, so a subsequent Ctrl+V would paste the wrong thing. /// with Ctrl+C, so a subsequent Ctrl+V would paste the wrong thing.
/// ///
/// Returns whether the text was successfully copied to the clipboard. /// 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, // Critical section: snapshot the clipboard, decide what to preserve,
// cancel any pending restore so the restorer cannot fire while we // cancel any pending restore so the restorer cannot fire while we
// paste, and put our word on the clipboard. The read and write happen // paste, and put our word on the clipboard. The read and write happen
// under the lock so they can never interleave with the restorer. // under the lock so they can never interleave with the restorer.
{ {
let mut st = self.clip.inner.lock().unwrap(); let mut st = self.clip.inner.lock().unwrap();
if !st.clipboard_saved {
let current = Self::read_clipboard(); let current = Self::read_clipboard();
let is_our_write = let is_our_write =
matches!((&current, &st.last_injected), (Some(c), Some(l)) if c == l); matches!((&current, &st.last_injected), (Some(c), Some(l)) if c == l);
if !is_our_write { if !is_our_write {
st.saved_clipboard = current; st.saved_clipboard = current;
} }
st.clipboard_saved = true;
}
st.restore_due = None; st.restore_due = None;
if !Self::copy_to_clipboard(text) { let copied = Self::copy_to_clipboard(text);
if !copied {
return false; return false;
} }
st.last_injected = Some(text.to_string()); st.last_injected = Some(text.to_string());
} }
// Give the selection owner a moment to take ownership before pasting. // 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 // Schedule a debounced restore. While the user keeps typing this gets
@ -479,46 +453,6 @@ impl UinputInjector {
true 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. /// Build a command to run as the original user with display environment.
fn user_cmd(program: &str) -> std::process::Command { fn user_cmd(program: &str) -> std::process::Command {
let is_root = unsafe { libc::getuid() == 0 }; let is_root = unsafe { libc::getuid() == 0 };
@ -554,11 +488,19 @@ impl UinputInjector {
std::process::Command::new(program) 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 { fn copy_to_clipboard(s: &str) -> bool {
// Try wl-copy (Wayland) via user_cmd let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
{ let (prog, args): (&str, &[&str]) = if is_wayland {
let mut cmd = Self::user_cmd("wl-copy"); ("wl-copy", &[])
} else {
("xclip", &["-selection", "clipboard", "-i"])
};
let mut cmd = Self::user_cmd(prog);
cmd.args(args);
let result = cmd let result = cmd
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.spawn() .spawn()
@ -572,27 +514,7 @@ impl UinputInjector {
return true; return true;
} }
} }
} eprintln!("[vietc] copy_to_clipboard: {} failed", prog);
// 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 {
return true;
}
}
false false
} }
@ -600,70 +522,21 @@ impl UinputInjector {
fn send_ctrl_v(&self) { fn send_ctrl_v(&self) {
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL press self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL press
self.send_uinput_event(0, 0, 0); // SYN 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(EV_KEY, 47, 1); // KEY_V press
self.send_uinput_event(0, 0, 0); // SYN 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(EV_KEY, 47, 0); // KEY_V release
self.send_uinput_event(0, 0, 0); // SYN 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(EV_KEY, 29, 0); // KEY_LEFTCTRL release
self.send_uinput_event(0, 0, 0); // SYN 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<unsafe extern "C" fn(*mut libc::c_void, u32, libc::c_int, u64) -> libc::c_int> = None;
static mut X11_FLUSH: Option<unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int> = None;
static mut X11_KEYCODE: Option<unsafe extern "C" fn(*mut libc::c_void, u64) -> 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 { impl Drop for UinputInjector {
@ -702,9 +575,10 @@ fn run_restorer(state: Arc<ClipState>) {
} }
// Deadline reached. Restore under the lock so the write cannot // Deadline reached. Restore under the lock so the write cannot
// interleave with a concurrent paste's clipboard write. // interleave with a concurrent paste's clipboard write.
let restored = st.saved_clipboard.clone().unwrap_or_default(); if let Some(restored) = st.saved_clipboard.clone() {
let _ = UinputInjector::copy_to_clipboard(&restored); let _ = UinputInjector::copy_to_clipboard(&restored);
st.last_injected = Some(restored); st.last_injected = Some(restored);
}
st.restore_due = None; st.restore_due = None;
} }
} }

View file

@ -507,9 +507,18 @@ impl KeyInjector for X11Injector {
} }
fn send_string(&self, s: &str) -> InjectResult { fn send_string(&self, s: &str) -> InjectResult {
// 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() { for ch in s.chars() {
self.send_char(ch); 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 InjectResult::Success
} }