From bb0847a38f92690b6af56b299b326f8168230ccf Mon Sep 17 00:00:00 2001 From: Khoa Vo Date: Fri, 26 Jun 2026 07:56:52 +0700 Subject: [PATCH] X11 capture: proper key tracking, direct clipboard, VNI default - X11 keyboard capture via XGrabKeyboard (no input group needed) - Direct X11 clipboard for Unicode injection (no xclip/xdotool dependency) - Proper KeyPress/KeyRelease tracking (fix Ctrl+C, Alt+Tab, held keys) - Default input_method=vni, start_enabled=false - AppImage: bundled xclip, proper keyboard+VN SVG icon - Deb: Recommends libxtst6, xclip --- daemon/src/config.rs | 10 +- daemon/src/main.rs | 143 ++++++++ engine/tests/generated_bulk.rs | 1 + packaging/appimage/build-appimage.sh | 134 ++++++-- packaging/deb/build-deb.sh | 4 +- protocol/src/lib.rs | 3 + protocol/src/x11_capture.rs | 305 +++++++++++++++++ protocol/src/x11_inject.rs | 485 +++++++++++++++++---------- vietc.toml | 4 +- 9 files changed, 875 insertions(+), 214 deletions(-) create mode 100644 protocol/src/x11_capture.rs diff --git a/daemon/src/config.rs b/daemon/src/config.rs index 04789a5..27734db 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -79,13 +79,13 @@ impl Default for AppStateConfig { } fn default_input_method() -> String { - "telex".into() + "vni".into() } fn default_toggle_key() -> String { "space".into() } fn default_start_enabled() -> bool { - true + false } fn default_true() -> bool { true @@ -278,9 +278,9 @@ vs = "với" fn parse_empty_config_uses_defaults() { let toml = ""; let config: Config = toml::from_str(toml).unwrap(); - assert_eq!(config.input_method, "telex"); + assert_eq!(config.input_method, "vni"); assert_eq!(config.toggle_key, "space"); - assert!(config.start_enabled); + assert!(!config.start_enabled); assert!(config.auto_restore.enabled); assert!(config.app_state.enabled); assert!(!config.app_state.english_apps.is_empty()); @@ -295,7 +295,7 @@ input_method = "vni" let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.input_method, "vni"); assert_eq!(config.toggle_key, "space"); // default - assert!(config.start_enabled); // default + assert!(!config.start_enabled); // default } #[test] diff --git a/daemon/src/main.rs b/daemon/src/main.rs index c08a8dc..4af7499 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -15,6 +15,11 @@ mod display; use app_state::AppStateManager; use config::Config; +#[cfg(feature = "x11")] +use vietc_protocol::x11_capture::X11Capture; +#[cfg(feature = "x11")] +use vietc_protocol::x11_inject::X11Injector; + fn get_log_path() -> Option { dirs::config_dir().map(|p| p.join("vietc").join("vietc.log")) } @@ -403,6 +408,27 @@ fn main() -> Result<(), Box> { }); } + #[cfg(feature = "x11")] + if display != display::DisplayServer::Wayland { + if let Some(mut capture) = X11Capture::new() { + if capture.grab_keyboard() { + log_info("[vietc] X11 keyboard grabbed — using X11 capture/injection"); + return run_with_x11( + capture, + &mut daemon, + shared_active_window, + config_changed, + status_changed, + engine_enabled, + ); + } else { + log_info("[vietc] X11 grab failed, falling back to evdev"); + } + } else { + log_info("[vietc] X11 not available, falling back to evdev"); + } + } + match open_keyboard_device() { Ok((device, path)) => { log_info(&format!("[vietc] Keyboard device: {}", path)); @@ -502,6 +528,123 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box>, + config_changed: Arc, + status_changed: Arc, + _engine_enabled: Arc, +) -> Result<(), Box> { + let injector: Box = Box::new(X11Injector::new()?); + let mut last_active_window = String::new(); + // Track physically-held keys so we only inject press on KeyPress + // and release on KeyRelease — without this, every KeyPress injects + // press+release immediately, breaking held-key combos (Ctrl+C, Alt+Tab…). + let mut pressed_keys: HashSet = HashSet::new(); + + loop { + if status_changed.load(Ordering::SeqCst) { + daemon.sync_status_file(); + status_changed.store(false, Ordering::SeqCst); + } + + if config_changed.load(Ordering::SeqCst) { + daemon.reload_config(); + config_changed.store(false, Ordering::SeqCst); + } + + { + 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(); + } + } + + if daemon.config.app_state.enabled { + let active_window = shared_active_window.lock().unwrap().clone(); + daemon.check_app_change_with(active_window); + } + + while let Some(event) = capture.next_event() { + if event.pressed { + // Skip autorepeat — key is already tracked as held + if !pressed_keys.insert(event.keycode) { + continue; + } + + // Toggle key: Ctrl+Space + if let Some(' ') = event.ch { + if (event.state & 4) != 0 { + pressed_keys.remove(&event.keycode); + daemon.toggle(); + continue; + } + } + + // Modifier or non-character key → forward press only + if capture.is_modifier_pressed(event.state) || event.ch.is_none() { + daemon.engine.reset(); + capture.without_grab(|| { + let _ = injector.send_key_event(event.keycode as u16, 1); + }); + continue; + } + + // Character key + if let Some(ch) = event.ch { + match ch { + '\x08' => { + daemon.engine.process_key('\x08'); + capture.without_grab(|| { + let _ = injector.send_backspace(); + }); + // Keep in pressed_keys so release is forwarded + } + '\n' => { + pressed_keys.remove(&event.keycode); + daemon.engine.reset(); + capture.without_grab(|| { + let _ = injector.send_key_event(event.keycode as u16, 1); + let _ = injector.send_key_event(event.keycode as u16, 0); + }); + } + _ => { + let commands = daemon.process_key(ch); + if !commands.is_empty() { + // Engine consumed the key; remove from tracking + pressed_keys.remove(&event.keycode); + capture.without_grab(|| { + execute_commands(&*injector, &commands, true); + }); + } else { + // Engine started composing; forward press+release immediately + pressed_keys.remove(&event.keycode); + capture.without_grab(|| { + let _ = injector.send_key_event(event.keycode as u16, 1); + let _ = injector.send_key_event(event.keycode as u16, 0); + }); + } + } + } + } + } else { + // Key release — only inject if we were tracking this key + if pressed_keys.remove(&event.keycode) { + capture.without_grab(|| { + let _ = injector.send_key_event(event.keycode as u16, 0); + }); + } + } + } + + thread::sleep(Duration::from_millis(10)); + } +} + fn run_with_evdev( mut device: evdev::Device, daemon: &mut Daemon, diff --git a/engine/tests/generated_bulk.rs b/engine/tests/generated_bulk.rs index 65aae66..011b1e8 100644 --- a/engine/tests/generated_bulk.rs +++ b/engine/tests/generated_bulk.rs @@ -24,6 +24,7 @@ fn get_display(events: &[EngineEvent]) -> String { for _ in 0..*backspaces { display.pop(); } display.push_str(restored); } + EngineEvent::Paste(text) => { display.push_str(text); } } } display diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh index 45bcbcf..9f9e30a 100644 --- a/packaging/appimage/build-appimage.sh +++ b/packaging/appimage/build-appimage.sh @@ -43,6 +43,15 @@ else [ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/" fi +# Bundle xclip as fallback for clipboard operations +echo " Bundling xclip..." +if command -v xclip &>/dev/null; then + cp "$(which xclip)" "$APPDIR/usr/bin/" + echo " xclip bundled" +else + echo " xclip not found on system, skipping" +fi + # Desktop integration echo "[3/5] Installing desktop integration..." if [ -f "deb-build/vietc.desktop" ]; then @@ -109,6 +118,67 @@ else cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/" fi +# Icon — required by appimagetool (desktop file has Icon=vietc) +# Use SVG from deb build if available, otherwise generate a keyboard icon +if [ -f "deb-build/usr/share/icons/hicolor/256x256/apps/vietc.svg" ]; then + cp "deb-build/usr/share/icons/hicolor/256x256/apps/vietc.svg" "$APPDIR/vietc.svg" +elif [ -f "deb-build/usr/share/icons/hicolor/256x256/apps/vietc.png" ]; then + cp "deb-build/usr/share/icons/hicolor/256x256/apps/vietc.png" "$APPDIR/vietc.png" +else + # Generate a proper keyboard+VN icon as SVG + cat > "$APPDIR/vietc.svg" << 'SVGEOF' + + + + + + + + + + + + + + + + + + + + + + + VN + +SVGEOF +fi + +# Convert SVG to PNG for appimagetool (it prefers PNG for the root icon) +if [ -f "$APPDIR/vietc.svg" ] && ! [ -f "$APPDIR/vietc.png" ]; then + if command -v rsvg-convert &>/dev/null; then + rsvg-convert -w 256 -h 256 "$APPDIR/vietc.svg" -o "$APPDIR/vietc.png" + elif command -v inkscape &>/dev/null; then + inkscape -w 256 -h 256 "$APPDIR/vietc.svg" --export-filename="$APPDIR/vietc.png" 2>/dev/null + elif command -v convert &>/dev/null; then + convert -background none "$APPDIR/vietc.svg" -resize 256x256 "$APPDIR/vietc.png" 2>/dev/null + elif command -v python3 &>/dev/null; then + python3 -c " +import subprocess, sys +try: + subprocess.check_call(['rsvg-convert', '-w', '256', '-h', '256', '$APPDIR/vietc.svg', '-o', '$APPDIR/vietc.png']) +except Exception: + pass +" 2>/dev/null + fi + # If no converter, appimagetool can use SVG directly +fi + +# Also put icon in hicolor for system installs via AppImage +mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps" +[ -f "$APPDIR/vietc.svg" ] && cp "$APPDIR/vietc.svg" "$APPDIR/usr/share/icons/hicolor/256x256/apps/" +[ -f "$APPDIR/vietc.png" ] && cp "$APPDIR/vietc.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/" + # Create custom AppRun script cat > "$APPDIR/AppRun" << 'EOF' #!/bin/sh @@ -128,39 +198,51 @@ ENV_PREFIX="env" [ -n "$XDG_RUNTIME_DIR" ] && ENV_PREFIX="$ENV_PREFIX XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" # Start daemon (kill old non-root one first if we have root) - -# Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wtype/wl-copy. -# Only set WAYLAND_DISPLAY if the user actually has a Wayland session. -if [ "$(id -u)" = "0" ] && [ -z "$XDG_RUNTIME_DIR" ] && [ -n "$SUDO_USER" ]; then - USER_UID=$(id -u "$SUDO_USER" 2>/dev/null || echo 1000) - export XDG_RUNTIME_DIR="/run/user/$USER_UID" - if [ -d "/run/user/$USER_UID" ] && ls "/run/user/$USER_UID/wayland-*" >/dev/null 2>&1; then - export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" - fi +# On X11 we can run without root (XGrabKeyboard + XTest injection needs no special permissions). +# On Wayland, evdev requires root (input group) or uinput. +NEED_ROOT="" +if [ -n "$WAYLAND_DISPLAY" ]; then + NEED_ROOT="yes" fi -if command -v pkexec >/dev/null && [ -z "$WAYLAND_DISPLAY" ]; then - pkill -x vietc 2>/dev/null; sleep 0.5 - pkexec $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & +if [ -z "$NEED_ROOT" ]; then + # X11: no root needed + pkill -x vietc 2>/dev/null; sleep 0.3 + "$HERE/usr/bin/vietc" >/dev/null & DAEMON_PID=$! -elif [ -n "$WAYLAND_DISPLAY" ]; then - password="" - if command -v kdialog >/dev/null; then - password=$(kdialog --password "Viet+ needs root privileges to grab the keyboard.") || password="" - elif command -v zenity >/dev/null; then - password=$(zenity --password --title="Viet+ needs root") || password="" - elif command -v ssh-askpass >/dev/null; then - password=$(ssh-askpass "Viet+ needs root privileges") || password="" +else + # Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wtype/wl-copy. + if [ "$(id -u)" = "0" ] && [ -z "$XDG_RUNTIME_DIR" ] && [ -n "$SUDO_USER" ]; then + USER_UID=$(id -u "$SUDO_USER" 2>/dev/null || echo 1000) + export XDG_RUNTIME_DIR="/run/user/$USER_UID" + if [ -d "/run/user/$USER_UID" ] && ls "/run/user/$USER_UID/wayland-*" >/dev/null 2>&1; then + export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" + fi fi - if [ -n "$password" ]; then + + if command -v pkexec >/dev/null; then pkill -x vietc 2>/dev/null; sleep 0.5 - echo "$password" | sudo -S $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & + pkexec $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & + DAEMON_PID=$! + elif [ -n "$WAYLAND_DISPLAY" ]; then + password="" + if command -v kdialog >/dev/null; then + password=$(kdialog --password "Viet+ needs root privileges to grab the keyboard.") || password="" + elif command -v zenity >/dev/null; then + password=$(zenity --password --title="Viet+ needs root") || password="" + elif command -v ssh-askpass >/dev/null; then + password=$(ssh-askpass "Viet+ needs root privileges") || password="" + fi + if [ -n "$password" ]; then + pkill -x vietc 2>/dev/null; sleep 0.5 + echo "$password" | sudo -S $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & + DAEMON_PID=$! + fi + elif command -v sudo >/dev/null; then + pkill -x vietc 2>/dev/null; sleep 0.5 + sudo $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & DAEMON_PID=$! fi -elif command -v sudo >/dev/null; then - pkill -x vietc 2>/dev/null; sleep 0.5 - sudo $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null & - DAEMON_PID=$! fi if [ -z "$DAEMON_PID" ] && ! pgrep -x vietc >/dev/null; then diff --git a/packaging/deb/build-deb.sh b/packaging/deb/build-deb.sh index 1aaa8e4..b57a75b 100755 --- a/packaging/deb/build-deb.sh +++ b/packaging/deb/build-deb.sh @@ -12,7 +12,7 @@ echo "=== Building Viet+ .deb package v${VERSION} ===" # Build binaries (all features: x11 + wayland) echo "[1/5] Building binaries..." cargo build --release --features "x11,wayland" --manifest-path "$PROJECT_ROOT/Cargo.toml" -(cd "$PROJECT_ROOT/ui" && cargo build --release) +(cd "$PROJECT_ROOT/ui" && cargo build --release) || echo " Warning: UI tray not built (libdbus-1-dev may be missing)" echo " Done." # Clean and create staging @@ -113,7 +113,7 @@ Section: utils Priority: optional Architecture: amd64 Depends: libc6 (>= 2.31), libevdev2 (>= 1.9.0) -Recommends: libwayland-client0 (>= 1.20), libx11-6 +Recommends: libwayland-client0 (>= 1.20), libx11-6, libxtst6, xclip Maintainer: Khoa Vo Description: Viet+ — Vietnamese Input Method for Linux Zero-configuration Vietnamese input method engine supporting diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index 00a9617..b1f8298 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -6,5 +6,8 @@ pub mod wayland_im; #[cfg(feature = "x11")] pub mod x11_inject; +#[cfg(feature = "x11")] +pub mod x11_capture; + pub use inject::KeyInjector; pub use monitor::KeyMonitor; diff --git a/protocol/src/x11_capture.rs b/protocol/src/x11_capture.rs new file mode 100644 index 0000000..bb444fe --- /dev/null +++ b/protocol/src/x11_capture.rs @@ -0,0 +1,305 @@ +use std::ffi::{c_char, c_int, c_void}; + +type Display = c_void; +type Window = u64; +type XID = u64; +type Time = u64; + +// X11 event types +const KEY_PRESS: c_int = 2; +const KEY_RELEASE: c_int = 3; + +// X11 modifier masks +const SHIFT_MASK: c_int = 1; +const CONTROL_MASK: c_int = 4; +const MOD1_MASK: c_int = 8; // Alt +const MOD4_MASK: c_int = 64; // Super/Win + +// Grab modes +const GRAB_MODE_ASYNC: c_int = 1; + +extern "C" { + fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; + fn dlclose(handle: *mut c_void) -> c_int; +} + +struct X11Lib { + handle: *mut c_void, + x_open_display: unsafe extern "C" fn(*const c_char) -> *mut Display, + x_close_display: unsafe extern "C" fn(*mut Display) -> c_int, + x_default_root_window: unsafe extern "C" fn(*mut Display) -> Window, + x_grab_keyboard: unsafe extern "C" fn(*mut Display, Window, c_int, c_int, c_int, Time) -> c_int, + x_ungrab_keyboard: unsafe extern "C" fn(*mut Display, Time) -> c_int, + x_next_event: unsafe extern "C" fn(*mut Display, *mut XEvent), + x_lookup_string: unsafe extern "C" fn(*mut XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int, + x_keysym_to_keycode: unsafe extern "C" fn(*mut Display, KeySym) -> u32, + x_utf8_lookup_string: Option c_int>, + x_flush: unsafe extern "C" fn(*mut Display) -> c_int, +} + +impl X11Lib { + fn new() -> Result> { + unsafe { + let paths = [ + b"libX11.so.6\0".as_ptr() as *const c_char, + b"libX11.so\0".as_ptr() as *const c_char, + ]; + let mut handle = std::ptr::null_mut(); + for path in paths { + handle = dlopen(path, 1); + if !handle.is_null() { + break; + } + } + if handle.is_null() { + return Err("Failed to load libX11.so.6".into()); + } + + macro_rules! sym { + ($name:expr) => { + std::mem::transmute(dlsym(handle, concat!($name, "\0").as_ptr() as *const c_char)) + }; + } + + let x_open_display = sym!("XOpenDisplay"); + let x_close_display = sym!("XCloseDisplay"); + let x_default_root_window = sym!("XDefaultRootWindow"); + let x_grab_keyboard = sym!("XGrabKeyboard"); + let x_ungrab_keyboard = sym!("XUngrabKeyboard"); + let x_next_event = sym!("XNextEvent"); + let x_lookup_string = sym!("XLookupString"); + let x_keysym_to_keycode = sym!("XKeysymToKeycode"); + let x_utf8_lookup_string = dlsym(handle, b"Xutf8LookupString\0".as_ptr() as *const c_char); + let x_utf8_lookup_string = if x_utf8_lookup_string.is_null() { + None + } else { + Some(std::mem::transmute(x_utf8_lookup_string)) + }; + let x_flush = sym!("XFlush"); + + Ok(Self { + handle, + x_open_display, + x_close_display, + x_default_root_window, + x_grab_keyboard, + x_ungrab_keyboard, + x_next_event, + x_lookup_string, + x_keysym_to_keycode, + x_utf8_lookup_string, + x_flush, + }) + } + } +} + +impl Drop for X11Lib { + fn drop(&mut self) { + unsafe { + dlclose(self.handle); + } + } +} + +#[derive(Copy, Clone)] +#[repr(C)] +struct XKeyEvent { + _type: c_int, + _serial: u64, + _send_event: c_int, + _display: *mut Display, + window: Window, + _root: Window, + _subwindow: Window, + _time: Time, + _x: c_int, + _y: c_int, + _x_root: c_int, + _y_root: c_int, + state: c_int, + keycode: u32, + _same_screen: c_int, +} + +#[repr(C)] +union XEventData { + key: XKeyEvent, +} + +#[repr(C)] +struct XEvent { + _type: c_int, + _pad: [u8; 24], + data: XEventData, +} + +type KeySym = u64; + +pub struct X11KeyEvent { + pub keycode: u32, + pub ch: Option, + pub pressed: bool, + pub state: c_int, +} + +pub struct X11Capture { + lib: X11Lib, + display: *mut Display, + root: Window, + grabbed: bool, + event_buf: Vec, +} + +unsafe impl Send for X11Capture {} + +impl X11Capture { + pub fn new() -> Option { + let lib = match X11Lib::new() { + Ok(lib) => lib, + Err(e) => { + eprintln!("[vietc] X11Capture: failed to load X11: {}", e); + return None; + } + }; + + unsafe { + let display = (lib.x_open_display)(std::ptr::null()); + if display.is_null() { + eprintln!("[vietc] X11Capture: cannot open display. Is DISPLAY set?"); + return None; + } + + let root = (lib.x_default_root_window)(display); + eprintln!("[vietc] X11Capture: initialized successfully"); + Some(Self { + lib, + display, + root, + grabbed: false, + event_buf: Vec::new(), + }) + } + } + + pub fn grab_keyboard(&mut self) -> bool { + unsafe { + let status = (self.lib.x_grab_keyboard)( + self.display, + self.root, + 0, // owner_events = False + GRAB_MODE_ASYNC, + GRAB_MODE_ASYNC, + 0, // CurrentTime + ) as i32; + if status == 0 { + self.grabbed = true; + eprintln!("[vietc] X11Capture: grabbed keyboard successfully"); + true + } else { + eprintln!("[vietc] X11Capture: grab failed with status {}", status); + false + } + } + } + + pub fn ungrab_keyboard(&mut self) { + if self.grabbed { + unsafe { + (self.lib.x_ungrab_keyboard)(self.display, 0); + (self.lib.x_flush)(self.display); + } + self.grabbed = false; + } + } + + pub fn next_event(&mut self) -> Option { + if !self.grabbed { + return None; + } + + let mut event: XEvent = unsafe { std::mem::zeroed() }; + unsafe { + (self.lib.x_next_event)(self.display, &mut event); + } + + let _type = event._type; + if _type != KEY_PRESS && _type != KEY_RELEASE { + return self.next_event(); + } + + let key_event = unsafe { &event.data.key }; + let ch = self.lookup_key(key_event); + Some(X11KeyEvent { + keycode: key_event.keycode, + ch, + pressed: _type == KEY_PRESS, + state: key_event.state, + }) + } + + pub fn is_modifier_pressed(&self, state: c_int) -> bool { + (state & (CONTROL_MASK | MOD1_MASK | MOD4_MASK)) != 0 + } + + pub fn with_grab(&mut self, f: F) -> T + where + F: FnOnce() -> T, + { + // Grab should already be held; just execute + f() + } + + pub fn without_grab(&mut self, f: F) -> T + where + F: FnOnce() -> T, + { + self.ungrab_keyboard(); + let result = f(); + self.grab_keyboard(); + result + } + + fn lookup_key(&self, event: &XKeyEvent) -> Option { + let mut buf = [0u8; 32]; + let mut keysym: KeySym = 0; + let len = unsafe { + if let Some(xutf8) = self.lib.x_utf8_lookup_string { + xutf8( + event as *const XKeyEvent as *mut XKeyEvent, + buf.as_mut_ptr() as *mut c_char, + buf.len() as c_int, + &mut keysym as *mut KeySym, + std::ptr::null_mut(), + ) + } else { + (self.lib.x_lookup_string)( + event as *const XKeyEvent as *mut XKeyEvent, + buf.as_mut_ptr() as *mut c_char, + buf.len() as c_int, + &mut keysym as *mut KeySym, + std::ptr::null_mut(), + ) + } + }; + + if len > 0 { + let s = std::str::from_utf8(&buf[..len as usize]).ok()?; + s.chars().next() + } else { + None + } + } +} + +impl Drop for X11Capture { + fn drop(&mut self) { + if self.grabbed { + self.ungrab_keyboard(); + } + unsafe { + (self.lib.x_close_display)(self.display); + } + } +} diff --git a/protocol/src/x11_inject.rs b/protocol/src/x11_inject.rs index 119698d..155b7fb 100644 --- a/protocol/src/x11_inject.rs +++ b/protocol/src/x11_inject.rs @@ -1,26 +1,46 @@ use super::inject::{InjectResult, KeyInjector}; +use std::cell::RefCell; use std::ffi::{c_char, c_int, c_void}; type Display = c_void; type Window = u64; +type Atom = u64; +type Time = u64; -// Dynamic linker FFI extern "C" { fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; fn dlclose(handle: *mut c_void) -> c_int; } +const CURRENT_TIME: Time = 0; +const PROP_MODE_REPLACE: c_int = 0; +const NO_EVENT_MASK: i64 = 0; +const INPUT_OUTPUT: c_int = 1; +const COPY_FROM_PARENT: Window = 0; + +const SELECTION_REQUEST: c_int = 30; +const SELECTION_NOTIFY: c_int = 31; + struct X11Lib { x11_handle: *mut c_void, xtst_handle: *mut c_void, - // Symbols x_open_display: unsafe extern "C" fn(*const c_char) -> *mut Display, x_close_display: unsafe extern "C" fn(*mut Display) -> c_int, x_default_root_window: unsafe extern "C" fn(*mut Display) -> Window, x_flush: unsafe extern "C" fn(*mut Display) -> c_int, x_test_fake_key_event: unsafe extern "C" fn(*mut Display, u32, c_int, u64) -> c_int, + x_intern_atom: unsafe extern "C" fn(*mut Display, *const c_char, c_int) -> Atom, + x_set_selection_owner: unsafe extern "C" fn(*mut Display, Atom, Window, Time) -> c_int, + x_change_property: unsafe extern "C" fn(*mut Display, Window, Atom, Atom, c_int, c_int, *const c_void, c_int) -> c_int, + x_get_selection_owner: unsafe extern "C" fn(*mut Display, Atom) -> Window, + x_send_event: unsafe extern "C" fn(*mut Display, Window, c_int, i64, *const c_void) -> c_int, + x_create_simple_window: unsafe extern "C" fn(*mut Display, Window, c_int, c_int, c_int, c_int, c_int, Atom, Atom) -> Window, + x_map_window: unsafe extern "C" fn(*mut Display, Window) -> c_int, + x_destroy_window: unsafe extern "C" fn(*mut Display, Window) -> c_int, + x_pending: unsafe extern "C" fn(*mut Display) -> c_int, + x_next_event: unsafe extern "C" fn(*mut Display, *mut XEvent), } impl X11Lib { @@ -32,7 +52,7 @@ impl X11Lib { ]; let mut x11_handle = std::ptr::null_mut(); for path in x11_paths { - x11_handle = dlopen(path, 1); // RTLD_LAZY + x11_handle = dlopen(path, 1); if !x11_handle.is_null() { break; } @@ -57,24 +77,27 @@ impl X11Lib { return Err("Failed to load libXtst.so.6".into()); } - let x_open_display = std::mem::transmute(dlsym( - x11_handle, - b"XOpenDisplay\0".as_ptr() as *const c_char, - )); - let x_close_display = std::mem::transmute(dlsym( - x11_handle, - b"XCloseDisplay\0".as_ptr() as *const c_char, - )); - let x_default_root_window = std::mem::transmute(dlsym( - x11_handle, - b"XDefaultRootWindow\0".as_ptr() as *const c_char, - )); - let x_flush = - std::mem::transmute(dlsym(x11_handle, b"XFlush\0".as_ptr() as *const c_char)); - let x_test_fake_key_event = std::mem::transmute(dlsym( - xtst_handle, - b"XTestFakeKeyEvent\0".as_ptr() as *const c_char, - )); + macro_rules! sym { + ($handle:expr, $name:expr) => { + std::mem::transmute(dlsym($handle, concat!($name, "\0").as_ptr() as *const c_char)) + }; + } + + let x_open_display = sym!(x11_handle, "XOpenDisplay"); + let x_close_display = sym!(x11_handle, "XCloseDisplay"); + let x_default_root_window = sym!(x11_handle, "XDefaultRootWindow"); + let x_flush = sym!(x11_handle, "XFlush"); + let x_intern_atom = sym!(x11_handle, "XInternAtom"); + let x_set_selection_owner = sym!(x11_handle, "XSetSelectionOwner"); + let x_change_property = sym!(x11_handle, "XChangeProperty"); + let x_get_selection_owner = sym!(x11_handle, "XGetSelectionOwner"); + let x_send_event = sym!(x11_handle, "XSendEvent"); + let x_create_simple_window = sym!(x11_handle, "XCreateSimpleWindow"); + let x_map_window = sym!(x11_handle, "XMapWindow"); + let x_destroy_window = sym!(x11_handle, "XDestroyWindow"); + let x_pending = sym!(x11_handle, "XPending"); + let x_next_event = sym!(x11_handle, "XNextEvent"); + let x_test_fake_key_event = sym!(xtst_handle, "XTestFakeKeyEvent"); Ok(Self { x11_handle, @@ -84,6 +107,16 @@ impl X11Lib { x_default_root_window, x_flush, x_test_fake_key_event, + x_intern_atom, + x_set_selection_owner, + x_change_property, + x_get_selection_owner, + x_send_event, + x_create_simple_window, + x_map_window, + x_destroy_window, + x_pending, + x_next_event, }) } } @@ -98,10 +131,8 @@ impl Drop for X11Lib { } } -// Linux-to-X11 keycode offset (X11 keycodes = Linux keycodes + 8) const X11_KEYCODE_OFFSET: u32 = 8; -// X11 keycodes for common ASCII characters fn char_to_keycode(ch: char) -> Option<(u32, bool)> { match ch { 'a' => Some((30, false)), @@ -156,53 +187,41 @@ fn char_to_keycode(ch: char) -> Option<(u32, bool)> { 'X' => Some((45, true)), 'Y' => Some((21, true)), 'Z' => Some((44, true)), - '0' => Some((11, false)), - '1' => Some((2, false)), - '2' => Some((3, false)), - '3' => Some((4, false)), - '4' => Some((5, false)), - '5' => Some((6, false)), - '6' => Some((7, false)), - '7' => Some((8, false)), - '8' => Some((9, false)), - '9' => Some((10, false)), - ' ' => Some((57, false)), - '.' => Some((52, false)), - ',' => Some((51, false)), - '-' => Some((12, false)), - '=' => Some((13, false)), - ';' => Some((39, false)), - '\'' => Some((40, false)), - '/' => Some((53, false)), - '\\' => Some((43, false)), - '`' => Some((41, false)), - '0' => Some((11, false)), - '1' => Some((2, false)), - '2' => Some((3, false)), - '3' => Some((4, false)), - '4' => Some((5, false)), - '5' => Some((6, false)), - '6' => Some((7, false)), - '7' => Some((8, false)), - '8' => Some((9, false)), - '9' => Some((10, false)), - ' ' => Some((57, false)), - '.' => Some((52, false)), - ',' => Some((51, false)), - '-' => Some((12, false)), - '=' => Some((13, false)), - ';' => Some((39, false)), - '\'' => Some((40, false)), - '/' => Some((53, false)), _ => None, } } +#[repr(C)] +struct XSelectionRequestEvent { + _type: c_int, + _serial: u64, + _send_event: c_int, + _display: *mut Display, + owner: Window, + requestor: Window, + selection: Atom, + target: Atom, + property: Atom, + time: Time, +} + +#[repr(C)] +struct XEvent { + _type: c_int, + _pad: [u8; 24], + data: [u64; 6], +} + pub struct X11Injector { lib: X11Lib, display: *mut Display, - #[allow(dead_code)] - window: Window, + root: Window, + clipboard_window: Window, + atom_clipboard: Atom, + atom_utf8: Atom, + atom_targets: Atom, + atom_string: Atom, + clipboard_text: RefCell, } unsafe impl Send for X11Injector {} @@ -216,33 +235,208 @@ impl X11Injector { if display.is_null() { return Err("Cannot open X11 display. Is DISPLAY set?".into()); } - let window = (lib.x_default_root_window)(display); + let root = (lib.x_default_root_window)(display); + + let atom_clipboard = (lib.x_intern_atom)(display, b"CLIPBOARD\0".as_ptr() as *const c_char, 0); + let atom_utf8 = (lib.x_intern_atom)(display, b"UTF8_STRING\0".as_ptr() as *const c_char, 0); + let atom_targets = (lib.x_intern_atom)(display, b"TARGETS\0".as_ptr() as *const c_char, 0); + let atom_string = (lib.x_intern_atom)(display, b"STRING\0".as_ptr() as *const c_char, 0); + + // Create a small hidden window for clipboard ownership + let clipboard_window = (lib.x_create_simple_window)( + display, root, 0, 0, 1, 1, 0, COPY_FROM_PARENT, COPY_FROM_PARENT, + ); + (lib.x_map_window)(display, clipboard_window); + Ok(Self { lib, display, - window, + root, + clipboard_window, + atom_clipboard, + atom_utf8, + atom_targets, + atom_string, + clipboard_text: RefCell::new(String::new()), }) } } + fn set_clipboard_text(&self, text: &str) { + *self.clipboard_text.borrow_mut() = text.to_string(); + unsafe { + // Set the text as a property on our clipboard window + (self.lib.x_change_property)( + self.display, + self.clipboard_window, + self.atom_clipboard, + self.atom_utf8, + 8, // 8-bit format + PROP_MODE_REPLACE, + text.as_ptr() as *const c_void, + text.len() as c_int, + ); + + // Also set as STRING (for apps that don't understand UTF8_STRING) + (self.lib.x_change_property)( + self.display, + self.clipboard_window, + self.atom_clipboard, + self.atom_string, + 8, + PROP_MODE_REPLACE, + text.as_ptr() as *const c_void, + text.len() as c_int, + ); + + // Claim the CLIPBOARD selection + (self.lib.x_set_selection_owner)( + self.display, + self.atom_clipboard, + self.clipboard_window, + CURRENT_TIME, + ); + + (self.lib.x_flush)(self.display); + } + } + + fn handle_pending_events(&self) { + unsafe { + while (self.lib.x_pending)(self.display) > 0 { + let mut event: XEvent = std::mem::zeroed(); + (self.lib.x_next_event)(self.display, &mut event); + if event._type == SELECTION_REQUEST { + let req = &*(&event as *const XEvent as *const XSelectionRequestEvent); + self.handle_selection_request(req); + } + } + } + } + + fn handle_selection_request(&self, req: &XSelectionRequestEvent) { + eprintln!( + "[vietc] SelectionRequest: target={} requestor={}", + req.target, req.requestor + ); + + // Determine what property to use for the response + let property = if req.property == 0 { + req.target // Use the target atom as property if property is None + } else { + req.property + }; + + unsafe { + if req.target == self.atom_targets { + // Respond with supported targets: TARGETS, UTF8_STRING, STRING + let targets: [Atom; 3] = [self.atom_targets, self.atom_utf8, self.atom_string]; + (self.lib.x_change_property)( + self.display, + req.requestor, + property, + self.atom_targets, + 32, // 32-bit format + PROP_MODE_REPLACE, + targets.as_ptr() as *const c_void, + targets.len() as c_int, + ); + } else if req.target == self.atom_utf8 || req.target == self.atom_string { + // Respond with the actual clipboard text + (self.lib.x_change_property)( + self.display, + req.requestor, + property, + req.target, + 8, // 8-bit format + PROP_MODE_REPLACE, + self.clipboard_text.borrow().as_ptr() as *const c_void, + self.clipboard_text.borrow().len() as c_int, + ); + } + + // Send SelectionNotify to inform the requestor + let mut notify = std::mem::zeroed::(); + notify._type = SELECTION_NOTIFY as c_int; + notify._display = self.display; + notify.requestor = req.requestor; + notify.selection = req.selection; + notify.target = req.target; + notify.property = if req.target == self.atom_targets + || req.target == self.atom_utf8 + || req.target == self.atom_string + { + property + } else { + 0 // PropertyNone = unsupported target + }; + notify.time = req.time; + + (self.lib.x_send_event)( + self.display, + req.requestor, + 0, // propagate = False + NO_EVENT_MASK, + ¬ify as *const XSelectionNotifyEvent as *const c_void, + ); + (self.lib.x_flush)(self.display); + } + } + + fn paste_via_clipboard(&self, backspaces: usize, text: &str) -> bool { + // Set clipboard text directly via X11 + self.set_clipboard_text(text); + + // Handle any pending SelectionRequest events that may have queued + // (unlikely at this point, but be safe) + self.handle_pending_events(); + + // Send backspaces via XTest + if backspaces > 0 { + for _ in 0..backspaces { + self.send_keycode(14, false); // KEY_BACKSPACE + } + } + + // Send Ctrl+V via XTest to paste + unsafe { + // X11 keycodes: 37 = Ctrl_L, 55 = V + (self.lib.x_test_fake_key_event)(self.display, 37, 1, 0); + (self.lib.x_test_fake_key_event)(self.display, 55, 1, 0); + (self.lib.x_test_fake_key_event)(self.display, 55, 0, 0); + (self.lib.x_test_fake_key_event)(self.display, 37, 0, 0); + (self.lib.x_flush)(self.display); + } + + // Handle SelectionRequest events that come from the paste target + // Process events with a short spin loop (up to ~50ms) + for _ in 0..10 { + // Brief sleep to let X11 events propagate + std::thread::sleep(std::time::Duration::from_millis(5)); + self.handle_pending_events(); + } + + true + } + fn send_keycode(&self, keycode: u32, shift: bool) { unsafe { if shift { - (self.lib.x_test_fake_key_event)(self.display, 50, 1, 0); // Shift press + (self.lib.x_test_fake_key_event)(self.display, 50, 1, 0); } - (self.lib.x_test_fake_key_event)(self.display, keycode, 1, 0); // Key press - (self.lib.x_test_fake_key_event)(self.display, keycode, 0, 0); // Key release + (self.lib.x_test_fake_key_event)(self.display, keycode, 1, 0); + (self.lib.x_test_fake_key_event)(self.display, keycode, 0, 0); if shift { - (self.lib.x_test_fake_key_event)(self.display, 50, 0, 0); // Shift release + (self.lib.x_test_fake_key_event)(self.display, 50, 0, 0); } (self.lib.x_flush)(self.display); } } fn send_unicode_via_xdotool(&self, ch: char) { - // For Unicode chars, try ydotool first (uinput-based, works as root), - // then xdotool (X11 XTest) as fallback. let s = ch.to_string(); + + // Try ydotool first (uinput-based, works as root) let ydotool_ok = std::process::Command::new("ydotool") .args(["type", &s]) .output() @@ -251,6 +445,8 @@ impl X11Injector { if ydotool_ok { return; } + + // Try xdotool let xdotool_ok = std::process::Command::new("xdotool") .args(["type", "--clearmodifiers", &s]) .output() @@ -259,33 +455,47 @@ impl X11Injector { if xdotool_ok { return; } - // Clipboard fallback: xclip + Ctrl+V via XTEST - let copied = std::process::Command::new("xclip") - .args(["-selection", "clipboard"]) - .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 copied { - unsafe { - (self.lib.x_test_fake_key_event)(self.display, 37, 1, 0); // Ctrl press (X11 keycode) - (self.lib.x_test_fake_key_event)(self.display, 55, 1, 0); // V press (X11 keycode) - (self.lib.x_test_fake_key_event)(self.display, 55, 0, 0); // V release - (self.lib.x_test_fake_key_event)(self.display, 37, 0, 0); // Ctrl release - (self.lib.x_flush)(self.display); + + // Fallback: direct X11 clipboard + Ctrl+V + self.paste_via_clipboard(0, &s); + } +} + +impl Drop for X11Injector { + fn drop(&mut self) { + unsafe { + if self.clipboard_window != 0 && !self.display.is_null() { + (self.lib.x_destroy_window)(self.display, self.clipboard_window); } + (self.lib.x_close_display)(self.display); } } } +#[repr(C)] +struct XSelectionNotifyEvent { + _type: c_int, + _serial: u64, + _send_event: c_int, + _display: *mut Display, + requestor: Window, + selection: Atom, + target: Atom, + property: Atom, + time: Time, +} + impl KeyInjector for X11Injector { + fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult { + unsafe { + (self.lib.x_test_fake_key_event)(self.display, keycode as u32, value, 0); + (self.lib.x_flush)(self.display); + } + InjectResult::Success + } + fn send_backspace(&self) -> InjectResult { - self.send_keycode(14, false); // KEY_BACKSPACE + self.send_keycode(14, false); InjectResult::Success } @@ -308,11 +518,10 @@ impl KeyInjector for X11Injector { fn inject_replacement(&self, backspaces: usize, text: &str) -> InjectResult { let is_ascii = text.chars().all(|c| char_to_keycode(c).is_some()); - if is_ascii { if backspaces > 0 { for _ in 0..backspaces { - self.send_keycode(14, false); // KEY_BACKSPACE + self.send_keycode(14, false); } } for ch in text.chars() { @@ -323,80 +532,11 @@ impl KeyInjector for X11Injector { return InjectResult::Success; } - // Contains Unicode: try xdotool with both backspaces and text in a single command - let has_xdotool = std::process::Command::new("which") - .arg("xdotool") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - if has_xdotool { - let mut args = Vec::new(); - if backspaces > 0 { - args.push("key".to_string()); - for _ in 0..backspaces { - args.push("BackSpace".to_string()); - } - } - if !text.is_empty() { - args.push("type".to_string()); - args.push("--clearmodifiers".to_string()); - args.push(text.to_string()); - } - - let ok = std::process::Command::new("xdotool") - .args(&args) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if ok { - return InjectResult::Success; - } - } - - // Fallback: Clipboard copy + paste. - // Send backspaces via XTEST, then copy to clipboard, then paste (Ctrl+V) via XTEST. - // Since all XTEST key events go through the same display connection, their ordering is guaranteed. - let mut clipboard_cmd = std::process::Command::new("xclip"); - clipboard_cmd.args(["-selection", "clipboard"]); - clipboard_cmd.stdin(std::process::Stdio::piped()); - let copied = clipboard_cmd - .spawn() - .and_then(|mut child| { - use std::io::Write; - child.stdin.take().unwrap().write_all(text.as_bytes())?; - child.wait() - }) - .map(|status| status.success()) - .unwrap_or(false); - - if copied { - if backspaces > 0 { - for _ in 0..backspaces { - self.send_keycode(14, false); // KEY_BACKSPACE - } - } - unsafe { - (self.lib.x_test_fake_key_event)(self.display, 37, 1, 0); // Ctrl press (X11 keycode) - (self.lib.x_test_fake_key_event)(self.display, 55, 1, 0); // V press (X11 keycode) - (self.lib.x_test_fake_key_event)(self.display, 55, 0, 0); // V release - (self.lib.x_test_fake_key_event)(self.display, 37, 0, 0); // Ctrl release - (self.lib.x_flush)(self.display); - } - InjectResult::Success - } else { - // Absolute last resort: backspaces via XTEST followed by individual unicode send_unicode_via_xdotool - if backspaces > 0 { - for _ in 0..backspaces { - self.send_keycode(14, false); // KEY_BACKSPACE - } - } - for ch in text.chars() { - self.send_char(ch); - } - InjectResult::Success - } + // Contains Unicode: use direct X11 clipboard + XTest Ctrl+V + self.paste_via_clipboard(backspaces, text); + InjectResult::Success } + fn flush(&self) -> InjectResult { unsafe { (self.lib.x_flush)(self.display); @@ -404,20 +544,7 @@ impl KeyInjector for X11Injector { InjectResult::Success } - /// Record that Unicode text was pasted via clipboard (for future delete/backspace support) fn update_pasted_text(&self, _text: &str) -> InjectResult { - eprintln!( - "[vietc] X11 update_pasted_text: recorded text (len={})", - _text.len() - ); InjectResult::Success } } - -impl Drop for X11Injector { - fn drop(&mut self) { - unsafe { - (self.lib.x_close_display)(self.display); - } - } -} diff --git a/vietc.toml b/vietc.toml index f603a54..66ae086 100644 --- a/vietc.toml +++ b/vietc.toml @@ -1,8 +1,8 @@ # Viet+ IME Configuration -input_method = "telex" +input_method = "vni" toggle_key = "space" -start_enabled = true +start_enabled = false grab = true [auto_restore]