git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitddccacbd30213a54a5a53f9d7028c8ed0e1c16f5
parenta06a8be4e1
authorLucas Galante <[email protected]>
date2026-08-22 14:36
feat: give a keyboard-less seat a synthetic keyboard

Injected keys did nothing on the headless backend while injected pointer
events worked, which made every key-gated behavior unverifiable in a
shadow session.

The seat advertises WL_SEAT_CAPABILITY_KEYBOARD unconditionally, so a
client always binds wl_keyboard — but the keymap only reaches it through
wlr_seat_set_keyboard, which ran solely from attach_device, i.e. only
once a real keyboard device existed. Headless has none, so no keymap was
ever sent, and a client without a keymap cannot turn a keycode into a
keysym: wlr_seat_keyboard_notify_key delivered events that were dropped
in silence.

Seat::ensure_synthetic_keyboard creates a device-less KeyboardGroup and
sets it on the seat, called from the three synthetic-input IPC commands.
It reuses the group machinery rather than hand-rolling a bare
wlr_keyboard: create/unref already own the wlr_keyboard_init/finish and
keymap refcounting, and a bare keyboard would leave
river_wlr_keyboard_get_data null, costing keyboard_notify_enter its
pressed-key tracking.

A real keyboard always wins — the function returns immediately when the
seat has one, so a session with hardware never reaches the creation path
and is bit-for-bit unaffected. Seat::destroy drops the group before its
assert that keyboard_groups is empty; nothing else would, since there is
no device to outlive it.

Verified in a shadow session: "/" opened cce-mail's search band, typed
characters filtered the list (so the keymap translates), Escape and a
ctrl+n chord both dispatched, the group was created exactly once, and
deinit finished with the assert intact.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/server/seat.rs           | 71 ++++++++++++++++++++++++++++++++++++++++++++
 src/server/window_manager.rs |  5 ++++
 2 files changed, 76 insertions(+)

diff --git a/src/server/seat.rs b/src/server/seat.rs
index f664a59..bccdc30 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -88,6 +88,10 @@ pub struct Seat {
     pub xkb_bindings: ffi::wl_list,
     pub pointer_bindings: ffi::wl_list,
     pub keyboard_groups: ffi::wl_list,
+    /// A device-less keyboard group, created on demand by
+    /// [`Seat::ensure_synthetic_keyboard`] when the backend supplies no
+    /// keyboard at all. Null on any seat that has a real one.
+    pub synthetic_keyboard: *mut crate::keyboard_group::KeyboardGroup,
     pub modifiers_old: u32,
     pub op: Option<SeatOp>,
     pub op_release: bool,
@@ -144,6 +148,7 @@ impl Seat {
             xkb_bindings: std::mem::zeroed(),
             pointer_bindings: std::mem::zeroed(),
             keyboard_groups: std::mem::zeroed(),
+            synthetic_keyboard: std::ptr::null_mut(),
             modifiers_old: 0,
             op: None,
             op_release: false,
@@ -247,6 +252,15 @@ impl Seat {
 
         (*seat).cursor.deinit();
 
+        // The synthetic keyboard has no device to outlive, so nothing else
+        // will ever drop its reference — and the assert below requires the
+        // group list to be empty by now.
+        if !(*seat).synthetic_keyboard.is_null() {
+            let group = (*seat).synthetic_keyboard;
+            (*seat).synthetic_keyboard = std::ptr::null_mut();
+            (*group).unref(&[]);
+        }
+
         // Verify keyboard_groups is empty
         let groups_head = &mut (*seat).keyboard_groups as *mut ffi::wl_list as *mut crate::server::WlList;
         assert_eq!((*groups_head).next, groups_head);
@@ -605,6 +619,63 @@ impl Seat {
         self.relay.focus(target_surface);
     }
 
+    /// Give this seat a keyboard if the backend never supplied one, so that
+    /// synthetic keys have somewhere to land. Returns false only when no
+    /// keymap is configured, leaving nothing worth attaching.
+    ///
+    /// The seat advertises `WL_SEAT_CAPABILITY_KEYBOARD` unconditionally (see
+    /// `update_capabilities`), so a client always binds `wl_keyboard`. But the
+    /// keymap reaches that client from `wlr_seat_set_keyboard`, which only ever
+    /// ran from `attach_device` — i.e. only once a real keyboard device
+    /// existed. On the headless backend there is no keyboard device, so no
+    /// keymap was ever sent, and a client with no keymap cannot turn a keycode
+    /// into a keysym: `wlr_seat_keyboard_notify_key` delivered events that were
+    /// silently dropped. That is why injected keys did nothing in a shadow
+    /// session while injected pointer events worked.
+    ///
+    /// A real keyboard always wins — this is a no-op the moment the seat has
+    /// one, so a normal session never reaches the creation path.
+    pub unsafe fn ensure_synthetic_keyboard(&mut self) -> bool {
+        if !ffi::river_wlr_seat_get_keyboard(self.wlr_seat).is_null() {
+            return true;
+        }
+
+        let keymap = (*self.server).xkb_config.default_keymap;
+        if keymap.is_null() {
+            log::warn!("[seat] no keymap configured; synthetic keys cannot be delivered");
+            return false;
+        }
+
+        // `KeyboardGroup::create` takes its own keymap reference and does the
+        // `wlr_keyboard_init`/`set_keymap` wiring, so this borrows the group
+        // machinery whole rather than hand-rolling a bare wlr_keyboard — which
+        // would also leave `river_wlr_keyboard_get_data` null and cost
+        // `keyboard_notify_enter` its pressed-key tracking.
+        let config = crate::keyboard::KeyboardConfig {
+            keymap,
+            repeat_rate: 40,
+            repeat_delay: 400,
+        };
+        match crate::keyboard_group::KeyboardGroup::create(self, config, true) {
+            Ok(group) => {
+                self.synthetic_keyboard = group;
+                // set_keyboard is what pushes the keymap out to every bound
+                // client; the enter re-announces focus with it in place.
+                ffi::wlr_seat_set_keyboard(self.wlr_seat, &mut (*group).wlr_keyboard);
+                let focused = ffi::river_wlr_seat_get_keyboard_focused_surface(self.wlr_seat);
+                if !focused.is_null() {
+                    self.keyboard_notify_enter(focused);
+                }
+                log::info!("[seat] no keyboard device on this backend — created a synthetic one so injected keys reach clients");
+                true
+            }
+            Err(err) => {
+                log::error!("[seat] failed to create synthetic keyboard: {}", err);
+                false
+            }
+        }
+    }
+
     pub unsafe fn keyboard_notify_enter(&mut self, wlr_surface: *mut ffi::wlr_surface) {
         if wlr_surface.is_null() {
             return;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index b76062f..d280479 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -4304,6 +4304,7 @@ impl WindowManager {
                     while curr_seat != seats_list {
                         let next_seat = (*curr_seat).next;
                         let seat = crate::container_of!(curr_seat, crate::seat::Seat, link);
+                        (*seat).ensure_synthetic_keyboard();
                         ffi::wlr_seat_keyboard_notify_key((*seat).wlr_seat, crate::util::msec_timestamp(), keycode, state);
                         if let Some(name) = mod_name {
                             let kb = ffi::river_wlr_seat_get_keyboard((*seat).wlr_seat);
@@ -4339,6 +4340,10 @@ impl WindowManager {
                     while curr_seat != seats_list {
                         let next_seat = (*curr_seat).next;
                         let seat = crate::container_of!(curr_seat, crate::seat::Seat, link);
+                        // A backend with no keyboard device leaves clients
+                        // without a keymap, and a keymap-less client drops
+                        // every key we notify. Attach one first.
+                        (*seat).ensure_synthetic_keyboard();
                         let time = crate::util::msec_timestamp();
                         ffi::wlr_seat_keyboard_notify_key((*seat).wlr_seat, time, keycode, ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED);
                         ffi::wlr_seat_keyboard_notify_key((*seat).wlr_seat, time + 1, keycode, ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_RELEASED);