Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/src/core/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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 {
Expand Down
199 changes: 199 additions & 0 deletions src-tauri/src/core/device/linux_evdev.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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<DeviceEvent> {
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;
}
}
}
});
}
}
37 changes: 29 additions & 8 deletions src-tauri/src/core/setup/linux.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
use tauri::{AppHandle, WebviewWindow};

pub fn platform(
_app_handle: &AppHandle,
_main_window: WebviewWindow,
) {
// Linux平台特定的初始化代码
}
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.");
}
}