Skip to content

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.

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

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.

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

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

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

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/1SYN_REPORT Immediate
emit_key(A, false) EV_KEY/A/0SYN_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

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/10SYN, EV_REL/REL_Y/5SYN One sync per axis
click(Left) EV_KEY/BTN_LEFT/1SYN, EV_KEY/BTN_LEFT/0SYN Press + release
scroll(Vertical, 3) EV_REL/REL_WHEEL/3SYN Single event
press_button(Right) EV_KEY/BTN_RIGHT/1SYN 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-1

This 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

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.

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

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.

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

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.

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

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.

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.

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

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.

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.

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

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.