- Published on
Google Summer of Code Blog 1
- Authors
- Name
- Mohammad Aadil Shabier
Introduction
Welcome to this blog series on my Google Summer of Code 2025 Project: swhkd: Provide Libinput as an Alternative Input Backend.
Period | From | To |
---|---|---|
Community Bonding | May 8 | May 31 |
Implementation - Midterm | June 1 | July 14 |
Implementation - Final | July 15 | September 1 |
For a more detailed timeline and overview of the project, check out the proposal1.
What is swhkd?
Short for Simple Wayland HotKey Daemon2. I'll borrow the answer from their own description.
swhkd is a display protocol-independent hotkey daemon made in Rust. swhkd uses an easy-to-use configuration system inspired by sxhkd, so you can easily add or remove hotkeys.
It also attempts to be a drop-in replacement for sxhkd, meaning your sxhkd config file is also compatible with swhkd. Because swhkd can be used anywhere, the same swhkd config can be used across Xorg or Wayland desktops, and you can even use swhkd in a TTY.
What is libinput?
libinput is a library that provides a full input stack for display servers and other applications that need to handle input devices provided by the kernel.
libinput provides device detection, event handling and abstraction to minimize the amount of custom input code the user of libinput need to provide the common set of functionality that users expect. Input event processing includes scaling touch coordinates, generating relative pointer events from touchpads, pointer acceleration, etc.
Goals
The goal of this project is to provide support for the user to use libinput
as the input backend, as an alternative to the default evdev
input backend. The motivation for this is outlined in the proposal1. In the first phase of this project, our goals are:
Read device events using libinput
We'll start from scratch and try to replicate some existing functionality of swhkd using the libinput backend. At this stage, we do not plan to modify any of the existing modules in swhkd (config, perms, tests, uinput). Instead, we'll focus on understanding how the library works and implementing the core functionality of swhkd:
- Adding devices by name.
- Grabbing devices and reading their inputs.
Read events asynchronously
The current implementation uses an asynchronous API to reduce CPU usage, we use the AsyncFd
3 struct from tokio
, which provides a wrapper around file descriptors to facilitate asynchronous reads and writes.
Implementation
Simple Libinput Reader
Here is a simple example using libinput to read and log events.
Click to enlarge snippet
use input::{Libinput, LibinputInterface};
use std::fs::{File, OpenOptions};
use std::os::unix::{fs::OpenOptionsExt, io::OwnedFd};
use std::path::Path;
extern crate libc;
use libc::{O_RDONLY, O_RDWR, O_WRONLY};
struct Interface;
impl LibinputInterface for Interface {
fn open_restricted(&mut self, path: &Path, flags: i32) -> Result<OwnedFd, i32> {
OpenOptions::new()
.custom_flags(flags)
.read((flags & O_RDONLY != 0) | (flags & O_RDWR != 0))
.write((flags & O_WRONLY != 0) | (flags & O_RDWR != 0))
.open(path)
.map(|file| file.into())
.map_err(|err| err.raw_os_error().unwrap())
}
fn close_restricted(&mut self, fd: OwnedFd) {
unsafe {
File::from(fd);
}
}
}
fn main() {
let mut input = Libinput::new_with_udev(Interface);
input.udev_assign_seat("seat0").unwrap();
loop {
input.dispatch().unwrap();
for event in &mut input {
println!("Got event: {:?}", event);
}
}
}
We first define a struct named Interface
which implements the LibinputInterface
trait (required by libinput) to handle opening/closing device files with the appropriate permissions.
This custom interface is used to initialize libinput with the udev backend. The udev backend auto-detects input devices tied to "seat0" (the default user session seat on Linux). The event loop then continuously listens for input events and prints them.
Let's gradually transform this boilerplate to a hotkey daemon. This will involve several experiments and design decisions. Let's walk through some of them.
Libinput Context
In the above example we use libinput with udev, which automatically adds and removes devices when the binary is run, or when a device is connected or disconnected. This is handled internally by udev.
let mut input = Libinput::new_with_udev(Interface);
input.udev_assign_seat("seat0").unwrap();
We could instead create a libinput context that requires the caller to manually add or remove devices by specifiying their paths.
let mut input = Libinput::new_from_path(Interface);
let dev = input.path_add_device("/dev/input/event20").unwrap();
// use device
input.path_remove_device(device);
This approach aligns more closely with the structure of the current swhkd and would likely make future integration smoother, but that involves writing a bunch of additional code using the udev
library to manually replicate what libinput already does for us.
So let's leave that problem to post midterm-review me. :)
Grab that device!
While writing a hotkey daemon, we want to grab all events coming from a device for ourselves, and check whether the sequence of keys entered constitutes a hotkey. If it does, we execute the associated operation. If it doesn't we allow the events to pass through to be handled by the window manager/application.
Linux lets us do this using the ioctl
operation EVIOCGRAB
, which is defined for evdev
devices. This lets the caller read events exclusively from this device. So we set the bit while opening the device, and unset it while closing it.
Click to enlarge snippet
use nix::ioctl_write_int;
ioctl_write_int!(eviocgrab, b'E', 0x90);
fn grab(fd: i32) -> std::io::Result<()> {
unsafe {
eviocgrab(fd, 1)?;
}
Ok(())
}
fn ungrab(fd: i32) -> std::io::Result<()> {
unsafe {
eviocgrab(fd, 0)?;
}
Ok(())
}
impl LibinputInterface for Interface {
fn open_restricted(&mut self, path: &Path, flags: i32) -> Result<OwnedFd, i32> {
// unchanged till here
.map(|file| {
grab(file.as_raw_fd()).expect("Could not grab fd");
file.into()
})
}
fn close_restricted(&mut self, fd: std::os::unix::io::OwnedFd) {
ungrab(fd.as_raw_fd()).expect("Could not ungrab fd");
drop(File::from(fd));
}
}
Do NOT grab that device!
But wait! If we grab all input devices for ourselves and then just print the events out, how do we then use our machine? Spoiler: we can't. In the initial phases of implementation, there were many instances where I had to restart the machine because I couldn't Ctrl+C
the executable. This would've been fine if we had implemented event passthrough; but as that's a task for future me, let's find a way to get around this for the time being.
Swhkd takes a devices argument from the user; we implement this temporarily by reading the device names from devices.txt
, which contains a list of newline separated device names.
How do we find the device name from the path? A rather simple way is to:
- Take the path of the device, e.g:
/dev/input/event20
. - Read the contents of the file at the equivalent path:
/sys/class/input/event20/device/name
Click to enlarge snippet
fn name_from_path(path: &Path) -> std::io::Result<String> {
// ...
}
fn get_devices_from_file(path: &Path) -> std::io::Result<HashSet<String>> {
// ...
}
struct Interface {
devices: HashSet<String>,
}
impl Interface {
pub fn new(path: &Path) -> std::io::Result<Self> {
Ok(Self { devices: get_devices_from_file(path)? })
}
}
impl LibinputInterface for Interface {
fn open_restricted(&mut self, path: &std::path::Path, flags: i32,) -> Result<OwnedFd, i32> {
let name = match name_from_path(path).unwrap();
if !self.devices.contains(&name) {
Err(-414)
} else {
OpenOptions::new()
// ...
}
}
}
fn main() -> std::io::Result<()> {
let interface = Interface::new(Path::new("./devices.txt"))?;
let mut input = Libinput::new_with_udev(interface);
// ...
}
To poll, or not to poll
Our current, rather simple implementation uses a loop to keep polling libinput and log events. It runs as fast as the CPU allows; pulling up htop
shows that it takes over a CPU core and stays at a 100% usage.

This is not ideal for a hotkey daemon that should be running in the background. Ideally, we would sit in idle and wait till an event is queued up, only then would be process the event, running for a few milliseconds per event.
To achieve this, we open the files in non-blocking mode. This is done by setting the O_NONBLOCK
flag during the open()
syscall. In this mode, if we attempt to read from a file and it's not ready, instead of blocking until data becomes available, the call return immediately with a EWOULDBLOCK
error.
This feature of the kernel is very handy, for the uninitiated, check out the man pages for select
, poll
and epoll
. There are also several blogs and resources4 on the internet to learn more about how this is used in practice.
Libinput opens devices in non-blocking mode by default, allowing us to take advantage of it. We pair it with Tokio's AsyncFd3, which is easy since the Libinput struct already implements the trait that AsyncFD
requires (std::os::fd::AsRawFd
).
AsyncFD associates an IO object backed by a Unix file descriptor with the tokio reactor, allowing for readiness to be polled. The file descriptor must be of a type that can be used with the OS polling facilities (ie, poll, epoll, kqueue, etc), ... and the file descriptor must have the nonblocking mode set to true.
Click to enlarge snippet
use tokio::io::unix::AsyncFd;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let interface = Interface::new(Path::new("./devices.txt"))?;
let mut input = Libinput::new_with_udev(interface);
input.udev_assign_seat("seat0").unwrap();
let mut input: AsyncFD<Libinput> = AsyncFd::new(input)?;
loop {
let mut guard = input.readable_mut().await?;
// Tries running the inner function, if the inner function returns a
// WouldBlock error, we continue
match guard.try_io(|inner| {
let input = inner.get_mut();
input.dispatch()?;
for event in input {
log::info!("Event: {:?}", event);
}
Ok(())
}) {
Ok(x) => {
x?;
}
Err(_would_block) => {}
};
// NOTE: very important, without this the fd is always ready to read from.
guard.clear_ready();
}
}
This is a lot to to take in, but go through the lines one by one and read the comments to understand how it all fits together. We first wait for the file descriptor to become readable, and acquire a guard.
let mut guard = input.readable_mut().await?;
Next, we we attempt to run the inner closure. Since we're now using non-blocking reads, input.dispatch()
returns a WouldBlock
error instead of blocking there are no available events.
The reason behind the last line in this snippet is explained very well here:
Tokio internally tracks when it has received a ready notification, and when readiness checking functions like readable and writable are called, if the readiness flag is set, these async functions will complete immediately. This however does mean that it is critical to ensure that this ready flag is cleared when (and only when) the file descriptor ceases to be ready.
match guard.try_io(|inner| {
let input = inner.get_mut();
input.dispatch()?;
// ...
Ok(())
}) {
Ok(x) => { x?; }
Err(_would_block) => {}
};
guard.clear_ready();

Results
Here's what the end product looks like. We filter through devices, and add only the ones which are in our set, the keyboard inputs are from pressing the ASDFGH
keys.
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Devices: {"Sino Wealth USB Keyboard Consumer Control", "Sino Wealth USB Keyboard"}
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event3, Power Button
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event16, Video Bus
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event5, Acer Wireless Radio Control
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event17, Video Bus
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event0, Power Button
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event2, Lid Switch
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event1, Sleep Button
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event4, Logitech B330/M330/M331
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Opening: /dev/input/event6, Sino Wealth USB Keyboard
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event8, Sino Wealth USB Keyboard System Control
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Opening: /dev/input/event9, Sino Wealth USB Keyboard Consumer Control
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Opening: /dev/input/event10, Sino Wealth USB Keyboard
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event11, Sino Wealth USB Keyboard Mouse
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event13, ELAN050A:00 04F3:3158 Mouse
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event14, ELAN050A:00 04F3:3158 Touchpad
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event18, HDA Intel PCH Front Headphone
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event19, HDA Intel PCH HDMI/DP,pcm=3
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event20, HDA Intel PCH HDMI/DP,pcm=7
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event21, HDA Intel PCH HDMI/DP,pcm=8
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event22, HDA Intel PCH HDMI/DP,pcm=9
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event7, AT Translated Set 2 keyboard
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event12, PC Speaker
[2025-06-01T11:04:50Z DEBUG swhkd_libinput] Skipping: /dev/input/event15, Acer WMI hotkeys
[2025-06-01T11:04:52Z INFO swhkd_libinput] Event: Device(Added(DeviceAddedEvent @0x5db7df55a900))
[2025-06-01T11:04:52Z INFO swhkd_libinput] Event: Device(Added(DeviceAddedEvent @0x5db7df569890))
[2025-06-01T11:04:52Z INFO swhkd_libinput] Event: Device(Added(DeviceAddedEvent @0x5db7df577970))
[2025-06-01T11:04:52Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 30, state: Pressed
[2025-06-01T11:04:52Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 30, state: Released
[2025-06-01T11:04:53Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 31, state: Pressed
[2025-06-01T11:04:53Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 31, state: Released
[2025-06-01T11:04:53Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 32, state: Pressed
[2025-06-01T11:04:53Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 32, state: Released
[2025-06-01T11:04:53Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 33, state: Pressed
[2025-06-01T11:04:53Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 33, state: Released
[2025-06-01T11:04:53Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 34, state: Pressed
[2025-06-01T11:04:53Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 34, state: Released
[2025-06-01T11:04:54Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 35, state: Pressed
[2025-06-01T11:04:54Z INFO swhkd_libinput] Device: Sino Wealth USB Keyboard, ev: event6, key pressed: 35, state: Released
Feel free to check out the Github repository to see how everything came together: swhkd:libinput-backend.
I hope everyone who managed to make it this far learned something new.
Footnotes
Proposal: https://docs.google.com/document/d/1v5R0VWxwPxz11hho753VfebKN5VCLLPzO0TisAqDy-I/edit?usp=sharing ↩ ↩2
AsyncFD: https://docs.rs/tokio/latest/tokio/io/unix/struct.AsyncFd.html ↩ ↩2
Chapter 6. I/O Multiplexing: The select and poll Functions: https://notes.shichao.io/unp/ch6/ ↩