diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a522e53..07847a6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,9 @@ tauri-plugin-single-instance = "2" tauri-plugin-process = "2" tauri-plugin-os = "2" +[target.'cfg(target_os = "linux")'.dependencies] +evdev = "0.13" + [features] # by default Tauri runs in production mode # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL diff --git a/src-tauri/src/core/device.rs b/src-tauri/src/core/device.rs index 8f1c649..3e803ce 100644 --- a/src-tauri/src/core/device.rs +++ b/src-tauri/src/core/device.rs @@ -4,6 +4,9 @@ use serde_json::{Value, json}; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{AppHandle, Emitter}; +#[cfg(target_os = "linux")] +mod linux_evdev; + static IS_RUNNING: AtomicBool = AtomicBool::new(false); #[derive(Debug, Clone, Serialize)] @@ -28,6 +31,20 @@ pub fn start_listening(app_handle: AppHandle) { IS_RUNNING.store(true, Ordering::SeqCst); + #[cfg(target_os = "linux")] + { + let wayland = linux_evdev::is_wayland(); + if wayland { + println!( + "[device] Wayland detected – using evdev for global input." + ); + linux_evdev::start_evdev_listener(app_handle); + return; + } + // X11: fall through to rdev listener below. + println!("[device] X11 detected – using rdev for global input."); + } + let callback = move |event: Event| { let device = match event.event_type { EventType::ButtonPress(button) => DeviceEvent { diff --git a/src-tauri/src/core/device/linux_evdev.rs b/src-tauri/src/core/device/linux_evdev.rs new file mode 100644 index 0000000..085a2fb --- /dev/null +++ b/src-tauri/src/core/device/linux_evdev.rs @@ -0,0 +1,199 @@ +// Linux evdev-based global input listener. +// +// On Linux, `rdev::listen()` uses X11's XRecord extension which does not work +// under Wayland compositors (Hyprland, Sway, etc.). This module reads directly +// from /dev/input/event* devices via the kernel evdev subsystem, which works +// on both X11 and Wayland. +// +// ## Requirements +// +// The user must have read access to /dev/input/event*. The recommended way +// is to add your user to the `input` group: +// +// sudo usermod -a -G input $USER +// # log out and back in +// +// On some distributions the group may be called `plugdev`. + +use evdev::{Device, EventSummary, KeyCode}; +use serde_json::json; +use std::path::PathBuf; + +use super::{DeviceEvent, DeviceKind}; + +/// Check whether the current session is running under Wayland. +pub fn is_wayland() -> bool { + std::env::var("WAYLAND_DISPLAY").is_ok() + || std::env::var("XDG_SESSION_TYPE") + .map(|v| v == "wayland") + .unwrap_or(false) +} + +/// Scan /dev/input for evdev device paths that look like keyboards or mice. +fn find_input_device_paths() -> Vec { + let mut paths = Vec::new(); + let dir = match std::fs::read_dir("/dev/input") { + Ok(d) => d, + Err(e) => { + eprintln!("[linux_evdev] Cannot read /dev/input: {e}"); + return paths; + } + }; + + for entry in dir.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.starts_with("event") { + continue; + } + + // Quick-open to query capabilities; drop immediately so the + // per-device worker thread can open it again. + match Device::open(&path) { + Ok(dev) => { + let has_keys = dev.supported_keys().map_or(false, |k| !k.is_empty()); + let has_rel = dev + .supported_relative_axes() + .map_or(false, |a| !a.is_empty()); + let has_abs = dev + .supported_absolute_axes() + .map_or(false, |a| !a.is_empty()); + + if has_keys || has_rel || has_abs { + paths.push(path); + } + } + Err(e) => { + eprintln!("[linux_evdev] Cannot open {}: {e}", path.display()); + } + } + } + paths +} + +/// Return `true` when `key` looks like a mouse button. +fn is_mouse_button(key: &KeyCode) -> bool { + matches!( + key, + KeyCode::BTN_LEFT + | KeyCode::BTN_RIGHT + | KeyCode::BTN_MIDDLE + | KeyCode::BTN_SIDE + | KeyCode::BTN_EXTRA + | KeyCode::BTN_FORWARD + | KeyCode::BTN_BACK + | KeyCode::BTN_TASK + ) +} + +/// Convert an evdev `EventSummary` into our app-level `DeviceEvent`, or +/// `None` if the event is uninteresting (e.g. SYN_REPORT). +fn convert_event(summary: EventSummary) -> Option { + match summary { + // --- Keyboard keys --- + EventSummary::Key(_, key, 1) if !is_mouse_button(&key) => Some(DeviceEvent { + kind: DeviceKind::KeyboardPress, + value: json!(format!("{key:?}")), + }), + EventSummary::Key(_, key, 0) if !is_mouse_button(&key) => Some(DeviceEvent { + kind: DeviceKind::KeyboardRelease, + value: json!(format!("{key:?}")), + }), + + // --- Mouse buttons --- + EventSummary::Key(_, btn, 1) if is_mouse_button(&btn) => Some(DeviceEvent { + kind: DeviceKind::MousePress, + value: json!(format!("{btn:?}")), + }), + EventSummary::Key(_, btn, 0) if is_mouse_button(&btn) => Some(DeviceEvent { + kind: DeviceKind::MouseRelease, + value: json!(format!("{btn:?}")), + }), + + // --- Relative axes (mouse movement & scroll) --- + EventSummary::RelativeAxis(_, axis, value) => Some(DeviceEvent { + kind: DeviceKind::MouseMove, + value: json!({ + "axis": format!("{axis:?}"), + "value": value, + }), + }), + + // --- Absolute axes (touch screens, drawing tablets) – treat as + // mouse move so the cat reacts --- + EventSummary::AbsoluteAxis(_, axis, value) => Some(DeviceEvent { + kind: DeviceKind::MouseMove, + value: json!({ + "axis": format!("{axis:?}"), + "value": value, + }), + }), + + _ => None, + } +} + +/// Spawn one OS thread per evdev device. Each thread blocks on +/// `fetch_events()` (which is fine – the kernel wakes us when events +/// arrive) and emits `device-changed` to the Tauri frontend. +pub fn start_evdev_listener(app_handle: tauri::AppHandle) { + let paths = find_input_device_paths(); + + if paths.is_empty() { + eprintln!( + "[linux_evdev] No accessible input devices in /dev/input/.\n\ + Make sure your user is in the 'input' group:\n\ + sudo usermod -a -G input $USER\n\ + Then log out and back in." + ); + return; + } + + println!( + "[linux_evdev] Found {} input device(s): {:?}", + paths.len(), + paths + ); + + for path in paths { + let handle = app_handle.clone(); + std::thread::spawn(move || { + let mut dev = match Device::open(&path) { + Ok(d) => d, + Err(e) => { + eprintln!("[linux_evdev] Failed to open {}: {e}", path.display()); + return; + } + }; + + println!("[linux_evdev] Listening on {}", path.display()); + + loop { + match dev.fetch_events() { + Ok(events) => { + for ev in events { + if let Some(de) = convert_event(ev.destructure()) { + if let Err(e) = handle.emit("device-changed", de) { + eprintln!( + "[linux_evdev] Failed to emit event: {e}" + ); + } + } + } + } + Err(e) => { + eprintln!( + "[linux_evdev] Error reading {}: {e}", + path.display() + ); + // If we lose a device, stop this worker rather + // than spinning in an error loop. + break; + } + } + } + }); + } +} diff --git a/src-tauri/src/core/setup/linux.rs b/src-tauri/src/core/setup/linux.rs index b1385d6..63e7db5 100644 --- a/src-tauri/src/core/setup/linux.rs +++ b/src-tauri/src/core/setup/linux.rs @@ -1,8 +1,29 @@ -use tauri::{AppHandle, WebviewWindow}; - -pub fn platform( - _app_handle: &AppHandle, - _main_window: WebviewWindow, -) { - // Linux平台特定的初始化代码 -} \ No newline at end of file +use tauri::{AppHandle, WebviewWindow}; + +pub fn platform( + _app_handle: &AppHandle, + _main_window: WebviewWindow, +) { + // On Linux, global input monitoring via evdev (/dev/input/event*) + // requires the user to be in the `input` group (or `plugdev` on some + // distros). If events don't arrive, run: + // + // sudo usermod -a -G input $USER + // + // then log out and back in. + + // Detect Wayland and log a hint – rdev's X11 listener won't work. + let wayland = std::env::var("WAYLAND_DISPLAY").is_ok() + || std::env::var("XDG_SESSION_TYPE") + .map(|v| v == "wayland") + .unwrap_or(false); + + if wayland { + println!( + "[setup/linux] Wayland detected – using evdev for global input.\n\ + Make sure your user is in the 'input' group." + ); + } else { + println!("[setup/linux] X11 detected – using rdev + evdev for global input."); + } +}