Skip to content

Keyboard Daemon: DRM/KMS Direct Scanout, Cairo Rendering, and evdev Parsing

The keyboard daemon (portal-keyboardd) is a standalone binary that renders a virtual touchscreen keyboard directly onto the Spaceboard’s eDP-1 panel and translates multi-touch input into synthetic keyboard and pointer events via /dev/uinput. Unlike the fusion engine and gesture recognizer described in Fusion Engine: Touch Tracking, Intent Classification, and Action Generation, this daemon owns the entire rendering surface — bypassing the compositor for pixel output while feeding synthesized keystrokes back into it through the virtual input devices covered in Virtual Input Devices: uinput Bridges to the Wayfire Compositor. The daemon operates two display paths: an active Wayland layer-shell path that renders via EGL with a Rive animation engine, and a preserved direct DRM/KMS path that scans out framebuffers with zero compositor involvement.

Sources: portal/input/src/bin/portal-keyboardd/main.rs#L1-L67, portal/input/Cargo.toml#L1-L51

The daemon was ported from a C implementation that performed direct DRM/KMS modesetting. The Rust rewrite introduced a Wayland layer-shell path after discovering that the Snapdragon X Elite’s Mesa msm driver returns EGL_BAD_NATIVE_WINDOW for GBM window surfaces. Rather than discarding the DRM path, the architecture preserves both as selectable backends sharing identical fusion-engine, touch, and virtual-device pipelines.

flowchart TB
    subgraph Init
        M["main()"] -->|"RUNNING: AtomicBool"| WL["WaylandKeyboardDaemon::new()"]
    end

    subgraph "Active Path: Wayland Layer-Shell"
        WL --> WLS["WaylandSurface.connect()"]
        WL --> CR["cairo_render::render_keyboard()"]
        WL --> EGL["WaylandEglContext::new()"]
        WL --> RIV["RiveRenderer::new()"]
        WL --> EVC["EvdevTouchCapture::discover_and_open()"]
    end

    subgraph "Legacy Path: Direct DRM/KMS (preserved, unused)"
        KBD["KeyboardDaemon::new()"]
        KBD --> DRM["DrmDevice::open('/dev/dri/card0')"]
        KBD --> CR2["cairo_render::render_keyboard()"]
        KBD --> EGL2["try_init_gles()"]
        EGL2 -->|"msm driver?"| DUMB["DumbBuffer fallback"]
        EGL2 -->|"else"| GBM["GBM + EGL + GLES"]
    end

    WLS -->|"zwlr_layer_surface_v1 OVERLAY"| LOOP["main_loop_iteration()"]
    RIV --> LOOP
    EVC --> LOOP

The active binary instantiates WaylandKeyboardDaemon, which connects to the Wayland display socket, creates a zwlr_layer_surface_v1 at the OVERLAY layer with namespace "keyboard" on the eDP-1 output, and waits for the compositor’s configure event before proceeding. The keyboard interactivity is set to None — no keyboard focus is claimed, because all synthesized keystrokes reach the compositor through /dev/uinput, not through the Wayland surface.

Sources: portal/input/src/bin/portal-keyboardd/main.rs#L40-L66, portal/input/src/bin/portal-keyboardd/app_wayland.rs#L111-L216, portal/input/src/bin/portal-keyboardd/wayland_surface.rs#L1-L50, portal/input/src/bin/portal-keyboardd/app/mod.rs#L1-L73

Aspect Wayland Layer-Shell (Active) Direct DRM/KMS (Legacy)
Surface creation zwlr_layer_surface_v1 via SCTK drmModeSetCrtc on eDP/HDMI-A-1
EGL platform EGL_PLATFORM_WAYLAND_KHR EGL_PLATFORM_GBM_KHR or dumb buffer
Rendering engine Rive (state machine + GL renderer) GLES shader program (fullscreen quad + glow) or dumb buffer pixel copy
Vsync Compositor frame callbacks DRM PageFlipFlags::EVENT + poll(2)
Compositor bypass No (cooperates with Wayfire) Yes (direct scanout, needs DRM master)
msm driver support Works Falls back to dumb buffer (no glow)
Touch source Direct evdev (wl_seat caps are 0) Direct evdev

Sources: portal/input/src/bin/portal-keyboardd/wayland_egl.rs#L1-L15, portal/input/src/bin/portal-keyboardd/app/mod.rs#L175-L223, portal/input/src/bin/portal-keyboardd/wayland_surface.rs#L107-L141

The DrmDevice struct owns the DRM file descriptor and tracks the selected connector, CRTC, and display mode. Device bring-up follows the same three-phase sequence as the original C daemon: find connector → find CRTC → set mode.

The discovery logic iterates all DRM connectors and selects one using a priority system. eDP connectors receive priority 2 (Spaceboard built-in panel); HDMI-A-1 connectors with interface_id == 1 receive priority 1 (Orange Pi external display). All other interfaces are rejected. Once a connector is selected, the preferred display mode is chosen by checking for ModeTypeFlags::PREFERRED, falling back to the first available mode if none is flagged.

Sources: portal/input/src/bin/portal-keyboardd/drm.rs#L177-L232

CRTC discovery uses a two-strategy approach. First, the connector’s current encoder is queried — if it has an attached CRTC that exists, that CRTC is reused. If no current encoder is available, the code iterates all encoders listed by the connector and uses ResourceHandles::filter_crtcs to resolve each encoder’s possible_crtcs bitmask into concrete CRTC handles, returning the first that responds successfully to get_crtc.

Sources: portal/input/src/bin/portal-keyboardd/drm.rs#L240-L278

Once the initial framebuffer is displayed via set_crtc, the render loop drives frame updates through page_flip:

sequenceDiagram
    participant Main as Main Loop
    participant DRM as DrmDevice
    participant Kernel as DRM Kernel
    participant GBM as GbmState

    Main->>DRM: page_flip(next_fb)
    DRM->>Kernel: drmModePageFlip(EVENT)
    Main->>Kernel: poll(drm_fd, POLLIN)
    Kernel-->>Main: page-flip completion
    Main->>DRM: handle_events()
    DRM-->>Main: flip_completed = true
    Main->>GBM: release_previous()
    Main->>GBM: rotate_buffers(next_bo)

The page-flip flow includes a DRM master recovery path: if page_flip returns EACCES, another client (typically Wayfire) has taken DRM master. The daemon re-acquires master via acquire_master_lock() and retries the flip. If recovery fails, the error propagates as a fatal DrmInit error.

Sources: portal/input/src/bin/portal-keyboardd/drm.rs#L307-L349, portal/input/src/bin/portal-keyboardd/drm.rs#L409-L458, portal/input/src/bin/portal-keyboardd/app/display.rs#L86-L168

When the Mesa msm driver is detected (via /sys/class/drm/card0/device/driver symlink) or PORTAL_NO_EGL=1 is set, EGL/GBM initialization is skipped entirely. The daemon creates a DRM dumb buffer via drm_ffi::mode::dumbbuffer::create, maps it with mmap(PROT_READ | PROT_WRITE, MAP_SHARED), and registers it as a DRM framebuffer via drmModeAddFB. The upload_rgba method performs per-pixel RGBA→BGRA conversion (swapping R and B channels) with forced alpha 0xFF, since the dumb buffer path has no shader stage to handle color conversion.

Sources: portal/input/src/bin/portal-keyboardd/dumb_buffer.rs#L1-L135, portal/input/src/bin/portal-keyboardd/app/mod.rs#L175-L194

The GbmState struct bridges EGL rendering and DRM scanout. It owns a gbm::Device<Card> wrapping the DRM file descriptor and a gbm::Surface<()> configured for XRGB8888 format with SCANOUT | RENDERING usage flags. After each eglSwapBuffers, the main loop calls lock_front_buffer() which locks the GBM front buffer, creates a DRM framebuffer via drmModeAddFB (or returns a cached one), and returns the framebuffer handle plus buffer object.

Buffer rotation follows the C daemon’s pattern: the current buffer object becomes the previous buffer object (released after the next page flip completes), while the newly locked buffer becomes current. A HashMap<u64, framebuffer::Handle> caches DRM framebuffers keyed by the GBM buffer handle’s raw u32 value, bounded in practice to ~3 entries due to double/triple buffering.

Sources: portal/input/src/bin/portal-keyboardd/gbm_surface.rs#L62-L127, portal/input/src/bin/portal-keyboardd/gbm_surface.rs#L149-L250

The render_keyboard function produces a static 5-row QWERTY layout as RGBA pixel data plus a vector of KeyDef structs carrying pixel positions and Linux evdev keycodes for hit-testing. The function is called during initialization; in the Wayland path, only the key definitions are retained (pixels are discarded in favor of the Rive renderer). In the DRM path, the pixels are uploaded as an OpenGL ES texture or copied into the dumb buffer.

Constant Value Purpose
KEYBOARD_WIDTH_PERCENT 0.85 Keyboard occupies 85% of screen width
KEYBOARD_HEIGHT_PERCENT 0.40 Keyboard occupies 40% of screen height
TOP_MARGIN_PERCENT 0.05 5% top margin above keyboard
GAP 4.0 px Inter-key gap (inset on all four sides)
Key background #32323C Dark gray with slight blue tint
Keyboard region #14141E Very dark blue-gray panel

Each key is keyboard_width / 12 pixels wide and keyboard_height / 5 pixels tall. The layout organizes five rows: digits (12 keys, uniform width), Q-P row (10 keys, left-aligned), A-L row (9 keys, centered), Shift+Z-M+Backspace row (variable widths — Shift and Backspace at 1.5×), and a bottom row with Portal/Warp/Space/Vortex/Backspace at 1.2×/1.2×/4.8×/1.2×/1.6× widths.

Sources: portal/input/src/bin/portal-keyboardd/cairo_render.rs#L27-L86, portal/input/src/bin/portal-keyboardd/cairo_render.rs#L162-L458

Cairo’s ImageSurface with Format::ARgb32 stores pixels in BGRA byte order on little-endian systems. The render_keyboard function performs a per-pixel conversion loop after flushing the surface, reading four bytes [B, G, R, A] and writing [R, G, B, A] to produce RGBA data suitable for glTexImage2D(GL_RGBA, GL_UNSIGNED_BYTE). The conversion accounts for stride differences between the Cairo surface stride and the tightly packed RGBA output.

Sources: portal/input/src/bin/portal-keyboardd/cairo_render.rs#L460-L490

Key labels are rendered using pangocairo::functions::create_layout and show_layout. The font specification follows a "Sans {size}" or "Sans Bold {size}" pattern depending on the bold flag. Text is centered within each key cell by querying layout.pixel_size() and offsetting the draw position by half the text dimensions. Letter keys (Q-P, A-L, Z-M) use 18pt bold; digit keys use 16pt regular; special-function keys (Portal, Warp, Vortex) use 14pt with varying bold settings.

Sources: portal/input/src/bin/portal-keyboardd/cairo_render.rs#L116-L134

The GlesRenderer (used only in the legacy DRM path) compiles a GLSL ES 3.00 shader program that renders the Cairo keyboard texture on a fullscreen quad with up to 10 concurrent per-key glow animations. The vertex shader passes through position and texture coordinates. The fragment shader samples the texture, then accumulates glow contributions from active slots.

Each glow slot follows a three-phase temporal envelope: rise (0–150ms linear ramp to full intensity), hold (150–500ms at peak), decay (500–800ms linear fade to zero). Spatial falloff uses smoothstep on the key’s bounding box with a 6-pixel bloom expansion, combined with a center-based radial falloff (1.0 - normalized_distance * 0.3) that keeps glows brightest at the key center.

The fullscreen quad vertices flip the V texture coordinate: Cairo row 0 (top of image) maps to V=0, while OpenGL’s bottom-left origin expects V=1 at the bottom. This is encoded directly in the vertex data so the fragment shader receives correctly oriented coordinates.

Sources: portal/input/src/bin/portal-keyboardd/gles.rs#L34-L104, portal/input/src/bin/portal-keyboardd/gles.rs#L110-L153, portal/input/src/bin/portal-keyboardd/gles.rs#L319-L399

The Wayland daemon replaces the Cairo+GLES stack with a Rive state machine renderer. The keyboard.riv file is embedded at compile time via include_bytes! and loaded through a C++ FFI bridge (rive_bridge.cpp / rive_bridge.rs). The renderer constructs a dependency chain — RiveContextRiveFileRiveArtboardRiveStateMachine — where each object is deliberately leaked via Box::leak to obtain 'static references. This avoids the undefined behavior of transmuting shorter-lived borrows, since the Rive object graph must live for the entire daemon lifetime.

Glow feedback in the Rive path uses a fixed-size [GlowSlot; 10] array with 0.8-second expiry. Each glow activation records a key index, an intent color constant (1=green for typing, 2=orange for pointing, 3=blue for gestures), and a monotonic start time. The state machine is advanced each frame by the delta time, and the artboard is drawn to the current GL framebuffer via the Rive GL renderer.

Sources: portal/input/src/bin/portal-keyboardd/rive_renderer.rs#L1-L171, portal/input/src/bin/portal-keyboardd/app_wayland.rs#L228-L325

The original raw evdev parsing code in evdev.rs has been deprecated and removed in favor of a library-based three-stage pipeline. The tombstone module documents this migration explicitly. All touch functionality now lives in portal_input::touch::* and is shared between the keyboard daemon and the fusion engine.

flowchart LR
    subgraph "Stage 1: Capture"
        SC["EvdevDeviceScanner<br/>scan /dev/input/event*"] --> DS["Device Selection<br/>(vendor:product whitelist)"]
        DS --> EC["EvdevTouchCapture<br/>open + O_NONBLOCK + fetch_events()"]
    end

    subgraph "Stage 2: Track"
        EC -->|"RawEvdevEvent stream"| ST["SlotTracker<br/>Protocol B (ABS_MT_SLOT)"]
    end

    subgraph "Stage 3: Normalize"
        ST -->|"on SYN_REPORT"| DN["DeviceNormalizer<br/>ioctl caps → [0,1]"]
        DN --> TE["TouchEvent"]
    end

EvdevDeviceScanner probes /dev/input/event0 through event31, opening each device and checking for ABS_MT_SLOT support (the Protocol B requirement). Devices that don’t support MT-B are filtered out. The selection policy enforces a strict vendor:product whitelist with no generic fallback:

Vendor Product ID Hardware
ILITEK (0x222a) 0x0001 Orange Pi touchscreen
ELAN (0x04f3) 0x4328 Spaceboard hid-over-i2c digitizer

An explicit device path in DeviceSelectionConfig bypasses vendor:product matching, enabling testing with alternative hardware.

Sources: portal/input/src/touch/capture/mod.rs#L1-L60, portal/input/src/touch/capture/scanner.rs#L1-L162

The SlotTracker implements Linux’s Multi-Touch Protocol Type B using a fixed-size [SlotState; MAX_SLOTS] array (10 slots, zero heap allocation on the hot path). Events are processed one at a time: EV_ABS events update slot state (ABS_MT_SLOT switches the active slot, ABS_MT_TRACKING_ID sets touch start/end/move, ABS_MT_POSITION_X/Y update coordinates, ABS_MT_TOUCH_MAJOR records contact area for palm rejection). A TouchEvent is emitted only on SYN_REPORT, when the build_touch_event method iterates all slots, normalizes coordinates via the injected TouchNormalizer, and computes the overall event phase from changed slots.

The tracking ID state machine handles four transitions: negative-to-positive (new touch, phase=Start), positive-to-negative (touch ended, phase=End), positive-to-positive with different ID (slot reuse, phase=Start), and positive-to-positive with same ID (position update, phase=Move).

Sources: portal/input/src/touch/tracker.rs#L1-L83, portal/input/src/touch/tracker.rs#L86-L133, portal/input/src/touch/tracker.rs#L135-L236

DeviceNormalizer maps raw evdev coordinates to the [0.0, 1.0] range using device capabilities queried once via ioctl at construction time. The normalization formula is ((raw - min) / (max - min)).clamp(0.0, 1.0), with a guard against zero range. The same struct provides a denormalize method for reverse conversion, used by downstream consumers that need to map normalized coordinates back to device resolution.

Sources: portal/input/src/touch/normalizer.rs#L1-L63

Event Reading with Bounds and Rate Filtering

Section titled “Event Reading with Bounds and Rate Filtering”

EvdevTouchCapture::read_events performs two filtering passes on the raw event stream. First, ABS_MT_POSITION_X/Y events are checked against device-reported bounds (min/max from ioctl) — out-of-bounds events are dropped with a tracing::debug! log. Second, a rate cap of 1000 events/second (MAX_EVENTS_PER_SEC) drops events whose timestamp is within MIN_INTER_EVENT_US (1000µs) of the last accepted event. Reading stops at the first SYN_REPORT boundary, producing a batch of events that represents one complete touch frame.

Sources: portal/input/src/touch/capture/backend.rs#L163-L250, portal/input/src/touch/capture/mod.rs#L39-L48

Both display paths share the same concurrency architecture for touch-to-action processing. A sync_channel(64) pair — designated Channel A (main → worker) and Channel D (worker → main) — decouples the input thread from the rendering thread.

flowchart LR
    subgraph "Main Loop Thread"
        TC["EvdevTouchCapture<br/>read_events()"] --> TT["SlotTracker<br/>process_event()"]
        TT --> ET["enrich_touch_event()<br/>hit-test + slot_keys"]
        ET -->|"try_send"| CA["Channel A<br/>sync_channel(64)"]
        CD["Channel D<br/>sync_channel(64)"] -->|"try_recv"| AD["ActionDispatcher"]
        AD --> UK["UinputKeyboard"]
        AD --> UP["UinputPointer"]
        CD -->|"try_recv"| RIV["RiveRenderer<br/>set_key_glow()"]
    end

    subgraph "Fusion Worker Thread"
        CA -->|"recv_timeout(100ms)"| FE["V21FusionEngine<br/>process_touch_event()"]
        FE -->|"send"| CD
    end

The EnrichedTouchEvent carries the normalized TouchEvent plus a slot_keys: Vec<(u32, Option<u16>)> mapping — each touch slot to the evdev keycode of the key it pressed (if hit-testing found a match). This mapping is computed on the main thread using the Cairo key definitions, before the event enters the channel, so the fusion engine receives pre-resolved key context without needing access to the layout geometry.

The fusion worker runs V21FusionEngine in a dedicated thread, processing each enriched event through intent classification and action generation. Results flow back on Channel D as FusionChannelResult containing classified intents and the original touch event (for glow source-event correlation). The main loop drains Channel D with try_recv in a non-blocking loop, dispatching intents to virtual devices and triggering glow animations.

Sources: portal/input/src/bin/portal-keyboardd/app_wayland.rs#L64-L100, portal/input/src/bin/portal-keyboardd/app_wayland.rs#L252-L367, portal/input/src/bin/portal-keyboardd/app/fusion_worker.rs#L1-L81

The ActionDispatcher translates InputAction variants from the fusion engine into concrete uinput device calls. Each action maps to specific key sequences on the virtual keyboard and pointer:

InputAction uinput Effect
KeyPress / KeyRelease keyboard.emit_key(code, pressed)
PointerMove pointer.move_pointer(dx, dy)
PointerClick pointer.click(button)
PointerDrag pointer.press_button + move_pointer
PointerScroll pointer.scroll(axis, amount)
PointerZoom(factor > 1) Ctrl+Equal key combo
SystemAction::Launcher Alt+F2
SystemAction::AppSwitcher Alt+Tab
SystemAction::Overview Ctrl+KeyUp
SystemAction::SpatialMode Ctrl+Alt+Right

Key combos are emitted in the correct order: modifiers pressed first, then the target key pressed and released, then modifiers released in reverse order. Errors from individual device calls are logged but do not block subsequent actions.

Sources: portal/input/src/bin/portal-keyboardd/action_dispatch.rs#L42-L200

Both display paths use libloading to dynamically load libEGL.so.1 rather than linking against it at build time. This accommodates GPU-specific EGL implementations (Mali, freedreno/turnip) that may not expose all extension entry points through static linking. The EGL initialization sequence:

  1. Load library and resolve eglGetProcAddress, eglGetError, eglQueryString
  2. Get platform display — GBM path uses EGL_PLATFORM_GBM_KHR (0x31D7); Wayland path uses EGL_PLATFORM_WAYLAND_KHR (0x31D8). The Wayland path additionally loads libwayland-egl.so for wl_egl_window_create
  3. Choose config requesting GLES3 renderable, RGBA8888, WINDOW_BIT
  4. Create context with EGL_CONTEXT_CLIENT_VERSION = 3
  5. Create window surface — GBM path uses eglCreateWindowSurface with the GBM surface pointer; Wayland path uses wl_egl_window_create to wrap the wl_surface, then eglCreateWindowSurface
  6. Make current — EGL context is current on the main thread for all GL operations

The Wayland path resolves function pointers through eglGetProcAddress and uses a centralized egl_cast_fn<T> transmute helper to convert the *mut c_void return into typed function pointers, isolating the single unsafe transmute site.

Sources: portal/input/src/bin/portal-keyboardd/egl.rs#L1-L200, portal/input/src/bin/portal-keyboardd/wayland_egl.rs#L1-L200, portal/input/src/bin/portal-keyboardd/wayland_egl.rs#L117-L127

The WaylandSurface struct owns the Wayland connection, event queue, and protocol state. It implements Dispatch for 11 Wayland protocol objects: WlRegistry, WlCompositor, WlOutput, ZxdgOutputManagerV1, ZxdgOutputV1, ZwlrLayerShellV1, ZwlrLayerSurfaceV1, WlSurface, WlSeat, WlTouch, and WlCallback.

The registry handler binds globals with version caps (wl_compositor ≤6, wl_output ≤4, zwlr_layer_shell_v1 ≤4, wl_seat ≤7). Output discovery uses zxdg_output_v1::get_name to match the eDP-1 target, falling back to description substring match, then to the first available output. The layer surface is configured with Anchor::all (top/bottom/left/right), exclusive_zone = -1 (overlay), and KeyboardInteractivity::None.

Although the surface binds wl_touch when the seat advertises touch capabilities, the daemon does not rely on Wayland touch events. Wayfire’s DRM backend doesn’t create a libinput backend, so wl_seat reports zero touch capabilities and wl_touch never fires. All touch input comes from the direct evdev pipeline.

Sources: portal/input/src/bin/portal-keyboardd/wayland_surface.rs#L146-L194, portal/input/src/bin/portal-keyboardd/wayland_surface.rs#L315-L370, portal/input/src/bin/portal-keyboardd/app_wayland.rs#L115-L119

The daemon runs as the portal-input.service systemd unit under the portal user. The service waits for the Wayland socket at /run/portal/wayland-1 before starting, then grants /dev/uinput group ownership to portal. Security hardening includes NoNewPrivileges, ProtectSystem=strict, MemoryDenyWriteExecute, and an empty CapabilityBoundingSet/dev/uinput access is granted through udev group ownership, not capabilities.

Sources: portal/systemd/portal-input.service#L1-L45

The touch pipeline defines its types in portal_input::touch::types with a focus on fixed-size, stack-allocated structures for the hot path. TouchEvent contains a [TouchPoint; MAX_SLOTS] fixed array (not a Vec), changed_slots as a u16 bitmask, and primary_slot as a u8. RawEvdevEvent is Copy for efficient pass-by-value through the channel. DeviceCapabilities is Clone with pre-computed Option<i32> fields for touch-major bounds.

Sources: portal/input/src/touch/types.rs#L17-L184, portal/input/src/touch/types.rs#L260-L292