Virtual Input Devices: uinput Bridges to the Wayfire Compositor
The Portal keyboard daemon (portal-keyboardd) captures raw touch input from a capacitive overlay, classifies user intent through the fusion engine, and must deliver the resulting keystrokes and pointer movements into running Wayland applications. Because Wayfire’s DRM/KMS backend does not create a libinput seat, the daemon cannot inject events via Wayland protocols. Instead, it creates two virtual devices through the Linux kernel’s uinput interface — a keyboard and a relative pointer — that Wayfire treats as ordinary hardware devices. This page examines the four-layer architecture: the ioctl-level UInputDevice wrapper, the trait-abstracted VirtualKeyboard/VirtualPointer interfaces, the batching-aware OutputWorker, and the systemd/udev security envelope that grants access without elevated capabilities.
Architectural Overview
Section titled “Architectural Overview”The uinput subsystem forms the final stage of the Portal input pipeline. Touch data enters through direct evdev capture, flows through normalization, multi-touch tracking, and the fusion engine’s intent classifier, and exits as concrete InputAction values. These actions pass through either the OutputWorker (library-level, with batching policies) or the ActionDispatcher (daemon-level, with system gesture translation) before reaching the uinput file descriptors. The kernel synthesizes these events into /dev/input/eventN device nodes, which Wayfire’s libinput seat picks up transparently.
flowchart TD
subgraph Capture["Touch Capture (evdev)"]
TC["Touch Capture<br/>EvdevTouchCapture"]
end
subgraph Fusion["Fusion Engine"]
FE["Intent Classifier<br/>InputAction"]
end
subgraph Dispatch["Dispatch Layer"]
AD["ActionDispatcher<br/>daemon-level"]
OW["OutputWorker<br/>library-level<br/>with batching"]
end
subgraph Virtual["Virtual Device Layer"]
VK["VirtualKeyboard trait"]
VP["VirtualPointer trait"]
end
subgraph Uinput["uinput FFI Layer"]
UK["UinputKeyboard"]
UP["UinputPointer"]
UID["UInputDevice<br/>ioctl + write(2)"]
end
subgraph Kernel["Linux Kernel"]
DEV["/dev/uinput"]
EV["/dev/input/eventN"]
end
subgraph Wayfire["Wayfire Compositor"]
WL["libinput Seat"]
APP["Wayland Applications"]
end
TC --> FE
FE --> AD
FE --> OW
AD --> VK
AD --> VP
OW --> VK
OW --> VP
VK -.-> UK
VP -.-> UP
UK --> UID
UP --> UID
UID --> DEV
DEV --> EV
EV --> WL
WL --> APP
The daemon (portal-keyboardd) instantiates UinputKeyboard and UinputPointer during initialization, then creates an ActionDispatcher bound to both devices on every fusion result callback. The OutputWorker provides the same dispatch semantics at the library level but adds time-based event batching for pointer movements and scrolls.
Sources: portal/input/src/bin/portal-keyboardd/app_wayland.rs#L191-L194, portal/input/src/output/mod.rs#L1-L27
The UInputDevice FFI Wrapper
Section titled “The UInputDevice FFI Wrapper”At the lowest layer sits UInputDevice, a thin Rust wrapper around the Linux kernel’s /dev/uinput character device. This file is a deliberate one-to-one mirror of linux/uinput.h and linux/input-event-codes.h — the module comment explicitly notes that splitting would force reviewers to cross-reference files when reading the device lifecycle against kernel documentation.
Architecture-Specific ioctl Constants
Section titled “Architecture-Specific ioctl Constants”The ioctl request numbers differ between x86_64 and ARM64 because the kernel encodes them using architecture-specific _IO family macros. Portal targets both platforms, so the code uses #[cfg(target_arch)] to select correct values at compile time:
| ioctl | aarch64 value | x86_64 value | Purpose |
|---|---|---|---|
UI_SET_EVBIT |
0x40045564 |
0x40045520 |
Enable an event type (key, relative, absolute) |
UI_SET_KEYBIT |
0x40045565 |
0x40045521 |
Enable a specific key/button code |
UI_SET_RELBIT |
0x40045566 |
0x40045522 |
Enable a specific relative axis |
UI_SET_ABSBIT |
0x40045567 |
0x40045523 |
Enable a specific absolute axis |
UI_DEV_SETUP |
0x405c5503 |
0x405c5503 |
Modern device setup (same on both) |
UI_DEV_CREATE |
0x5501 |
0x5501 |
Finalize device creation |
UI_DEV_DESTROY |
0x5502 |
0x5502 |
Tear down device |
The UI_DEV_SETUP, UI_DEV_CREATE, and UI_DEV_DESTROY constants are identical across architectures because they use simpler ioctl encodings. Only the UI_SET_* family diverges, as these carry a data argument encoded into the ioctl number.
Sources: portal/input/src/output/uinput.rs#L30-L64
Kernel ABI Structures
Section titled “Kernel ABI Structures”Three #[repr(C)] structs mirror the kernel’s data layout exactly:
InputEvent (24 bytes, verified by unit test) matches struct input_event — each individual event carries a timeval, a 16-bit event type, a 16-bit code, and a 32-bit value. The new() constructor zero-initializes the timestamp because the kernel fills the real time on write(2).
UInputSetup (92 bytes) matches struct uinput_setup — the modern device registration structure. It bundles an InputId (bustype, vendor, product, version), an 80-byte name buffer, and a force-feedback effects count. Portal uses a consistent identity across all virtual devices: bustype = 0x03 (BUS_USB), vendor = 0x1234, product = 0x5678, version = 1.
UInputUserDev (1060 bytes) is the legacy write-based setup structure, retained for backwards compatibility with older kernels. It adds four 64-element i32 arrays for absolute axis bounds (absmax, absmin, absfuzz, absflat).
Sources: portal/input/src/output/uinput.rs#L189-L287, portal/input/src/output/uinput/tests.rs#L6-L14
Device Lifecycle
Section titled “Device Lifecycle”UInputDevice follows the canonical uinput lifecycle defined by the kernel:
sequenceDiagram
participant App as UInputDevice
participant Kernel as /dev/uinput
participant WF as Wayfire
App->>Kernel: open("/dev/uinput", O_RDWR)
App->>Kernel: ioctl(UI_SET_EVBIT, EV_KEY)
App->>Kernel: ioctl(UI_SET_KEYBIT, KEY_A)
Note over App,Kernel: ...repeat for each capability...
App->>Kernel: ioctl(UI_DEV_SETUP, &uinput_setup)
App->>Kernel: ioctl(UI_DEV_CREATE, 0)
Kernel-->>WF: udev event: new /dev/input/eventN
WF->>WF: libinput discovers device
App->>Kernel: write(input_event{EV_KEY, KEY_A, 1})
App->>Kernel: write(input_event{EV_SYN, 0, 0})
WF->>WF: libinput delivers key press
App->>Kernel: ioctl(UI_DEV_DESTROY, 0)
WF->>WF: libinput removes device
Each capability must be declared before device creation via individual set_evbit, set_keybit, set_relbit, or set_absbit ioctls. The create_device method issues the two-phase modern creation: first UI_DEV_SETUP to register device identity, then UI_DEV_CREATE to instantiate the kernel device node.
The emit method serializes an InputEvent struct via write(2) to the file descriptor. Each write delivers a single kernel event. The emit_sync convenience method appends a SYN_REPORT event immediately after, ensuring the kernel delivers the event atomically to readers.
The Drop implementation calls destroy() on a best-effort basis. If the daemon crashes or the file descriptor is otherwise closed, the kernel automatically tears down the virtual device — the explicit Drop is a safety net, not a correctness requirement.
Sources: portal/input/src/output/uinput.rs#L298-L540
Virtual Keyboard: UinputKeyboard
Section titled “Virtual Keyboard: UinputKeyboard”The VirtualKeyboard trait defines a high-level interface for key input: create(), emit_key(), emit_text(), destroy(), and device_name(). The UinputKeyboard struct implements this trait by wrapping a single UInputDevice.
During create(), the implementation enables the EV_KEY event type and then declares support for a comprehensive key range: codes 2–88 (standard keys and F-keys) plus KEY_RIGHTCTRL (97), KEY_RIGHTALT (100), and navigation keys (Insert, Delete, Home, End, PageUp, PageDown). This ensures the compositor’s XKB layout engine has the full keymap available.
The emit_key method converts a unified KeyCode to its evdev code via to_evdev_code() and calls emit_sync() — writing a key event followed immediately by SYN_REPORT. The boolean state maps to integer value 1 (press) or 0 (release).
The emit_text method decomposes text into individual characters and uses a character-to-keycode lookup table (char_to_key) to emit the appropriate key sequences, including KEY_LEFTSHIFT for uppercase letters and shifted symbols. This is a QWERTY-US mapping; alternate layouts are handled at the fusion engine level, not here.
| Method | Event Sequence | Latency |
|---|---|---|
emit_key(A, true) |
EV_KEY/A/1 → SYN_REPORT |
Immediate |
emit_key(A, false) |
EV_KEY/A/0 → SYN_REPORT |
Immediate |
emit_text("Hi") |
Shift↓ → H↓ → H↑ → Shift↑ → i↓ → i↑ |
Immediate per char |
Sources: portal/input/src/output/uinput_keyboard.rs#L10-L72, portal/input/src/output/keyboard.rs#L19-L137
Virtual Pointer: UinputPointer
Section titled “Virtual Pointer: UinputPointer”The VirtualPointer trait defines movement, clicking, dragging, scrolling, position tracking, and screen dimension reporting. UinputPointer implements it as a relative-mode pointer device.
During creation, the implementation declares support for both EV_REL and EV_KEY event types. For relative movement, it enables REL_X, REL_Y, REL_WHEEL, and REL_HWHEEL. For buttons, it enables BTN_TOOL_MOUSE (which helps compositors identify the device as a mouse) plus left, right, and middle buttons. Notably, the device uses relative movement rather than absolute positioning — this avoids the need for calibration against screen boundaries and works correctly with compositor-level cursor acceleration.
The pointer tracks its estimated position internally (clamped to screen dimensions) for the position() method that returns normalized coordinates. However, the actual cursor position in Wayfire is determined by the compositor’s own pointer acceleration and constraint logic — the internal tracking is an approximation used for position queries, not for event emission.
| Operation | evdev Events | Synch Policy |
|---|---|---|
move_pointer(10, 5) |
EV_REL/REL_X/10 → SYN, EV_REL/REL_Y/5 → SYN |
One sync per axis |
click(Left) |
EV_KEY/BTN_LEFT/1 → SYN, EV_KEY/BTN_LEFT/0 → SYN |
Press + release |
scroll(Vertical, 3) |
EV_REL/REL_WHEEL/3 → SYN |
Single event |
press_button(Right) |
EV_KEY/BTN_RIGHT/1 → SYN |
No auto-release |
The Wayfire configuration explicitly maps this device to the GLASSES-1 virtual output:
[input-device:Portal Virtual Pointer]map_to_output = GLASSES-1This constraint ensures pointer movements stay within the spatial display region that the streaming pipeline captures, preventing the cursor from drifting onto the physical eDP-1 display.
Sources: portal/input/src/output/uinput_pointer.rs#L9-L114, portal/compositor/wayfire.ini#L30-L31
Event Batching: The OutputWorker
Section titled “Event Batching: The OutputWorker”The OutputWorker provides a batching-aware dispatch layer that sits between the fusion engine and the virtual devices. It is generic over K: VirtualKeyboard and P: VirtualPointer, enabling test injection with TestVirtualKeyboard/TestVirtualPointer stubs.
Batching Policy Matrix
Section titled “Batching Policy Matrix”The batching design follows specification §10.4, balancing input responsiveness against event rate reduction:
| Intent Type | Action | BatchMask | Rate | Rationale |
|---|---|---|---|---|
| Typing | KeyPress / KeyRelease | Immediate | — | Zero latency for text entry |
| Typing | ModifierHold | Consumed | — | Internal state tracking only |
| Pointing | PointerMove | Batch4ms | 250 Hz | Smooth cursor, reduced syscall rate |
| Pointing | PointerClick | Immediate | — | Instant tactile feedback |
| Pointing | PointerDrag | Immediate | — | Responsive drag operations |
| Pointing | PointerScroll | Batch8ms | 125 Hz | Smooth scrolling, relaxed latency budget |
| Pointing | PointerZoom / PointerRotate | Immediate | — | Discrete gesture completions |
| Gesturing / System | All | Immediate or Consumed | — | System actions handled via key combos |
Flush Semantics
Section titled “Flush Semantics”When an Immediate action arrives while batched events are pending, the worker first flushes all pending events, then emits the immediate action. This prevents key events from being delayed behind queued pointer movements. The flush_elapsed method is designed to be called periodically from the daemon’s event loop, flushing only events whose time budget has expired.
Internal Consumption
Section titled “Internal Consumption”Certain actions never reach the uinput layer at all. ModifierHold actions for the Portal/Warp/Vortex special keys (evdev codes 183–185) are consumed internally — the fusion engine tracks modifier state and uses these flags to route subsequent gestures. SystemAction, SystemGesture, DimensionSwitch, AppSwitch, and Overview are also consumed by the OutputWorker because the daemon-level ActionDispatcher translates them into keyboard shortcuts (e.g., Ctrl+Alt+Right for dimension switching) that Wayfire binds to compositor actions.
Sources: portal/input/src/output/worker.rs#L51-L246, portal/input/src/output/types.rs#L9-L165
Action Dispatch in the Daemon
Section titled “Action Dispatch in the Daemon”The ActionDispatcher in portal-keyboardd provides the production dispatch path. Unlike OutputWorker, it translates high-level system actions into concrete keyboard shortcut sequences that Wayfire binds to compositor features.
System Action to Key Combo Translation
Section titled “System Action to Key Combo Translation”The dispatcher’s dispatch_one method pattern-matches each InputAction variant and calls the appropriate virtual device method. For system actions, it uses emit_key_combo, which presses modifiers, taps the target key, then releases modifiers in reverse order — standard chord semantics:
| System Action | Key Combo | Wayfire Bind |
|---|---|---|
Launcher |
Alt + F2 |
alt+f2 = /usr/bin/portal-ctl launcher |
AppSwitcher |
Alt + Tab |
alt+tab = switcher |
Home |
Alt + Home |
Compositor navigation |
Back |
Alt + Left |
Navigation back |
Overview |
Ctrl + Up |
ctrl+up = expo |
LockScreen |
Ctrl + Delete |
Compositor lock |
DimensionSwitch::Next |
Ctrl + Alt + Right |
ctrl+alt+right = dimension-next |
DimensionSwitch::Previous |
Ctrl + Alt + Left |
ctrl+alt+left = dimension-prev |
PointerZoom (in) |
Ctrl + Equal |
Application zoom |
PointerZoom (out) |
Ctrl + Minus |
Application zoom |
PointerRotate (CW) |
Ctrl + Shift + Right |
Application rotation |
PointerRotate (CCW) |
Ctrl + Shift + Left |
Application rotation |
Error handling in the dispatcher is deliberately non-fatal: each device call uses if let Err(e) = ... with an eprintln! log message. The input daemon must never crash on a write failure — degraded input is preferable to total input loss.
Sources: portal/input/src/bin/portal-keyboardd/action_dispatch.rs#L42-L244, portal/compositor/wayfire.ini#L36-L42
KeyCode Mapping Architecture
Section titled “KeyCode Mapping Architecture”The Portal input system uses a single unified KeyCode enum as its canonical key representation. This enum supersedes earlier per-subsystem variants and is the only type passed between fusion and output layers.
The KeyCode::to_evdev_code() method provides the bidirectional bridge between the unified type and Linux input-event-codes.h constants. The fusion engine’s evdev_code_to_key_code function provides the reverse mapping for incoming evdev events. Three special evdev codes (183 = KEY_F13, 184 = KEY_F14, 185 = KEY_F15) map to None in the key mapping and instead map to Modifier::Portal, Modifier::Warp, and Modifier::Vortex respectively — these are Portal-specific mode keys that never reach the compositor as regular keystrokes.
graph LR
subgraph Kernel["Linux evdev"]
EVD["evdev scancodes<br/>(u16)"]
end
subgraph Portal["Portal Unified"]
KC["KeyCode enum"]
MOD["Modifier enum<br/>Portal/Warp/Vortex"]
end
subgraph Output["uinput Output"]
U16["u16 evdev code<br/>via to_evdev_code"]
end
EVD -->|"evdev_code_to_key_code<br/>(incoming touch)"| KC
EVD -->|"evdev_code_to_modifier<br/>(F13/F14/F15)"| MOD
KC -->|"to_evdev_code<br/>(outgoing uinput)"| U16
The Custom(u16) variant on KeyCode provides an escape hatch for key codes outside the standard set, passing through directly to the uinput layer without translation.
Sources: portal/input/src/types/key_code.rs#L1-L189, portal/input/src/fusion/key_code_mapping.rs#L15-L110
Security Model: Zero-Capability uinput Access
Section titled “Security Model: Zero-Capability uinput Access”Access to /dev/uinput normally requires root privileges or the CAP_SYS_RAWIO capability. Portal’s design avoids both through a layered security approach.
Udev Rules
Section titled “Udev Rules”A persistent udev rule grants the portal group standing read/write access to /dev/uinput at boot:
KERNEL=="uinput", GROUP="portal", MODE="0660"This rule fires before any Portal service starts, eliminating the need for ExecStartPre chmod commands and enabling the service to run with an empty CapabilityBoundingSet.
Systemd Hardening
Section titled “Systemd Hardening”The portal-input.service unit applies aggressive systemd security hardening:
| Directive | Value | Effect |
|---|---|---|
User / Group |
portal |
Non-root execution |
NoNewPrivileges |
true |
Prevents setuid escalation |
ProtectSystem |
strict |
Read-only filesystem (except ReadWritePaths) |
MemoryDenyWriteExecute |
true |
Blocks JIT/injection |
SystemCallFilter |
@system-service mincore |
Limits syscalls to service set |
CapabilityBoundingSet |
(empty) | Zero Linux capabilities |
AmbientCapabilities |
(empty) | No inherited capabilities |
ReadWritePaths |
/dev/uinput /run/portal /var/lib/portal |
Minimal write surface |
The combination of udev group ownership and an empty capability bounding set means the daemon writes to /dev/uinput using its filesystem permissions — group read/write on a 0660 device node — rather than through any kernel capability.
Sources: portal/systemd/99-portal-input.rules#L1-L6, portal/systemd/portal-input.service#L1-L45
Testing Infrastructure
Section titled “Testing Infrastructure”ABI Verification Tests
Section titled “ABI Verification Tests”Unit tests in the uinput module verify struct sizes against kernel ABI expectations: InputEvent must be exactly 24 bytes, UInputSetup must be 92 bytes, InputId must be 8 bytes, and UInputUserDev must exceed 1000 bytes. These tests catch accidental padding changes that would break the ioctl contract.
Mock Devices
Section titled “Mock Devices”Two parallel testing strategies exist:
TestVirtualKeyboard / TestVirtualPointer (in keyboard.rs and pointer.rs) implement the VirtualKeyboard and VirtualPointer traits as no-op stubs. They track created state and return errors if methods are called before create(). These enable the OutputWorker to be tested without /dev/uinput access.
MockVirtualKeyboard / MockVirtualPointer (in test_support/mock_uinput.rs) record every action into a Vec for later assertion. They provide methods like key_press, move_by, button_press, and scroll that mirror the real device interface, along with actions() for inspection and clear() for reset between test cases.
Fuzzing
Section titled “Fuzzing”The fuzz/fuzz_targets/fuzz_evdev.rs target fuzzes the evdev parsing layer that feeds the fusion engine, indirectly exercising the code paths that generate the InputAction values dispatched to virtual devices.
Sources: portal/input/src/output/uinput/tests.rs#L1-L200, portal/input/src/test_support/mock_uinput.rs#L1-L168
Daemon Integration Flow
Section titled “Daemon Integration Flow”In the running portal-keyboardd, the uinput devices are created once during initialization and held for the daemon’s lifetime. The WaylandKeyboardDaemon struct stores them as fields. On each fusion engine result (received via a synchronous channel from the fusion worker thread), the handle_fusion_result method creates a transient ActionDispatcher borrowing both devices and dispatches each intent’s action:
let mut dispatcher = ActionDispatcher::new( &mut self.keyboard, &mut self.pointer, self.pointer_w, self.pointer_h,);for intent in &result.intents { dispatcher.dispatch(std::slice::from_ref(&intent.action));}The pointer dimensions are set to 2560×1080 — matching the GLASSES-1 virtual output resolution — ensuring the internal position tracking aligns with the spatial display canvas.
Sources: portal/input/src/bin/portal-keyboardd/app_wayland.rs#L124-L132, portal/input/src/bin/portal-keyboardd/app_wayland.rs#L369-L380
For the upstream touch processing and intent classification that feeds these virtual devices, see Fusion Engine: Touch Tracking, Intent Classification, and Action Generation. For the DRM/KMS direct scanout keyboard rendering that coexists with uinput dispatch, see Keyboard Daemon: DRM/KMS Direct Scanout, Cairo Rendering, and evdev Parsing. For the Wayfire plugin architecture that consumes these events on the compositor side, see Wayfire Plugin Integration: Rust FFI via C++ Shims.