use std::io::{BufRead, BufReader, ErrorKind, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::Duration;
use evdev::{Device, EventSummary, KeyCode};
use xkbcommon::xkb;
#[derive(Debug, thiserror::Error)]
pub enum RecordError {
#[error("no keyboard devices found under /dev/input")]
NoKeyboards,
#[error(
"permission denied reading /dev/input — add your user to the 'input' group (`sudo usermod -aG input $USER`) and log back in"
)]
Permission,
#[error("could not compile the keyboard layout (xkbcommon)")]
Keymap,
#[error("chord-capture timed out")]
Timeout,
}
pub fn record_chord(timeout: Duration) -> Result<String, RecordError> {
log::info!("chord_capture: compiling keymap");
let keymap_text = compile_keymap()?;
log::info!("chord_capture: enumerating keyboards");
let keyboards = keyboard_devices()?;
log::info!(
"chord_capture: opened {} keyboard device(s)",
keyboards.len()
);
let (tx, rx) = mpsc::channel::<String>();
for (idx, device) in keyboards.into_iter().enumerate() {
let tx = tx.clone();
let keymap_text = keymap_text.clone();
let name = device
.name()
.map(|n| n.to_string())
.unwrap_or_else(|| "<unnamed>".to_string());
thread::spawn(move || {
log::info!("chord_capture: device #{idx} reader started ({name})");
read_until_chord(device, &keymap_text, &tx);
log::info!("chord_capture: device #{idx} reader exiting ({name})");
});
}
drop(tx);
match rx.recv_timeout(timeout) {
Ok(chord) => Ok(chord),
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => {
Err(RecordError::Timeout)
}
}
}
fn compile_keymap() -> Result<String, RecordError> {
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let keymap =
xkb::Keymap::new_from_names(&context, "", "", "", "", None, xkb::KEYMAP_COMPILE_NO_FLAGS)
.ok_or(RecordError::Keymap)?;
Ok(keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1))
}
fn keyboard_devices() -> Result<Vec<Device>, RecordError> {
let entries = match std::fs::read_dir("/dev/input") {
Ok(entries) => entries,
Err(e) if e.kind() == ErrorKind::PermissionDenied => return Err(RecordError::Permission),
Err(_) => return Err(RecordError::NoKeyboards),
};
let mut keyboards = Vec::new();
let mut permission_denied = false;
for entry in entries.flatten() {
let path = entry.path();
let is_event_node = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("event"));
if !is_event_node {
continue;
}
log::info!("chord_capture: opening {}", path.display());
match Device::open(&path) {
Ok(device) if is_keyboard(&device) => {
log::info!("chord_capture: keyboard {}", path.display());
keyboards.push(device);
}
Ok(_) => {
log::info!("chord_capture: non-keyboard {}", path.display());
}
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
log::warn!("chord_capture: permission denied on {}", path.display());
permission_denied = true;
}
Err(e) => {
log::warn!("chord_capture: error opening {}: {e}", path.display());
}
}
}
if !keyboards.is_empty() {
Ok(keyboards)
} else if permission_denied {
Err(RecordError::Permission)
} else {
Err(RecordError::NoKeyboards)
}
}
fn is_keyboard(device: &Device) -> bool {
device
.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::KEY_A))
}
fn read_until_chord(mut device: Device, keymap_text: &str, tx: &mpsc::Sender<String>) {
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let Some(keymap) = xkb::Keymap::new_from_string(
&context,
keymap_text.to_owned(),
xkb::KEYMAP_FORMAT_TEXT_V1,
xkb::KEYMAP_COMPILE_NO_FLAGS,
) else {
return;
};
let mut state = xkb::State::new(&keymap);
loop {
let events = match device.fetch_events() {
Ok(events) => events,
Err(e) => {
log::warn!("chord_capture: fetch_events error: {e}");
return;
}
};
for input in events {
let EventSummary::Key(_, code, value) = input.destructure() else {
continue;
};
let keycode = xkb::Keycode::new(u32::from(code.0) + 8);
if value == 1
&& let Some(chord) = chord_from_state(&state, keycode)
{
let _ = tx.send(chord);
return;
}
if value != 2 {
let direction = if value == 0 {
xkb::KeyDirection::Up
} else {
xkb::KeyDirection::Down
};
state.update_key(keycode, direction);
}
}
}
}
fn chord_from_state(state: &xkb::State, keycode: xkb::Keycode) -> Option<String> {
let sym = state.key_get_one_sym(keycode).raw();
if is_modifier_keysym(sym) {
return None;
}
let key_token = chord_key_token(sym)?;
let active = |m: &str| state.mod_name_is_active(m, xkb::STATE_MODS_EFFECTIVE);
let mut parts: Vec<&str> = Vec::new();
if active(xkb::MOD_NAME_CTRL) {
parts.push("CTRL");
}
if active(xkb::MOD_NAME_SHIFT) {
parts.push("SHIFT");
}
if active(xkb::MOD_NAME_ALT) {
parts.push("ALT");
}
if active(xkb::MOD_NAME_LOGO) {
parts.push("SUPER");
}
Some(if parts.is_empty() {
key_token
} else {
format!("{}+{key_token}", parts.join("+"))
})
}
fn chord_key_token(sym: u32) -> Option<String> {
let named = match sym {
0xff1b => Some("ESC"), 0xff0d | 0xff8d => Some("ENTER"), 0xff09 => Some("TAB"), 0xff08 => Some("BACKSPACE"), 0xffff => Some("DELETE"), 0xff52 => Some("UP"), 0xff54 => Some("DOWN"), 0xff51 => Some("LEFT"), 0xff53 => Some("RIGHT"), 0x20 => Some("SPACE"), 0x2b => Some("PLUS"), 0x2d => Some("MINUS"), 0x3d => Some("EQUAL"), _ => None,
};
if let Some(token) = named {
return Some(token.to_string());
}
if (0x21..=0x7E).contains(&sym) {
let ch = char::from_u32(sym)?.to_ascii_uppercase();
return Some(ch.to_string());
}
let name = xkb::keysym_get_name(xkb::Keysym::from(sym));
if name.is_empty() {
return None;
}
Some(name.to_ascii_uppercase())
}
fn is_modifier_keysym(sym: u32) -> bool {
(0xffe1..=0xffee).contains(&sym)
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("daemon not running (no vernier socket)")]
DaemonOffline,
#[error("chord-capture IPC: {0}")]
Io(String),
#[error("chord-capture cancelled")]
Cancelled,
#[error("daemon error: {0}")]
Daemon(String),
}
pub const DEFAULT_RECORD_TIMEOUT: Duration = Duration::from_secs(30);
pub struct ChordRecording {
rx: mpsc::Receiver<Result<String, ClientError>>,
abort: UnixStream,
}
impl ChordRecording {
pub fn try_recv(&self) -> Result<Option<String>, ClientError> {
match self.rx.try_recv() {
Ok(result) => result.map(Some),
Err(mpsc::TryRecvError::Empty) => Ok(None),
Err(mpsc::TryRecvError::Disconnected) => Err(ClientError::Cancelled),
}
}
pub fn abort(&self) {
let _ = self.abort.shutdown(std::net::Shutdown::Both);
}
}
pub fn record_chord_ipc() -> Result<ChordRecording, ClientError> {
let path = ipc_socket_path();
log::info!("chord_capture client: connecting to {}", path.display());
let mut stream = UnixStream::connect(&path).map_err(|e| {
if e.kind() == ErrorKind::NotFound || e.kind() == ErrorKind::ConnectionRefused {
ClientError::DaemonOffline
} else {
ClientError::Io(e.to_string())
}
})?;
stream
.write_all(b"capture-chord\n")
.map_err(|e| ClientError::Io(e.to_string()))?;
let abort = stream
.try_clone()
.map_err(|e| ClientError::Io(e.to_string()))?;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut reader = BufReader::new(stream);
let mut line = String::new();
let result = match reader.read_line(&mut line) {
Ok(_) => parse_reply(line.trim()),
Err(e) => Err(ClientError::Io(e.to_string())),
};
let _ = tx.send(result);
});
Ok(ChordRecording { rx, abort })
}
fn parse_reply(line: &str) -> Result<String, ClientError> {
if line.is_empty() || line == "cancel" {
return Err(ClientError::Cancelled);
}
if let Some(rest) = line.strip_prefix("error: ") {
return Err(ClientError::Daemon(rest.to_string()));
}
if let Some(rest) = line.strip_prefix("err ") {
return Err(ClientError::Daemon(rest.to_string()));
}
Ok(line.to_string())
}
fn ipc_socket_path() -> PathBuf {
let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
runtime_dir.join("vernier.sock")
}