evdev: poll all keyboard devices simultaneously; x11: replace XRecord capture with XQueryKeymap polling
- open_keyboard_device() -> open_keyboard_devices(): returns Vec of all keyboard-capable evdev devices instead of just the first one - run_with_evdev() polls all device FDs via single libc::poll() call - Each device maintains independent key_state tracking - Added XQueryKeymap/XLookupString to X11Lib in protocol crate - X11KeymapCapture: new struct that polls X11 keymap every 10ms via XQueryKeymap, diffs consecutive polls for press/release detection, and uses XLookupString/Xutf8LookupString for char conversion - run_with_x11_keymap(): replaces segfaulting XRecord-based run_with_x11 as the primary X11 fallback path
This commit is contained in:
parent
88a64224b6
commit
6b2b42639f
2 changed files with 576 additions and 309 deletions
|
|
@ -855,15 +855,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try evdev first (more reliable than X11 XRecord in most environments).
|
// Try evdev first: open ALL keyboard-capable devices and poll them
|
||||||
// If the evdev device opens but produces no events (common in VMs where
|
// simultaneously. This handles VMs where input arrives on a different
|
||||||
// keyboard input bypasses the evdev grab), run_with_evdev will exit via
|
// event node than the first device found.
|
||||||
// the 30-second safety timeout — we fall through to X11 capture.
|
match open_keyboard_devices() {
|
||||||
match open_keyboard_device() {
|
Ok(mut devices) => {
|
||||||
Ok((device, path)) => {
|
|
||||||
log_info(&format!("[vietc] Keyboard device: {}", path));
|
|
||||||
match run_with_evdev(
|
match run_with_evdev(
|
||||||
device,
|
&mut devices,
|
||||||
&mut daemon,
|
&mut daemon,
|
||||||
shared_active_window.clone(),
|
shared_active_window.clone(),
|
||||||
shared_window_class.clone(),
|
shared_window_class.clone(),
|
||||||
|
|
@ -890,19 +888,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
#[cfg(feature = "x11")]
|
#[cfg(feature = "x11")]
|
||||||
if display != display::DisplayServer::Wayland {
|
if display != display::DisplayServer::Wayland {
|
||||||
if let Some(capture) = X11Capture::new() {
|
log_info("[vietc] Trying X11 keymap-based capture");
|
||||||
log_info("[vietc] X11 XRecord capture active — using X11 capture/injection");
|
match run_with_x11_keymap(
|
||||||
return run_with_x11(
|
&mut daemon,
|
||||||
capture,
|
shared_active_window.clone(),
|
||||||
&mut daemon,
|
shared_window_class.clone(),
|
||||||
shared_active_window,
|
config_changed.clone(),
|
||||||
shared_window_class,
|
status_changed.clone(),
|
||||||
config_changed,
|
engine_enabled.clone(),
|
||||||
status_changed,
|
display,
|
||||||
engine_enabled,
|
) {
|
||||||
);
|
Ok(()) => {
|
||||||
} else {
|
log_info("[vietc] X11 keymap returned, falling through to stdin mode");
|
||||||
log_info("[vietc] X11 not available, falling back");
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] X11 keymap exited with error: {} — falling back",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -920,12 +924,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error::Error>> {
|
fn open_keyboard_devices() -> Result<Vec<(evdev::Device, String)>, Box<dyn std::error::Error>> {
|
||||||
let dir = std::path::Path::new("/dev/input");
|
let dir = std::path::Path::new("/dev/input");
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
return Err("No /dev/input directory".into());
|
return Err("No /dev/input directory".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut devices: Vec<(evdev::Device, String)> = Vec::new();
|
||||||
let mut permission_denied_count = 0u32;
|
let mut permission_denied_count = 0u32;
|
||||||
let mut total_event_count = 0u32;
|
let mut total_event_count = 0u32;
|
||||||
|
|
||||||
|
|
@ -947,7 +952,12 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error:
|
||||||
.supported_keys()
|
.supported_keys()
|
||||||
.is_some_and(|k| k.contains(evdev::Key::KEY_A))
|
.is_some_and(|k| k.contains(evdev::Key::KEY_A))
|
||||||
{
|
{
|
||||||
return Ok((device, format!("{} ({})", entry.path().display(), dev_name)));
|
log_info(&format!(
|
||||||
|
"[vietc] Found keyboard device: {} ({})",
|
||||||
|
entry.path().display(),
|
||||||
|
dev_name
|
||||||
|
));
|
||||||
|
devices.push((device, format!("{} ({})", entry.path().display(), dev_name)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -960,8 +970,12 @@ fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !devices.is_empty() {
|
||||||
|
log_info(&format!("[vietc] Opened {} keyboard device(s)", devices.len()));
|
||||||
|
return Ok(devices);
|
||||||
|
}
|
||||||
|
|
||||||
if permission_denied_count > 0 {
|
if permission_denied_count > 0 {
|
||||||
// Check if user is in the group but session hasn't refreshed
|
|
||||||
let in_group_db = std::process::Command::new("groups")
|
let in_group_db = std::process::Command::new("groups")
|
||||||
.output()
|
.output()
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("input"))
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("input"))
|
||||||
|
|
@ -1006,19 +1020,27 @@ fn run_with_x11(
|
||||||
// press+release immediately, breaking held-key combos (Ctrl+C, Alt+Tab…).
|
// press+release immediately, breaking held-key combos (Ctrl+C, Alt+Tab…).
|
||||||
let mut pressed_keys: HashSet<u32> = HashSet::new();
|
let mut pressed_keys: HashSet<u32> = HashSet::new();
|
||||||
|
|
||||||
eprintln!("[vietc] X11 event loop starting");
|
use std::io::Write;
|
||||||
|
let _ = std::io::stderr().write_all(b"[vietc] X11 event loop starting\n");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let _ = std::io::stderr().write_all(b"[vietc] X11: check status_changed\n");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
if status_changed.load(Ordering::SeqCst) {
|
if status_changed.load(Ordering::SeqCst) {
|
||||||
daemon.sync_status_file();
|
daemon.sync_status_file();
|
||||||
status_changed.store(false, Ordering::SeqCst);
|
status_changed.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = std::io::stderr().write_all(b"[vietc] X11: check config_changed\n");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
if config_changed.load(Ordering::SeqCst) {
|
if config_changed.load(Ordering::SeqCst) {
|
||||||
daemon.reload_config();
|
daemon.reload_config();
|
||||||
config_changed.store(false, Ordering::SeqCst);
|
config_changed.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = std::io::stderr().write_all(b"[vietc] X11: lock active_window\n");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
{
|
{
|
||||||
let active_window = shared_active_window.lock().unwrap().clone();
|
let active_window = shared_active_window.lock().unwrap().clone();
|
||||||
if active_window != last_active_window {
|
if active_window != last_active_window {
|
||||||
|
|
@ -1027,6 +1049,8 @@ fn run_with_x11(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = std::io::stderr().write_all(b"[vietc] X11: lock window_class\n");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
if daemon.config.app_state.enabled {
|
if daemon.config.app_state.enabled {
|
||||||
let class = shared_window_class.lock().unwrap().clone();
|
let class = shared_window_class.lock().unwrap().clone();
|
||||||
if !class.is_empty() {
|
if !class.is_empty() {
|
||||||
|
|
@ -1042,11 +1066,13 @@ fn run_with_x11(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for events with 100ms timeout.
|
// Wait for events with 100ms timeout.
|
||||||
// SKIP_RECORD_EVENTS may still be true from a previous injection —
|
let _ = std::io::stderr().write_all(b"[vietc] X11: wait_for_event\n");
|
||||||
// drain_pipe drops any stale injected events while flag is true.
|
std::io::stderr().flush().ok();
|
||||||
let _got_data = capture.wait_for_event(100);
|
let _got_data = capture.wait_for_event(100);
|
||||||
// NOW safe to clear: any injected events from last iteration were dropped.
|
// NOW safe to clear: any injected events from last iteration were dropped.
|
||||||
SKIP_RECORD_EVENTS.store(false, Ordering::Relaxed);
|
SKIP_RECORD_EVENTS.store(false, Ordering::Relaxed);
|
||||||
|
let _ = std::io::stderr().write_all(b"[vietc] X11: next_event\n");
|
||||||
|
std::io::stderr().flush().ok();
|
||||||
let evt = capture.next_event();
|
let evt = capture.next_event();
|
||||||
if evt.is_none() {
|
if evt.is_none() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1118,8 +1144,143 @@ fn run_with_x11(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "x11")]
|
||||||
|
fn run_with_x11_keymap(
|
||||||
|
daemon: &mut Daemon,
|
||||||
|
shared_active_window: Arc<Mutex<String>>,
|
||||||
|
shared_window_class: Arc<Mutex<String>>,
|
||||||
|
config_changed: Arc<AtomicBool>,
|
||||||
|
status_changed: Arc<AtomicBool>,
|
||||||
|
_engine_enabled: Arc<AtomicBool>,
|
||||||
|
display: display::DisplayServer,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use vietc_protocol::x11_inject::X11KeymapCapture;
|
||||||
|
|
||||||
|
let mut capture = X11KeymapCapture::new()?;
|
||||||
|
let injector = create_injector(display)?;
|
||||||
|
let mut last_active_window = String::new();
|
||||||
|
let mut last_window_class = String::new();
|
||||||
|
let mut key_state: HashSet<u32> = HashSet::new();
|
||||||
|
|
||||||
|
log_info("[vietc] X11 keymap capture active");
|
||||||
|
loop {
|
||||||
|
if SIGNAL_EXIT.load(Ordering::SeqCst) {
|
||||||
|
log_info("[vietc] Exiting on signal");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
last_active_window = active_window.clone();
|
||||||
|
daemon.replay_reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if daemon.config.app_state.enabled {
|
||||||
|
let class = shared_window_class.lock().unwrap().clone();
|
||||||
|
if !class.is_empty() && class != last_window_class {
|
||||||
|
last_window_class = class.clone();
|
||||||
|
daemon.check_app_change_with(class.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll keymap for changes every 10ms
|
||||||
|
let events = capture.poll();
|
||||||
|
if events.is_empty() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update persistent key state
|
||||||
|
for (keycode, pressed) in &events {
|
||||||
|
if *pressed {
|
||||||
|
key_state.insert(*keycode);
|
||||||
|
} else {
|
||||||
|
key_state.remove(keycode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (keycode, pressed) in &events {
|
||||||
|
if !*pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let keycode = *keycode;
|
||||||
|
|
||||||
|
let shift_pressed = key_state.contains(&42) || key_state.contains(&54);
|
||||||
|
let ctrl_pressed = key_state.contains(&29) || key_state.contains(&97);
|
||||||
|
let alt_pressed = key_state.contains(&56) || key_state.contains(&100);
|
||||||
|
let caps_state = key_state.contains(&58);
|
||||||
|
|
||||||
|
let mut mod_state = 0i32;
|
||||||
|
if shift_pressed { mod_state |= 1; }
|
||||||
|
if caps_state { mod_state |= 2; }
|
||||||
|
if ctrl_pressed { mod_state |= 4; }
|
||||||
|
if alt_pressed { mod_state |= 8; }
|
||||||
|
|
||||||
|
let is_mod = ctrl_pressed || alt_pressed || key_state.contains(&125);
|
||||||
|
|
||||||
|
if is_mod {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine toggle: Ctrl+Space
|
||||||
|
if ctrl_pressed && keycode == 57 {
|
||||||
|
daemon.toggle();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method toggle: Ctrl+LeftShift
|
||||||
|
if ctrl_pressed && shift_pressed {
|
||||||
|
daemon.toggle_method();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password detection
|
||||||
|
if daemon.config.app_state.enabled {
|
||||||
|
let is_pw = daemon.app_state.is_password_field();
|
||||||
|
let currently_enabled = daemon.engine.is_enabled();
|
||||||
|
if is_pw && currently_enabled {
|
||||||
|
daemon.engine.set_enabled(false);
|
||||||
|
daemon.write_status();
|
||||||
|
} else if !is_pw && !currently_enabled && daemon.config.start_enabled {
|
||||||
|
let default_state = daemon.app_state.get_default_state();
|
||||||
|
if default_state {
|
||||||
|
daemon.engine.set_enabled(true);
|
||||||
|
daemon.write_status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use keymap lookup for character conversion
|
||||||
|
if let Some(ch) = capture.lookup_keycode(keycode, mod_state) {
|
||||||
|
let mut commands = daemon.process_key(ch);
|
||||||
|
if !commands.is_empty()
|
||||||
|
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
|
||||||
|
{
|
||||||
|
for cmd in &mut commands {
|
||||||
|
if let OutputCommand::Backspace(ref mut n) = cmd {
|
||||||
|
*n += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
execute_commands(&*injector, &commands, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run_with_evdev(
|
fn run_with_evdev(
|
||||||
mut device: evdev::Device,
|
devices: &mut Vec<(evdev::Device, String)>,
|
||||||
daemon: &mut Daemon,
|
daemon: &mut Daemon,
|
||||||
shared_active_window: Arc<Mutex<String>>,
|
shared_active_window: Arc<Mutex<String>>,
|
||||||
shared_window_class: Arc<Mutex<String>>,
|
shared_window_class: Arc<Mutex<String>>,
|
||||||
|
|
@ -1130,8 +1291,10 @@ fn run_with_evdev(
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let injector = create_injector(display)?;
|
let injector = create_injector(display)?;
|
||||||
|
|
||||||
let mut grabbed = if daemon.grab_enabled {
|
// Use the first device for grab (only one device can be grabbed at a time)
|
||||||
match device.grab() {
|
let primary_idx = 0usize;
|
||||||
|
let mut grabbed = if daemon.grab_enabled && !devices.is_empty() {
|
||||||
|
match devices[primary_idx].0.grab() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
log_info("[vietc] Keyboard grabbed — race condition eliminated");
|
log_info("[vietc] Keyboard grabbed — race condition eliminated");
|
||||||
true
|
true
|
||||||
|
|
@ -1146,64 +1309,64 @@ fn run_with_evdev(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log_info("[vietc] Keyboard grab disabled (config grab = false)");
|
if !daemon.grab_enabled {
|
||||||
log_info("[vietc] Set grab = true in vietc.toml to enable (needs root)");
|
log_info("[vietc] Keyboard grab disabled (config grab = false)");
|
||||||
|
log_info("[vietc] Set grab = true in vietc.toml to enable (needs root)");
|
||||||
|
}
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut consumed_keys: HashSet<u16> = HashSet::new();
|
let mut consumed_keys: HashSet<u16> = HashSet::new();
|
||||||
let mut last_active_window = String::new();
|
let mut last_active_window = String::new();
|
||||||
let mut last_window_class = String::new();
|
let mut last_window_class = String::new();
|
||||||
// Skip counter: after Unicode injection, skip N upcoming events
|
|
||||||
// (they're auto-repeat pile-up from the injection delay)
|
|
||||||
let mut skip_count = 0u32;
|
let mut skip_count = 0u32;
|
||||||
// Password detection: re-check every N key presses even without window change
|
|
||||||
// (catches in-terminal sudo prompts where window stays the same)
|
|
||||||
let mut password_check_counter: u32 = 0;
|
let mut password_check_counter: u32 = 0;
|
||||||
|
|
||||||
// Safety: if grab is active and no events arrive for 3 seconds,
|
|
||||||
// release the grab so the user isn't locked out, and continue in
|
|
||||||
// non-grabbed mode (events reach both X and the daemon; daemon
|
|
||||||
// applies backspace corrections via uinput).
|
|
||||||
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();
|
let mut last_key_time = std::time::Instant::now();
|
||||||
// Track consecutive idle polls for fast grab fallback
|
|
||||||
let mut idle_polls: u32 = 0;
|
let mut idle_polls: u32 = 0;
|
||||||
|
|
||||||
|
// Track key states for each device independently
|
||||||
|
let mut device_states: Vec<(evdev::AttributeSet<evdev::Key>, bool)> = devices
|
||||||
|
.iter()
|
||||||
|
.map(|(d, _)| {
|
||||||
|
let caps = is_caps_lock_on(d);
|
||||||
|
let state = d.get_key_state().ok().unwrap_or_else(evdev::AttributeSet::new);
|
||||||
|
(state, caps)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
log_info("[vietc] Event loop started");
|
log_info("[vietc] Event loop started");
|
||||||
loop {
|
loop {
|
||||||
// Check for signal (Ctrl+C, SIGTERM) — release grab before exit
|
|
||||||
if SIGNAL_EXIT.load(Ordering::SeqCst) {
|
if SIGNAL_EXIT.load(Ordering::SeqCst) {
|
||||||
if grabbed {
|
if grabbed && !devices.is_empty() {
|
||||||
let _ = device.ungrab();
|
let _ = devices[primary_idx].0.ungrab();
|
||||||
log_info("[vietc] Signal received — keyboard grab released");
|
log_info("[vietc] Signal received — keyboard grab released");
|
||||||
}
|
}
|
||||||
log_info("[vietc] Exiting on signal");
|
log_info("[vietc] Exiting on signal");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grab safety timeout: if the grabbed device produces no events
|
|
||||||
// (common in VMs where EVIOCGRAB breaks event delivery), release
|
|
||||||
// the grab after ~300ms idle and continue in non-grabbed mode
|
|
||||||
// where events reach both X and the daemon.
|
|
||||||
if grabbed && idle_polls >= 3 && last_event_time.elapsed() > std::time::Duration::from_millis(200) {
|
if grabbed && idle_polls >= 3 && last_event_time.elapsed() > std::time::Duration::from_millis(200) {
|
||||||
log_info(
|
log_info(
|
||||||
"[vietc] No events received via grab — releasing grab, continuing in non-grabbed evdev mode",
|
"[vietc] No events received via grab — releasing grab, continuing in non-grabbed evdev mode",
|
||||||
);
|
);
|
||||||
let _ = device.ungrab();
|
let _ = devices[primary_idx].0.ungrab();
|
||||||
grabbed = false;
|
grabbed = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll evdev fd with 100ms timeout so the loop stays responsive
|
// Poll ALL devices simultaneously
|
||||||
// even when no keyboard events arrive (e.g. VM doesn't route input
|
let mut pfds: Vec<libc::pollfd> = devices
|
||||||
// through the grabbed device).
|
.iter()
|
||||||
let mut pfd = libc::pollfd {
|
.map(|(d, _)| libc::pollfd {
|
||||||
fd: device.as_raw_fd(),
|
fd: d.as_raw_fd(),
|
||||||
events: libc::POLLIN,
|
events: libc::POLLIN,
|
||||||
revents: 0,
|
revents: 0,
|
||||||
};
|
})
|
||||||
let poll_ret = unsafe { libc::poll(&mut pfd, 1, 100) };
|
.collect();
|
||||||
|
|
||||||
|
let poll_ret = unsafe { libc::poll(pfds.as_mut_ptr(), pfds.len() as libc::nfds_t, 100) };
|
||||||
if poll_ret < 0 {
|
if poll_ret < 0 {
|
||||||
let err = std::io::Error::last_os_error();
|
let err = std::io::Error::last_os_error();
|
||||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||||
|
|
@ -1217,8 +1380,6 @@ fn run_with_evdev(
|
||||||
}
|
}
|
||||||
if poll_ret == 0 {
|
if poll_ret == 0 {
|
||||||
idle_polls += 1;
|
idle_polls += 1;
|
||||||
// No events available — check for background window changes even
|
|
||||||
// without a keypress (the background thread polls every 250ms).
|
|
||||||
if daemon.config.app_state.enabled {
|
if daemon.config.app_state.enabled {
|
||||||
let class = shared_window_class.lock().unwrap().clone();
|
let class = shared_window_class.lock().unwrap().clone();
|
||||||
if !class.is_empty() && class != last_window_class {
|
if !class.is_empty() && class != last_window_class {
|
||||||
|
|
@ -1230,27 +1391,6 @@ fn run_with_evdev(
|
||||||
}
|
}
|
||||||
idle_polls = 0;
|
idle_polls = 0;
|
||||||
|
|
||||||
let caps = is_caps_lock_on(&device);
|
|
||||||
let mut key_state = device
|
|
||||||
.get_key_state()
|
|
||||||
.ok()
|
|
||||||
.unwrap_or_else(evdev::AttributeSet::new);
|
|
||||||
let events = match device.fetch_events() {
|
|
||||||
Ok(events) => events,
|
|
||||||
Err(e) => {
|
|
||||||
if e.kind() == std::io::ErrorKind::Interrupted {
|
|
||||||
// SIGINT/SIGTERM received — loop back to signal check
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
log_info(&format!(
|
|
||||||
"[vietc] fetch_events error (non-interrupted): {:?} — exiting",
|
|
||||||
e
|
|
||||||
));
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
last_event_time = std::time::Instant::now();
|
|
||||||
|
|
||||||
// Check for status changes instantly
|
// Check for status changes instantly
|
||||||
if status_changed.load(Ordering::SeqCst) {
|
if status_changed.load(Ordering::SeqCst) {
|
||||||
daemon.sync_status_file();
|
daemon.sync_status_file();
|
||||||
|
|
@ -1263,261 +1403,250 @@ fn run_with_evdev(
|
||||||
config_changed.store(false, Ordering::SeqCst);
|
config_changed.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
for event in events {
|
// Process events from whichever device(s) have data ready
|
||||||
if let evdev::InputEventKind::Key(key) = event.kind() {
|
for (i, pfd) in pfds.iter().enumerate() {
|
||||||
let value = event.value();
|
if (pfd.revents & libc::POLLIN) == 0 {
|
||||||
let keycode = key.0;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Update key state dynamically
|
let (ref mut device, ref _name) = devices[i];
|
||||||
if value == 1 {
|
let caps = device_states[i].1;
|
||||||
key_state.insert(key);
|
let mut key_state = std::mem::take(&mut device_states[i].0);
|
||||||
} else if value == 0 {
|
|
||||||
key_state.remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Completely bypass all IME processing/interception for terminal emulators, IDE terminals, and games
|
let events = match device.fetch_events() {
|
||||||
if daemon.is_current_app_bypassed() {
|
Ok(events) => events,
|
||||||
if grabbed {
|
Err(e) => {
|
||||||
injector.send_key_event(keycode, value);
|
if e.kind() == std::io::ErrorKind::Interrupted {
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if value == 1 && is_toggle_combination_state(&key_state, &daemon.config.toggle_key)
|
|
||||||
{
|
|
||||||
daemon.toggle();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+LeftShift: toggle VNI/Telex input method
|
|
||||||
if value == 1 && is_method_toggle_state(&key_state)
|
|
||||||
{
|
|
||||||
daemon.toggle_method();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password field check: disable engine if typing into a password field
|
|
||||||
if value == 1 {
|
|
||||||
let is_pw = daemon.app_state.is_password_field();
|
|
||||||
let currently_enabled = daemon.engine.is_enabled();
|
|
||||||
if is_pw && currently_enabled {
|
|
||||||
daemon.engine.set_enabled(false);
|
|
||||||
daemon.write_status();
|
|
||||||
log_info("[vietc] Password field detected — engine disabled");
|
|
||||||
} else if !is_pw && !currently_enabled && daemon.config.start_enabled {
|
|
||||||
// Only re-enable if we're not in a manual toggle state
|
|
||||||
let default_state = daemon.app_state.get_default_state();
|
|
||||||
if default_state {
|
|
||||||
daemon.engine.set_enabled(true);
|
|
||||||
daemon.write_status();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !grabbed {
|
|
||||||
// Legacy mode: raw keystrokes reach the application directly.
|
|
||||||
// Use process_key for corrections; +1 backspace for control
|
|
||||||
// keys that landed on screen as literal characters.
|
|
||||||
if value != 1 {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if is_modifier_pressed(&key_state) {
|
log_info(&format!(
|
||||||
continue;
|
"[vietc] fetch_events error on device {}: {:?} — exiting",
|
||||||
}
|
i, e
|
||||||
if let Some(ch) = key_to_char(key) {
|
));
|
||||||
let mut commands = daemon.process_key(ch);
|
return Err(e.into());
|
||||||
if !commands.is_empty()
|
}
|
||||||
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
|
};
|
||||||
{
|
last_event_time = std::time::Instant::now();
|
||||||
for cmd in &mut commands {
|
|
||||||
if let OutputCommand::Backspace(ref mut n) = cmd {
|
|
||||||
*n += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
execute_commands(&*injector, &commands, false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Grabbing mode: all output goes through uinput only.
|
|
||||||
|
|
||||||
// If Ctrl, Alt, or Meta/Super is pressed, bypass the engine completely and forward raw key events.
|
for event in events {
|
||||||
if is_modifier_pressed(&key_state) {
|
if let evdev::InputEventKind::Key(key) = event.kind() {
|
||||||
injector.send_key_event(keycode, value);
|
let value = event.value();
|
||||||
continue;
|
let keycode = key.0;
|
||||||
}
|
|
||||||
|
|
||||||
// Backspace in grab mode: pop engine, inject via uinput.
|
|
||||||
if key == evdev::Key::KEY_BACKSPACE {
|
|
||||||
if value == 1 || value == 2 {
|
|
||||||
daemon.engine.process_key('\x08');
|
|
||||||
injector.send_key_event(14, 1);
|
|
||||||
injector.send_key_event(14, 0);
|
|
||||||
}
|
|
||||||
consumed_keys.insert(keycode);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Update key state dynamically
|
||||||
if value == 1 {
|
if value == 1 {
|
||||||
// Press: process through engine
|
key_state.insert(key);
|
||||||
if consumed_keys.contains(&keycode) {
|
} else if value == 0 {
|
||||||
consumed_keys.remove(&keycode);
|
key_state.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completely bypass all IME processing/interception for terminal emulators, IDE terminals, and games
|
||||||
|
if daemon.is_current_app_bypassed() {
|
||||||
|
if grabbed {
|
||||||
|
injector.send_key_event(keycode, value);
|
||||||
}
|
}
|
||||||
if let Some(mut ch) = key_to_char(key) {
|
continue;
|
||||||
// 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)
|
if value == 1 && is_toggle_combination_state(&key_state, &daemon.config.toggle_key)
|
||||||
let active_window_id = shared_active_window.lock().unwrap().clone();
|
{
|
||||||
let mut new_window = None;
|
daemon.toggle();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// On Wayland, window ID may not change (native Wayland apps
|
// Ctrl+LeftShift: toggle VNI/Telex input method
|
||||||
// don't have X11 IDs), so also check window class as a fallback.
|
if value == 1 && is_method_toggle_state(&key_state)
|
||||||
let active_window_class = shared_window_class.lock().unwrap().clone();
|
{
|
||||||
|
daemon.toggle_method();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if active_window_id != last_active_window {
|
// Password field check: disable engine if typing into a password field
|
||||||
new_window = Some(active_window_id.clone());
|
if value == 1 {
|
||||||
} else if !active_window_class.is_empty()
|
let is_pw = daemon.app_state.is_password_field();
|
||||||
&& active_window_class != last_window_class
|
let currently_enabled = daemon.engine.is_enabled();
|
||||||
|
if is_pw && currently_enabled {
|
||||||
|
daemon.engine.set_enabled(false);
|
||||||
|
daemon.write_status();
|
||||||
|
log_info("[vietc] Password field detected — engine disabled");
|
||||||
|
} else if !is_pw && !currently_enabled && daemon.config.start_enabled {
|
||||||
|
let default_state = daemon.app_state.get_default_state();
|
||||||
|
if default_state {
|
||||||
|
daemon.engine.set_enabled(true);
|
||||||
|
daemon.write_status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !grabbed {
|
||||||
|
if value != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_modifier_pressed(&key_state) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ch) = key_to_char(key) {
|
||||||
|
let mut commands = daemon.process_key(ch);
|
||||||
|
if !commands.is_empty()
|
||||||
|
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
|
||||||
{
|
{
|
||||||
// Window ID same but class changed — treat as window switch
|
for cmd in &mut commands {
|
||||||
// (this covers Wayland native app switches)
|
if let OutputCommand::Backspace(ref mut n) = cmd {
|
||||||
new_window = Some(active_window_class.clone());
|
*n += 1;
|
||||||
} else {
|
break;
|
||||||
// Always verify active window on every keypress — window
|
|
||||||
// switches under 100ms can leak the old engine buffer.
|
|
||||||
if let Some(id) = app_state::get_active_window_id() {
|
|
||||||
if id != active_window_id {
|
|
||||||
new_window = Some(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
execute_commands(&*injector, &commands, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if is_modifier_pressed(&key_state) {
|
||||||
|
injector.send_key_event(keycode, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(id) = new_window {
|
if key == evdev::Key::KEY_BACKSPACE {
|
||||||
log_info(&format!(
|
if value == 1 || value == 2 {
|
||||||
"[vietc] Window changed: '{}' -> '{}' (gap={:?})",
|
daemon.engine.process_key('\x08');
|
||||||
last_active_window, id, gap
|
injector.send_key_event(14, 1);
|
||||||
));
|
injector.send_key_event(14, 0);
|
||||||
last_active_window = id.clone();
|
|
||||||
// Save the window class when it changes (covers Wayland
|
|
||||||
// where IDs might be identical for different apps)
|
|
||||||
if !active_window_class.is_empty() {
|
|
||||||
last_window_class = active_window_class.clone();
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-check password field status on window change
|
|
||||||
if daemon.config.password_detection.enabled {
|
|
||||||
let is_pw = daemon.app_state.check_password_field();
|
|
||||||
if is_pw && daemon.engine.is_enabled() {
|
|
||||||
daemon.engine.set_enabled(false);
|
|
||||||
daemon.write_status();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
consumed_keys.insert(keycode);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Periodic password re-check (every 30 keystrokes) —
|
if value == 1 {
|
||||||
// catches in-terminal sudo prompts where the window
|
if consumed_keys.contains(&keycode) {
|
||||||
// doesn't change but the focused widget becomes a
|
consumed_keys.remove(&keycode);
|
||||||
// password field (detected via AT-SPI2).
|
}
|
||||||
if daemon.config.password_detection.enabled {
|
if let Some(mut ch) = key_to_char(key) {
|
||||||
password_check_counter += 1;
|
let gap = last_key_time.elapsed();
|
||||||
if password_check_counter >= 30 {
|
last_key_time = std::time::Instant::now();
|
||||||
password_check_counter = 0;
|
|
||||||
let is_pw = daemon.app_state.check_password_field();
|
let active_window_id = shared_active_window.lock().unwrap().clone();
|
||||||
let currently_enabled = daemon.engine.is_enabled();
|
let mut new_window = None;
|
||||||
if is_pw && currently_enabled {
|
let active_window_class = shared_window_class.lock().unwrap().clone();
|
||||||
daemon.engine.set_enabled(false);
|
|
||||||
daemon.write_status();
|
if active_window_id != last_active_window {
|
||||||
log_info("[vietc] Password field detected (periodic) — engine disabled");
|
new_window = Some(active_window_id.clone());
|
||||||
} else if !is_pw && !currently_enabled {
|
} else if !active_window_class.is_empty()
|
||||||
if daemon.app_state.get_default_state() {
|
&& active_window_class != last_window_class
|
||||||
daemon.engine.set_enabled(true);
|
{
|
||||||
daemon.write_status();
|
new_window = Some(active_window_class.clone());
|
||||||
|
} else {
|
||||||
|
if let Some(id) = app_state::get_active_window_id() {
|
||||||
|
if id != active_window_id {
|
||||||
|
new_window = Some(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let shift = is_modifier_held_shift(&key_state);
|
if let Some(id) = new_window {
|
||||||
if ch.is_ascii_alphabetic() && (shift ^ caps) {
|
log_info(&format!(
|
||||||
ch = ch.to_ascii_uppercase();
|
"[vietc] Window changed: '{}' -> '{}' (gap={:?})",
|
||||||
}
|
last_active_window, id, gap
|
||||||
let buf_before = daemon.engine.buffer().chars().count();
|
));
|
||||||
let commands = daemon.process_key(ch);
|
last_active_window = id.clone();
|
||||||
if !commands.is_empty() {
|
if !active_window_class.is_empty() {
|
||||||
log_info(&format!(
|
last_window_class = active_window_class.clone();
|
||||||
"[vietc] inject: engine={} ch='{}' buf={} cmds={:?}",
|
}
|
||||||
if daemon.engine.is_enabled() { "VN" } else { "EN" },
|
daemon.engine.reset();
|
||||||
ch,
|
daemon.replay_reset();
|
||||||
buf_before,
|
|
||||||
commands
|
if daemon.config.app_state.enabled {
|
||||||
));
|
let class = shared_window_class.lock().unwrap().clone();
|
||||||
consumed_keys.insert(keycode);
|
let class = if class.is_empty() {
|
||||||
execute_commands(&*injector, &commands, false);
|
app_state::get_focused_window_class().unwrap_or_default()
|
||||||
// Flush chars: forward raw key after injection.
|
} else {
|
||||||
// When engine is disabled (English mode), the Insert event
|
class
|
||||||
// already contains the character — forwarding raw key
|
};
|
||||||
// would double-inject (double space on Ctrl+Space toggle).
|
daemon.check_app_change_with(class);
|
||||||
if is_flush_char(ch) && daemon.engine.is_enabled() {
|
}
|
||||||
injector.send_key_event(keycode, 1);
|
|
||||||
injector.send_key_event(keycode, 0);
|
if daemon.config.password_detection.enabled {
|
||||||
|
let is_pw = daemon.app_state.check_password_field();
|
||||||
|
if is_pw && daemon.engine.is_enabled() {
|
||||||
|
daemon.engine.set_enabled(false);
|
||||||
|
daemon.write_status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if daemon.config.password_detection.enabled {
|
||||||
|
password_check_counter += 1;
|
||||||
|
if password_check_counter >= 30 {
|
||||||
|
password_check_counter = 0;
|
||||||
|
let is_pw = daemon.app_state.check_password_field();
|
||||||
|
let currently_enabled = daemon.engine.is_enabled();
|
||||||
|
if is_pw && currently_enabled {
|
||||||
|
daemon.engine.set_enabled(false);
|
||||||
|
daemon.write_status();
|
||||||
|
log_info("[vietc] Password field detected (periodic) — engine disabled");
|
||||||
|
} else if !is_pw && !currently_enabled {
|
||||||
|
if daemon.app_state.get_default_state() {
|
||||||
|
daemon.engine.set_enabled(true);
|
||||||
|
daemon.write_status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shift = is_modifier_held_shift(&key_state);
|
||||||
|
if ch.is_ascii_alphabetic() && (shift ^ caps) {
|
||||||
|
ch = ch.to_ascii_uppercase();
|
||||||
|
}
|
||||||
|
let buf_before = daemon.engine.buffer().chars().count();
|
||||||
|
let commands = daemon.process_key(ch);
|
||||||
|
if !commands.is_empty() {
|
||||||
|
log_info(&format!(
|
||||||
|
"[vietc] inject: engine={} ch='{}' buf={} cmds={:?}",
|
||||||
|
if daemon.engine.is_enabled() { "VN" } else { "EN" },
|
||||||
|
ch,
|
||||||
|
buf_before,
|
||||||
|
commands
|
||||||
|
));
|
||||||
|
consumed_keys.insert(keycode);
|
||||||
|
execute_commands(&*injector, &commands, false);
|
||||||
|
if is_flush_char(ch) && daemon.engine.is_enabled() {
|
||||||
|
injector.send_key_event(keycode, 1);
|
||||||
|
injector.send_key_event(keycode, 0);
|
||||||
|
}
|
||||||
|
skip_count = 3;
|
||||||
|
} else if daemon.engine.is_enabled()
|
||||||
|
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
|
||||||
|
&& daemon.engine.buffer().chars().count() <= buf_before
|
||||||
|
{
|
||||||
|
consumed_keys.insert(keycode);
|
||||||
|
} else {
|
||||||
|
injector.send_key_event(keycode, 1);
|
||||||
}
|
}
|
||||||
// Skip upcoming auto-repeat pile-up from injection delay
|
|
||||||
skip_count = 3;
|
|
||||||
} else if daemon.engine.is_enabled()
|
|
||||||
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
|
|
||||||
&& daemon.engine.buffer().chars().count() <= buf_before
|
|
||||||
{
|
|
||||||
// Tone/mark key truly absorbed with no effect (no
|
|
||||||
// literal character appended) — consume silently.
|
|
||||||
// When the key is instead kept as a literal base
|
|
||||||
// letter (e.g. leading "x", the "r" in "tr"), the
|
|
||||||
// buffer grows and we must forward it like any
|
|
||||||
// other character so it reaches the screen.
|
|
||||||
consumed_keys.insert(keycode);
|
|
||||||
} else {
|
} else {
|
||||||
injector.send_key_event(keycode, 1);
|
injector.send_key_event(keycode, 1);
|
||||||
}
|
}
|
||||||
} else {
|
} else if value == 2 {
|
||||||
injector.send_key_event(keycode, 1);
|
if consumed_keys.contains(&keycode) || skip_count > 0 {
|
||||||
|
if skip_count > 0 { skip_count -= 1; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
injector.send_key_event(keycode, 2);
|
||||||
|
} else if value == 0 {
|
||||||
|
if consumed_keys.contains(&keycode) {
|
||||||
|
consumed_keys.remove(&keycode);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
injector.send_key_event(keycode, 0);
|
||||||
}
|
}
|
||||||
} else if value == 2 {
|
|
||||||
// Auto-repeat: skip if consumed or during injection drain
|
|
||||||
if consumed_keys.contains(&keycode) || skip_count > 0 {
|
|
||||||
if skip_count > 0 { skip_count -= 1; }
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
injector.send_key_event(keycode, 2);
|
|
||||||
} else if value == 0 {
|
|
||||||
// Release: skip if consumed, else forward
|
|
||||||
if consumed_keys.contains(&keycode) {
|
|
||||||
consumed_keys.remove(&keycode);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
injector.send_key_event(keycode, 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save updated key state back
|
||||||
|
device_states[i].0 = key_state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1554,10 +1683,10 @@ fn run_stdin_mode(
|
||||||
config_changed.store(false, Ordering::SeqCst);
|
config_changed.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok((device, path)) = open_keyboard_device() {
|
if let Ok(mut devices) = open_keyboard_devices() {
|
||||||
log_info(&format!("[vietc] Keyboard device found: {}", path));
|
log_info(&format!("[vietc] Keyboard device(s) found: {}", devices.len()));
|
||||||
return run_with_evdev(
|
return run_with_evdev(
|
||||||
device,
|
&mut devices,
|
||||||
daemon,
|
daemon,
|
||||||
shared_active_window,
|
shared_active_window,
|
||||||
shared_window_class,
|
shared_window_class,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,26 @@ type Display = c_void;
|
||||||
type Window = u64;
|
type Window = u64;
|
||||||
type Atom = u64;
|
type Atom = u64;
|
||||||
type Time = u64;
|
type Time = u64;
|
||||||
|
type KeySym = u64;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct XKeyEvent {
|
||||||
|
_type: c_int,
|
||||||
|
_serial: u64,
|
||||||
|
_send_event: c_int,
|
||||||
|
_display: *mut Display,
|
||||||
|
window: u64,
|
||||||
|
_root: u64,
|
||||||
|
_subwindow: u64,
|
||||||
|
_time: u64,
|
||||||
|
_x: c_int,
|
||||||
|
_y: c_int,
|
||||||
|
_x_root: c_int,
|
||||||
|
_y_root: c_int,
|
||||||
|
state: c_int,
|
||||||
|
keycode: u32,
|
||||||
|
_same_screen: c_int,
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
|
fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
|
||||||
|
|
@ -40,6 +60,9 @@ struct X11Lib {
|
||||||
x_destroy_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_pending: unsafe extern "C" fn(*mut Display) -> c_int,
|
||||||
x_next_event: unsafe extern "C" fn(*mut Display, *mut XEvent),
|
x_next_event: unsafe extern "C" fn(*mut Display, *mut XEvent),
|
||||||
|
x_query_keymap: unsafe extern "C" fn(*mut Display, *mut c_char) -> c_int,
|
||||||
|
x_lookup_string: unsafe extern "C" fn(*const XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int,
|
||||||
|
x_utf8_lookup_string: Option<unsafe extern "C" fn(*mut c_void, *const XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl X11Lib {
|
impl X11Lib {
|
||||||
|
|
@ -95,6 +118,12 @@ impl X11Lib {
|
||||||
let x_destroy_window = sym!(x11_handle, "XDestroyWindow");
|
let x_destroy_window = sym!(x11_handle, "XDestroyWindow");
|
||||||
let x_pending = sym!(x11_handle, "XPending");
|
let x_pending = sym!(x11_handle, "XPending");
|
||||||
let x_next_event = sym!(x11_handle, "XNextEvent");
|
let x_next_event = sym!(x11_handle, "XNextEvent");
|
||||||
|
let x_query_keymap = sym!(x11_handle, "XQueryKeymap");
|
||||||
|
let x_lookup_string = sym!(x11_handle, "XLookupString");
|
||||||
|
let x_utf8_lookup_string = {
|
||||||
|
let p = dlsym(x11_handle, b"Xutf8LookupString\0".as_ptr() as *const c_char);
|
||||||
|
if p.is_null() { None } else { Some(std::mem::transmute(p)) }
|
||||||
|
};
|
||||||
let x_test_fake_key_event = sym!(xtst_handle, "XTestFakeKeyEvent");
|
let x_test_fake_key_event = sym!(xtst_handle, "XTestFakeKeyEvent");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
@ -114,6 +143,9 @@ impl X11Lib {
|
||||||
x_destroy_window,
|
x_destroy_window,
|
||||||
x_pending,
|
x_pending,
|
||||||
x_next_event,
|
x_next_event,
|
||||||
|
x_query_keymap,
|
||||||
|
x_lookup_string,
|
||||||
|
x_utf8_lookup_string,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -553,3 +585,109 @@ impl KeyInjector for X11Injector {
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// X11 keymap-based capture: polls XQueryKeymap periodically to detect
|
||||||
|
/// key presses/releases. No XRecord, no XGrabKeyboard — works on any X11
|
||||||
|
/// system including VMs where evdev produces no events.
|
||||||
|
pub struct X11KeymapCapture {
|
||||||
|
lib: X11Lib,
|
||||||
|
display: *mut Display,
|
||||||
|
prev_keys: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for X11KeymapCapture {}
|
||||||
|
|
||||||
|
impl X11KeymapCapture {
|
||||||
|
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let lib = X11Lib::new()?;
|
||||||
|
unsafe {
|
||||||
|
let display = (lib.x_open_display)(std::ptr::null());
|
||||||
|
if display.is_null() {
|
||||||
|
return Err("Cannot open X11 display".into());
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
lib,
|
||||||
|
display,
|
||||||
|
prev_keys: [0u8; 32],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the current keymap and return any newly pressed or released keycodes.
|
||||||
|
/// Returns a Vec of (keycode_in_evdev_format, pressed) tuples.
|
||||||
|
/// X11 keycodes use offset 8 from evdev codes: evdev = x11 - 8.
|
||||||
|
pub fn poll(&mut self) -> Vec<(u32, bool)> {
|
||||||
|
let mut keys = [0u8; 32];
|
||||||
|
unsafe {
|
||||||
|
(self.lib.x_query_keymap)(self.display, keys.as_mut_ptr() as *mut c_char);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for i in 0..32 {
|
||||||
|
let changed = keys[i] ^ self.prev_keys[i];
|
||||||
|
if changed == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for bit in 0..8 {
|
||||||
|
if (changed >> bit) & 1 != 0 {
|
||||||
|
let x11_keycode = (i * 8 + bit) as u32;
|
||||||
|
let pressed = (keys[i] >> bit) & 1;
|
||||||
|
// Convert from X11 keycode to evdev keycode (subtract 8)
|
||||||
|
if x11_keycode >= 8 {
|
||||||
|
events.push((x11_keycode - 8, pressed == 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.prev_keys = keys;
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an evdev keycode + modifier state to a character.
|
||||||
|
/// `state` is the X11 modifier bitmask (Shift=1, Lock=2, Ctrl=4, Mod1=8, etc.)
|
||||||
|
pub fn lookup_keycode(&self, keycode: u32, state: c_int) -> Option<char> {
|
||||||
|
let x11_keycode = keycode + 8;
|
||||||
|
unsafe {
|
||||||
|
let mut xke: XKeyEvent = std::mem::zeroed();
|
||||||
|
xke._type = 2; // KeyPress
|
||||||
|
xke._display = self.display;
|
||||||
|
xke.keycode = x11_keycode;
|
||||||
|
xke.state = state;
|
||||||
|
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
let mut keysym: KeySym = 0;
|
||||||
|
let len = if let Some(xutf8) = self.lib.x_utf8_lookup_string {
|
||||||
|
xutf8(
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
&mut xke as *mut XKeyEvent,
|
||||||
|
buf.as_mut_ptr() as *mut c_char,
|
||||||
|
buf.len() as c_int,
|
||||||
|
&mut keysym,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(self.lib.x_lookup_string)(
|
||||||
|
&mut xke as *mut XKeyEvent,
|
||||||
|
buf.as_mut_ptr() as *mut c_char,
|
||||||
|
buf.len() as c_int,
|
||||||
|
&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 X11KeymapCapture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
(self.lib.x_close_display)(self.display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue