From 94be547da0abcc72ea0300c6ceb0f2f126e6b246 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 15 Aug 2026 20:25:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(input):=20a=20German=20keyboard=20typed=20U?= =?UTF-8?q?S=20characters=20=E2=80=94=20nothing=20carried=20the=20box's=20?= =?UTF-8?q?layout=20into=20the=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from an iPad on a Bazzite host in Game Mode: `#` arrived as `\`, `-` as `/`, `ä` as `'`. That set is not random — they are the US ANSI keys that sit where the German ISO ones do, which says the client was right and the host session was resolving positions with a US keymap. The key wire is US-POSITIONAL by design: a client sends the physical key, `vk_to_evdev` turns it into an evdev code, and the session's keymap picks the character. So the contract is "host layout == the layout on the client's keyboard", and nothing was upholding it: - Nothing exports `XKB_DEFAULT_*`. `localectl set-x11-keymap de` writes /etc/X11/xorg.conf.d/00-keyboard.conf — a file only Xorg reads — and libxkbcommon's fallback chain stops at the env vars, which no session manager sets. Compiling from empty names on a properly-configured German box therefore yielded evdev/pc105/us, silently. - gamescope reads those env vars and then publishes the keymap to nobody. It builds the keymap onto `keyboard_group`, but the seat carries `virtual_keyboard_device` — a stub whose own comment says it exists "only to set the keymap" and which never gets one. `wlserver_keyboardfocus()` rebinds that stub on every focus change, and the real group only reaches the seat from a libinput key event, which a `--backend headless` session never has. Verified on the box: both Xwayland servers map evdev 53/40/43 to slash/apostrophe/backslash on a de/nodeadkeys machine. `pf_host_config::layout` resolves what the box actually recorded (`XKB_DEFAULT_*`, then xorg.conf.d, then vconsole's XKBLAYOUT — never vconsole's KEYMAP, whose names are console names and do not map onto xkb's). From there: - the wlroots injector compiles its uploaded keymap from it instead of from empty names, which fixes Sway/Hyprland hosts outright; - all four gamescope launch paths hand the session `XKB_DEFAULT_*`, gated behind a `+pfhdr8` probe that warns when the binary predates the fix rather than leaving it unexplained; - `sync_session_keyboard_layout()` covers the case none of that reaches — the autologin session punktfunk ATTACHES to, where no launch-time decision applies — by pointing each gamescope Xwayland at the box's layout on adoption. Everything in Game Mode is an X11 client of those servers, so this is the leg that fixes the report. `PUNKTFUNK_SESSION_LAYOUT=0` turns it off. gamescope patch 0010 sets the keymap on the stub as well, which is what makes the env legs mean anything for Wayland-native clients. It is NOT build-verified (no gamescope build environment here); the series does apply `git am`-clean at the pinned 5fb8dce4. Verified: 10 new unit tests over the resolver; pf-inject + pf-host-config clippy `-D warnings` and those tests on real linux-gnu in the CI image; pf-vdisplay checks and clippies clean on linux-gnu via scripts/xcheck.sh, confirmed non-vacuous with a planted error. The character mapping itself was confirmed on the box with `xmodmap -pke` before and after applying the layout. --- crates/pf-host-config/src/layout.rs | 425 ++++++++++++++++++ crates/pf-host-config/src/lib.rs | 6 + crates/pf-inject/src/inject/linux/wlr.rs | 39 +- crates/pf-inject/src/lib.rs | 5 +- .../src/vdisplay/linux/gamescope.rs | 152 ++++++- .../src/vdisplay/linux/gamescope/discovery.rs | 13 + docs-site/content/docs/troubleshooting.md | 30 ++ packaging/gamescope/README.md | 2 + ...e-seat-s-stub-keyboard-the-compiled-.patch | 67 +++ 9 files changed, 726 insertions(+), 13 deletions(-) create mode 100644 crates/pf-host-config/src/layout.rs create mode 100644 packaging/gamescope/patches/0010-wlserver-give-the-seat-s-stub-keyboard-the-compiled-.patch diff --git a/crates/pf-host-config/src/layout.rs b/crates/pf-host-config/src/layout.rs new file mode 100644 index 00000000..159be85b --- /dev/null +++ b/crates/pf-host-config/src/layout.rs @@ -0,0 +1,425 @@ +//! Where the host session's keyboard LAYOUT comes from. +//! +//! punktfunk's key wire is **US-positional**: a client sends the Windows VK of the *physical* key +//! it saw, [`vk_to_evdev`](super::keymap::vk_to_evdev) turns that into a Linux evdev code, and the +//! **session's keymap** is what decides which character that position finally produces. The +//! standing contract is therefore "host layout == the layout printed on the client's keyboard" — +//! a German keyboard needs a German session, or its ISO keys render as their US neighbours +//! (`#`→`\`, `ä`→`'`, `-`→`/`, the y↔z swap). +//! +//! Nothing on a Wayland box arranges that by itself. `localectl set-x11-keymap de` records the +//! choice in `/etc/X11/xorg.conf.d/00-keyboard.conf` (and, on systemd ≥ 249, `/etc/vconsole.conf`), +//! but that file is read by **Xorg** — a Wayland compositor never opens it. libxkbcommon's own +//! fallback chain stops at the `XKB_DEFAULT_*` env vars, and no session manager exports those. So +//! compiling a keymap from empty names on a properly-configured German box still silently yields +//! evdev/pc105/**us**, which is exactly the scramble above. +//! +//! [`system_layout`] reads what the machine actually recorded so the injected keyboard follows the +//! box. It is advisory for backends whose keymap we do not own (libei/KWin/gamescope resolve our +//! evdev codes against the compositor's own keymap — see [`crate::text_input_supported`]); there it +//! only feeds the diagnostic, and the operator has to fix the session itself. + +use std::path::{Path, PathBuf}; + +/// The five xkb rule names, each `None` when nothing configured it (libxkbcommon then applies its +/// own built-in default — `evdev`/`pc105`/`us`/``/``). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct XkbNames { + pub rules: Option, + pub model: Option, + pub layout: Option, + pub variant: Option, + pub options: Option, +} + +impl XkbNames { + /// Nothing at all was configured ⇒ the compiled keymap will be libxkbcommon's US default. + pub fn is_empty(&self) -> bool { + self.rules.is_none() + && self.model.is_none() + && self.layout.is_none() + && self.variant.is_none() + && self.options.is_none() + } + + /// Fill every field this one leaves unset from `fallback` (per-field precedence, mirroring how + /// libxkbcommon itself falls back one `XKB_DEFAULT_*` variable at a time rather than taking a + /// source whole — a box that exports only `XKB_DEFAULT_LAYOUT` still keeps its configured + /// variant). + fn fill_from(&mut self, fallback: XkbNames) { + for (slot, value) in [ + (&mut self.rules, fallback.rules), + (&mut self.model, fallback.model), + (&mut self.layout, fallback.layout), + (&mut self.variant, fallback.variant), + (&mut self.options, fallback.options), + ] { + if slot.is_none() { + *slot = value; + } + } + } + + /// The names as `xkb_keymap_new_from_names` wants them: an empty string means "unset", which + /// is where libxkbcommon applies its own default for that field. + pub fn as_args(&self) -> (&str, &str, &str, &str, Option) { + ( + self.rules.as_deref().unwrap_or(""), + self.model.as_deref().unwrap_or(""), + self.layout.as_deref().unwrap_or(""), + self.variant.as_deref().unwrap_or(""), + self.options.clone(), + ) + } + + /// The same names as `XKB_DEFAULT_*` environment pairs, for a compositor punktfunk spawns + /// itself. Only the fields we actually resolved are emitted — exporting an empty + /// `XKB_DEFAULT_VARIANT` is not the same as leaving it unset, since an explicit empty string + /// *overrides* a variant the rules file would otherwise supply. + pub fn env_pairs(&self) -> Vec<(&'static str, String)> { + [ + ("XKB_DEFAULT_RULES", &self.rules), + ("XKB_DEFAULT_MODEL", &self.model), + ("XKB_DEFAULT_LAYOUT", &self.layout), + ("XKB_DEFAULT_VARIANT", &self.variant), + ("XKB_DEFAULT_OPTIONS", &self.options), + ] + .into_iter() + .filter_map(|(k, v)| v.as_ref().map(|v| (k, v.clone()))) + .collect() + } + + /// `de(nodeadkeys)` / `de` / `us (libxkbcommon default)` — for one readable log field. + pub fn describe(&self) -> String { + match (&self.layout, &self.variant) { + (Some(l), Some(v)) if !v.is_empty() => format!("{l}({v})"), + (Some(l), _) => l.clone(), + (None, _) => "us (libxkbcommon default)".to_string(), + } + } +} + +/// The resolved layout plus where it came from, so the log line can name the file an operator +/// would have to edit. +#[derive(Clone, Debug)] +pub struct SystemLayout { + pub names: XkbNames, + /// Origin of `names.layout` specifically — the field that matters and the only one worth + /// naming in a one-line log. + pub source: String, +} + +/// What `localectl set-x11-keymap` writes. +const X11_CONF_DIR: &str = "/etc/X11/xorg.conf.d"; +/// systemd ≥ 249 mirrors the X11 keymap here as `XKBLAYOUT=`/`XKBVARIANT=`/… +const VCONSOLE_CONF: &str = "/etc/vconsole.conf"; + +/// Resolve the host's configured keyboard layout: `XKB_DEFAULT_*` env (explicit operator intent, +/// and what libxkbcommon would have used anyway) → `/etc/X11/xorg.conf.d/*keyboard*.conf` → +/// `/etc/vconsole.conf`. Per-field, so a partially-configured box keeps whatever each source knows. +pub fn system_layout() -> SystemLayout { + resolve_from( + from_env(), + Path::new(X11_CONF_DIR), + Path::new(VCONSOLE_CONF), + ) +} + +/// [`system_layout`] with its three inputs injected — the env block is a parameter so the tests +/// never depend on the `XKB_DEFAULT_*` of whatever machine runs them. +fn resolve_from(env: XkbNames, x11_dir: &Path, vconsole: &Path) -> SystemLayout { + let mut names = env; + let mut source = if names.layout.is_some() { + "XKB_DEFAULT_LAYOUT".to_string() + } else { + String::new() + }; + + for path in x11_keyboard_confs(x11_dir) { + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let parsed = parse_x11_keyboard_conf(&text); + if source.is_empty() && parsed.layout.is_some() { + source = path.display().to_string(); + } + names.fill_from(parsed); + } + + if let Ok(text) = std::fs::read_to_string(vconsole) { + let parsed = parse_vconsole_conf(&text); + if source.is_empty() && parsed.layout.is_some() { + source = vconsole.display().to_string(); + } + names.fill_from(parsed); + } + + if source.is_empty() { + source = "unconfigured".to_string(); + } + SystemLayout { names, source } +} + +fn from_env() -> XkbNames { + let get = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + XkbNames { + rules: get("XKB_DEFAULT_RULES"), + model: get("XKB_DEFAULT_MODEL"), + layout: get("XKB_DEFAULT_LAYOUT"), + variant: get("XKB_DEFAULT_VARIANT"), + options: get("XKB_DEFAULT_OPTIONS"), + } +} + +/// Every `*keyboard*.conf` in the Xorg snippet directory, in **reverse** lexical order. Xorg +/// merges these low-to-high with the later file winning; [`resolve_from`] fills fields first-hit- +/// wins, so handing it the highest-numbered snippet first reproduces that precedence. +fn x11_keyboard_confs(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut out: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.extension().is_some_and(|e| e == "conf") + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("keyboard")) + }) + .collect(); + out.sort(); + out.reverse(); + out +} + +/// Pull `Option "XkbLayout" "de"` style entries out of an Xorg `InputClass` snippet. Deliberately +/// section-blind: `localectl` writes exactly one `MatchIsKeyboard` class, and scanning every +/// `Option` line beats half-implementing the Xorg config grammar. +fn parse_x11_keyboard_conf(text: &str) -> XkbNames { + let mut names = XkbNames::default(); + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let mut fields = line.split('"'); + // `Option ` | key | ` ` | value + let Some(head) = fields.next() else { continue }; + if !head.trim_end().eq_ignore_ascii_case("Option") { + continue; + } + let (Some(key), Some(_), Some(value)) = (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + let slot = match key.to_ascii_lowercase().as_str() { + "xkbrules" => &mut names.rules, + "xkbmodel" => &mut names.model, + "xkblayout" => &mut names.layout, + "xkbvariant" => &mut names.variant, + "xkboptions" => &mut names.options, + _ => continue, + }; + *slot = Some(value.to_string()); + } + names +} + +/// Pull `XKBLAYOUT=de` / `XKBVARIANT="nodeadkeys"` out of `/etc/vconsole.conf`. +/// +/// ⚠ `KEYMAP=` is deliberately ignored: it names a **console** keymap (`de-nodeadkeys`, `uk`, +/// `sg-latin1`), whose namespace only coincides with xkb's by accident — `uk` is xkb `gb`, and a +/// wrong guess here would mis-type every key rather than fall back to a visible default. +fn parse_vconsole_conf(text: &str) -> XkbNames { + let mut names = XkbNames::default(); + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().trim_matches(['"', '\'']).to_string(); + if value.is_empty() { + continue; + } + let slot = match key.trim().to_ascii_uppercase().as_str() { + "XKBRULES" => &mut names.rules, + "XKBMODEL" => &mut names.model, + "XKBLAYOUT" => &mut names.layout, + "XKBVARIANT" => &mut names.variant, + "XKBOPTIONS" => &mut names.options, + _ => continue, + }; + *slot = Some(value); + } + names +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim `localectl set-x11-keymap de pc105 nodeadkeys` output. + const LOCALECTL_DE: &str = r#"# Written by systemd-localed(8), read by systemd-localed and Xorg. It's +# probably wise not to edit this file manually. Use localectl(1) to +# update this file. +Section "InputClass" + Identifier "system-keyboard" + MatchIsKeyboard "on" + Option "XkbLayout" "de" + Option "XkbModel" "pc105" + Option "XkbVariant" "nodeadkeys" +EndSection +"#; + + #[test] + fn parses_localectl_x11_snippet() { + let n = parse_x11_keyboard_conf(LOCALECTL_DE); + assert_eq!(n.layout.as_deref(), Some("de")); + assert_eq!(n.model.as_deref(), Some("pc105")); + assert_eq!(n.variant.as_deref(), Some("nodeadkeys")); + assert_eq!(n.rules, None); + assert_eq!(n.describe(), "de(nodeadkeys)"); + } + + #[test] + fn x11_snippet_ignores_comments_and_non_xkb_options() { + let n = parse_x11_keyboard_conf( + "# Option \"XkbLayout\" \"fr\"\n\ + Identifier \"system-keyboard\"\n\ + Option \"XkbLayout\" \"ch\"\n\ + Option \"SomethingElse\" \"nope\"\n", + ); + assert_eq!(n.layout.as_deref(), Some("ch")); + assert!(n.options.is_none()); + } + + #[test] + fn parses_vconsole_and_ignores_console_keymap() { + // The KEYMAP= line must NOT become an xkb layout: console and xkb namespaces differ. + let n = parse_vconsole_conf("KEYMAP=\"de-nodeadkeys\"\nFONT=\"eurlatgr\"\n"); + assert!(n.is_empty(), "KEYMAP must not be read as an xkb layout"); + + let n = parse_vconsole_conf("XKBLAYOUT=de\nXKBVARIANT=\"nodeadkeys\"\nKEYMAP=de\n"); + assert_eq!(n.layout.as_deref(), Some("de")); + assert_eq!(n.variant.as_deref(), Some("nodeadkeys")); + } + + #[test] + fn empty_names_describe_as_the_us_default() { + let n = XkbNames::default(); + assert!(n.is_empty()); + assert_eq!(n.describe(), "us (libxkbcommon default)"); + assert_eq!(n.as_args(), ("", "", "", "", None)); + assert!(n.env_pairs().is_empty()); + } + + #[test] + fn per_field_fallback_keeps_the_more_specific_source() { + // Env named only the layout; the X11 snippet still supplies model/variant. + let mut n = XkbNames { + layout: Some("fr".into()), + ..Default::default() + }; + n.fill_from(parse_x11_keyboard_conf(LOCALECTL_DE)); + assert_eq!(n.layout.as_deref(), Some("fr"), "env layout must win"); + assert_eq!(n.variant.as_deref(), Some("nodeadkeys")); + assert_eq!(n.model.as_deref(), Some("pc105")); + } + + #[test] + fn env_pairs_round_trip_only_the_resolved_fields() { + let n = XkbNames { + layout: Some("de".into()), + variant: Some("nodeadkeys".into()), + ..Default::default() + }; + assert_eq!( + n.env_pairs(), + vec![ + ("XKB_DEFAULT_LAYOUT", "de".to_string()), + ("XKB_DEFAULT_VARIANT", "nodeadkeys".to_string()), + ] + ); + } + + /// A throwaway `/etc` stand-in; the caller writes the two files it cares about. + fn scratch(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pf-layout-{}-{tag}", std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(dir.join("xorg.conf.d")).unwrap(); + dir + } + + #[test] + fn resolve_reads_the_x11_snippet_then_vconsole() { + let dir = scratch("x11-then-vconsole"); + let x11 = dir.join("xorg.conf.d"); + std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap(); + let vconsole = dir.join("vconsole.conf"); + std::fs::write(&vconsole, "XKBLAYOUT=fr\nXKBOPTIONS=compose:ralt\n").unwrap(); + + let got = resolve_from(XkbNames::default(), &x11, &vconsole); + // The X11 snippet outranks vconsole, so its layout stands; vconsole still fills the + // options nothing else supplied. + assert_eq!(got.names.layout.as_deref(), Some("de")); + assert_eq!(got.names.variant.as_deref(), Some("nodeadkeys")); + assert_eq!(got.names.options.as_deref(), Some("compose:ralt")); + assert!(got.source.ends_with("00-keyboard.conf"), "{}", got.source); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_higher_numbered_xorg_snippet_wins() { + let dir = scratch("xorg-precedence"); + let x11 = dir.join("xorg.conf.d"); + std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap(); + std::fs::write( + x11.join("90-custom-keyboard.conf"), + "Option \"XkbLayout\" \"no\"\n", + ) + .unwrap(); + + let got = resolve_from(XkbNames::default(), &x11, &dir.join("absent")); + assert_eq!(got.names.layout.as_deref(), Some("no")); + // The `00-` file still supplies what `90-` left unsaid. + assert_eq!(got.names.variant.as_deref(), Some("nodeadkeys")); + assert!( + got.source.ends_with("90-custom-keyboard.conf"), + "{}", + got.source + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn env_outranks_every_file() { + let dir = scratch("env-wins"); + let x11 = dir.join("xorg.conf.d"); + std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap(); + + let env = XkbNames { + layout: Some("us".into()), + ..Default::default() + }; + let got = resolve_from(env, &x11, &dir.join("absent")); + assert_eq!(got.names.layout.as_deref(), Some("us")); + assert_eq!(got.source, "XKB_DEFAULT_LAYOUT"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn resolve_on_a_bare_box_reports_unconfigured() { + let missing = Path::new("/nonexistent/pf-layout-test"); + let got = resolve_from(XkbNames::default(), missing, missing); + assert_eq!(got.source, "unconfigured"); + assert!(got.names.is_empty()); + assert_eq!(got.names.describe(), "us (libxkbcommon default)"); + } +} diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index e4f7eb82..9dcdb183 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -33,6 +33,12 @@ //! share a name; do NOT conflate them. #![forbid(unsafe_code)] +/// Which keyboard LAYOUT the box is configured for. Not a `PUNKTFUNK_*` knob — it is read from +/// what `localectl` recorded — but it is host configuration every input path needs (the injector +/// compiles its keymap from it; the gamescope backend hands it to the session it launches), and it +/// lives here so both can reach it without either crate depending on the other. +pub mod layout; + use std::sync::OnceLock; /// Whether a `PUNKTFUNK_*` env var reads as ON, or `None` when it is unset — the host's diff --git a/crates/pf-inject/src/inject/linux/wlr.rs b/crates/pf-inject/src/inject/linux/wlr.rs index bd685bee..75e09492 100644 --- a/crates/pf-inject/src/inject/linux/wlr.rs +++ b/crates/pf-inject/src/inject/linux/wlr.rs @@ -275,18 +275,37 @@ impl WlrootsInjector { pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ()); let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ()); - // The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to - // the standard `XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS` env vars, then to - // libxkbcommon's built-ins (evdev/pc105/us) — so a non-US host sets e.g. - // `XKB_DEFAULT_LAYOUT=de` and the positional wire keys render as its layout (parity with - // the libei path, where the session compositor's own keymap applies). Previously this - // hardcoded "us", which forced US characters for the OEM/umlaut keys on every layout. + // The keymap the compositor resolves our raw evdev keycodes with. The wire keys are + // US-POSITIONAL, so this keymap is what decides the character each one finally types — + // it has to be the layout printed on the client's keyboard, or ISO keys render as their + // US neighbours (`#`→`\`, `ä`→`'`, `-`→`/`). + // + // Resolved from the box's own configuration (`crate::layout`), NOT from empty names: + // empty defers to `XKB_DEFAULT_*`, which nothing on a Wayland session exports, so a + // `localectl set-x11-keymap de` host silently compiled evdev/pc105/**us**. (Before that + // it hardcoded "us" outright.) `XKB_DEFAULT_*` still wins when an operator sets it. + let resolved = pf_host_config::layout::system_layout(); + let (rules, model, layout, variant, options) = resolved.names.as_args(); let ctx = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); - let keymap = - xkb::Keymap::new_from_names(&ctx, "", "", "", "", None, xkb::KEYMAP_COMPILE_NO_FLAGS) - .context("compile xkb keymap (check XKB_DEFAULT_LAYOUT/VARIANT/RULES if set)")?; + let keymap = xkb::Keymap::new_from_names( + &ctx, + rules, + model, + layout, + variant, + options, + xkb::KEYMAP_COMPILE_NO_FLAGS, + ) + .with_context(|| { + format!( + "compile xkb keymap {} (from {})", + resolved.names.describe(), + resolved.source + ) + })?; tracing::info!( - layout = %std::env::var("XKB_DEFAULT_LAYOUT").unwrap_or_else(|_| "us (default)".into()), + layout = %resolved.names.describe(), + source = %resolved.source, "virtual keyboard keymap compiled" ); let keymap_str = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1); diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 692f3916..9d304ea7 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -6,8 +6,9 @@ //! Sway always advertises. We connect as an ordinary Wayland client (the host process //! inherits Sway's `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, and translate //! events into virtual pointer/keyboard requests. Keyboard codes are Linux evdev; we upload an -//! xkb keymap (the host's layout via `XKB_DEFAULT_LAYOUT` et al., defaulting to evdev/US) and -//! track modifier state so the compositor resolves shifted keysyms correctly. +//! xkb keymap built from the box's configured layout (`pf_host_config::layout` — `XKB_DEFAULT_*`, +//! then what `localectl` recorded) and track modifier state so the compositor resolves shifted +//! keysyms correctly. //! //! Extracted into a subsystem crate (plan §W6): consumes `punktfunk_core::input` (the neutral //! event vocabulary) + `pf-driver-proto` (the HID wire contract), never the orchestrator. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 0fea5a39..8dd8a236 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -29,7 +29,7 @@ mod splash; use discovery::{ check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin, gamescope_can_composite_external_overlay, gamescope_can_offer_refresh_rates, - gamescope_node_present, poll_managed_node, wait_for_node, + gamescope_honours_xkb_env, gamescope_node_present, poll_managed_node, wait_for_node, }; pub(crate) use discovery::{ game_session_exited, gamescope_can_composite_cursor, gamescope_hdr_capable, is_available, @@ -1228,8 +1228,10 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode, hdr: bool) -> Re Environment=PF_H={h}\n\ Environment=PF_HZ={hz}\n\ Environment=\"PF_HDR_ARGS={hdr_args}\"\n\ + {xkb}\ UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n", shim = shim_dir.display(), + xkb = xkb_unit_lines(), w = mode.width, h = mode.height, hz = game_hz(mode.refresh_hz), @@ -1315,8 +1317,10 @@ fn write_session_plus_dropin( {binds}\ Environment=PF_HZ={hz}\n\ Environment=\"PF_HDR_ARGS={hdr_args}\"\n\ + {xkb}\ {wsi}", binds = bind.unit_lines(), + xkb = xkb_unit_lines(), hz = game_hz(mode.refresh_hz), hdr_args = hdr_args(hdr) .into_iter() @@ -3653,6 +3657,85 @@ fn point_injector_at_eis() { "gamescope: no connectable gamescope EIS socket found — input won't reach the session" ), } + sync_session_keyboard_layout(); +} + +/// Explicit-off kill switch for [`sync_session_keyboard_layout`]. +const LAYOUT_SYNC_ENV: &str = "PUNKTFUNK_SESSION_LAYOUT"; +/// `setxkbmap` talks to a local X server; anything slower than this is a server that is not +/// answering, and the connecting client is waiting on us. +const LAYOUT_SYNC_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); + +/// Align the session's Xwayland servers with the box's configured keyboard layout. +/// +/// [`xkb_env`] only reaches a session punktfunk LAUNCHES. The common case on a Bazzite / SteamOS +/// box is the opposite one: the gaming session is already up from autologin, punktfunk ATTACHES to +/// it, and nothing decided at launch time applies — its Xwayland servers keep the `us` they were +/// born with. That matters because everything a gamescope session runs (Steam Big Picture, and +/// every Proton game) is an X11 client of those servers, so their keymap is what turns punktfunk's +/// US-positional key codes into characters. Without this, a German keyboard types `\` for `#`, +/// `'` for `ä` and `/` for `-` no matter what `localectl` says. +/// +/// Best-effort and idempotent — re-applying the layout a server already has is a no-op, so this +/// runs on every adoption rather than reading the current one back. It does nothing at all when +/// the box configured no layout (there is nothing to align *to*, and inventing one would be worse +/// than the default), when no gamescope Xwayland is running, or when `PUNKTFUNK_SESSION_LAYOUT` is +/// explicitly off. +/// +/// ⚠ Xwayland only. A Wayland-native client under gamescope takes the compositor's own keymap, +/// which is [`xkb_env`] plus the `+pfhdr8` patch — the two halves are not interchangeable. +fn sync_session_keyboard_layout() { + if pf_host_config::env_on(LAYOUT_SYNC_ENV) == Some(false) { + return; + } + let resolved = pf_host_config::layout::system_layout(); + let Some(layout) = resolved.names.layout.as_deref() else { + return; + }; + let targets = xwayland_cursor_targets(); + if targets.is_empty() { + return; + } + let non_empty = |v: &Option| v.as_deref().filter(|s| !s.is_empty()).map(str::to_owned); + for (dpy, xauth) in targets { + let mut cmd = Command::new("setxkbmap"); + cmd.args(["-display", &dpy, "-layout", layout]); + if let Some(v) = non_empty(&resolved.names.variant) { + cmd.args(["-variant", &v]); + } + if let Some(m) = non_empty(&resolved.names.model) { + cmd.args(["-model", &m]); + } + // Only when configured: `-option ""` is setxkbmap's CLEAR, not its no-op. + if let Some(o) = non_empty(&resolved.names.options) { + cmd.args(["-option", &o]); + } + if let Some(xa) = &xauth { + cmd.env("XAUTHORITY", xa); + } + match crate::proc::status_within(&mut cmd, LAYOUT_SYNC_BUDGET) { + Ok(st) if st.success() => tracing::info!( + display = %dpy, + layout = %resolved.names.describe(), + source = %resolved.source, + "gamescope: aligned the session's keyboard layout with the box" + ), + Ok(st) => tracing::warn!( + display = %dpy, + status = ?st.code(), + "gamescope: setxkbmap rejected the box's layout — the session keeps its own" + ), + // Overwhelmingly "setxkbmap is not installed" (it ships in xorg-x11-xkb-utils / + // x11-xkb-utils). Not fatal: only a non-US keyboard notices, and it is exactly the + // case the +pfhdr8 gamescope handles without any of this. + Err(e) => tracing::warn!( + display = %dpy, + error = %e, + layout = %resolved.names.describe(), + "gamescope: could not set the session's keyboard layout (is setxkbmap installed?)" + ), + } + } } /// Mirror the physical head this gamescope session is driving (`vdisplay::mirror`'s gamescope arm). @@ -4553,6 +4636,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul for arg in wsi.setenv_args() { cmd.arg(arg); } + for arg in xkb_setenv_args() { + cmd.arg(arg); + } // Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the // user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY // that would abort gamescope at startup. @@ -4839,6 +4925,68 @@ fn cursor_args() -> Vec { args } +/// The box's configured keyboard layout as `XKB_DEFAULT_*`, for a gamescope session we launch. +/// +/// gamescope builds its xkb keymap from exactly these five variables and nothing else — it never +/// looks at `/etc/X11/xorg.conf.d/00-keyboard.conf`, which is where `localectl set-x11-keymap` +/// records the choice, because that file belongs to Xorg. Nothing in a systemd user session +/// exports them either, so a session launched without this runs libxkbcommon's built-in default: +/// evdev/pc105/**us**. +/// +/// That matters because punktfunk's key wire is US-POSITIONAL — a client sends the *physical* key +/// and the session's keymap decides the character — so a US session renders a German keyboard's +/// ISO keys as their US neighbours (`#`→`\`, `ä`→`'`, `-`→`/`). +/// +/// ⚠ Empty on a box that configured nothing, so an unconfigured session keeps behaving exactly as +/// it does today rather than being pinned to an invented layout. +/// +/// ⚠ This is necessary but not, on its own, sufficient: gamescope only publishes the keymap to its +/// clients once a keyboard is actually bound to the seat, which on a HEADLESS session (no libinput +/// devices) needs the `punktfunk-gamescope` patch that gives the seat's stub keyboard the compiled +/// keymap. Against a stock gamescope these variables are read and then never reach Xwayland. +fn xkb_env() -> Vec<(&'static str, String)> { + let resolved = pf_host_config::layout::system_layout(); + let pairs = resolved.names.env_pairs(); + if pairs.is_empty() { + return pairs; + } + // Passed either way — it costs nothing against an older binary and starts working the moment + // the box updates — but say so, because "I set the layout and the keys are still US" is + // otherwise unexplainable from the outside. + if gamescope_honours_xkb_env() { + tracing::info!( + layout = %resolved.names.describe(), + source = %resolved.source, + "gamescope session: handing it the box's keyboard layout" + ); + } else { + tracing::warn!( + layout = %resolved.names.describe(), + source = %resolved.source, + "gamescope session: this build ignores XKB_DEFAULT_* (needs punktfunk-gamescope \ + +pfhdr8) — the session will type US characters whatever the box is configured for" + ); + } + pairs +} + +/// [`xkb_env`] as `systemd-run --setenv=` arguments — mirrors [`WsiPlan::setenv_args`]. +fn xkb_setenv_args() -> Vec { + xkb_env() + .into_iter() + .map(|(name, value)| format!("--setenv={name}={value}")) + .collect() +} + +/// [`xkb_env`] as unit-file lines for a drop-in. Trailing newline included, so whatever the body +/// puts after it still parses — same contract as [`WsiPlan::unit_lines`]. +fn xkb_unit_lines() -> String { + xkb_env() + .into_iter() + .map(|(name, value)| format!("Environment={name}={value}\n")) + .collect() +} + /// `--custom-refresh-rates ` when the resolved gamescope has it (patch level 3+): the rates a /// HEADLESS session may offer its clients. /// @@ -4950,6 +5098,8 @@ fn spawn( cmd.args(app.split_whitespace()) // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") + // The box's keyboard layout — see [`xkb_env`]. Empty on an unconfigured box. + .envs(xkb_env()) // A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after // a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale // WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session) diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs index 0222fae7..6fd31476 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs @@ -507,6 +507,19 @@ pub(crate) fn gamescope_can_composite_cursor() -> bool { gamescope_patch_level() >= 2 && !flags_lost() } +/// Does the resolved gamescope actually PUBLISH the keymap it compiles from `XKB_DEFAULT_*`? +/// +/// Below this level it reads the five variables, builds the keymap, and then hands its clients +/// nothing: the seat carries a keymap-less stub keyboard that `wlserver_keyboardfocus()` re-binds +/// on every focus change, so Xwayland and every Wayland client keep their own built-in `us`. A +/// headless session has no libinput devices to ever put the real keymap on the seat, so it never +/// recovers. The keys punktfunk injects are US-POSITIONAL and the session's keymap is what turns +/// them into characters — so on a stock gamescope a German keyboard types its US neighbours +/// (`#`→`\`, `ä`→`'`, `-`→`/`) no matter how the box is configured. +pub(crate) fn gamescope_honours_xkb_env() -> bool { + gamescope_patch_level() >= 8 && !flags_lost() +} + /// Does the resolved gamescope let us hand a headless session the list of refresh rates it may /// offer (`--custom-refresh-rates`)? /// diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 9c5fa696..72c12211 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -304,6 +304,36 @@ were holding down is released on the host, so nothing sticks. The rest of the in switch mouse mode, disconnect, fullscreen — are in [Getting your input back](/docs/input#getting-your-input-back). +## My keyboard types the wrong characters (`#` comes out as `\`) + +A German keyboard giving `\` for `#`, `'` for `ä` and `/` for `-`, or `z` and `y` swapped, is a +**host** layout mismatch — the client is fine. + +Punktfunk sends the *physical key you pressed*, not the character, exactly as a keyboard plugged +into the host would. What that key finally types is decided by the layout the **host session** is +running, so the host has to be set to the same layout as the keyboard you're typing on. When it +isn't, every key whose position differs between the two layouts comes out as its neighbour. + +On Linux, set the layout the normal way and reconnect: + +```sh +sudo localectl set-x11-keymap de pc105 nodeadkeys # your layout, model, variant +``` + +Punktfunk reads that setting and hands it to the session on the next connect. Two things are worth +knowing: + +- **Wayland desktops don't read it by themselves.** `localectl` writes a file only Xorg opens, so + before this release a correctly-configured box could still run a US session. If your compositor + is already set to the right layout in its own settings, nothing changes. +- **Game Mode needs a current `punktfunk-gamescope`.** Gamescope publishes no keyboard layout at + all to the apps it runs, so Steam and games saw US whatever the box was set to. Our build fixes + that from `+pfhdr8` on — check with `punktfunk-gamescope --version`, and + [update](/docs/updating) if it's older. + +Nothing here changes which physical key does what in a game: `WASD` stays under the same fingers on +every layout. + ## A controller is detected but games don't see it - **Linux.** The host user needs to be in the `input` group. On Bazzite: diff --git a/packaging/gamescope/README.md b/packaging/gamescope/README.md index fbc18796..b23212f3 100644 --- a/packaging/gamescope/README.md +++ b/packaging/gamescope/README.md @@ -20,6 +20,7 @@ The patches here add the missing half, and nothing else. See | `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle | | `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise | | `0009-pipewire-destroy-capture-textures-on-the-compositor-.patch` | Move capture-buffer destruction off the PipeWire thread: `remove_buffer`/stale-push queue the corpse (`bury_buffer`), steamcompmgr reaps on every vblank — including while the stream is paused, which is exactly the linger window. Without it, dropping the last `CVulkanTexture` ref on the PW thread races `vulkan_screenshot` on the same device and SIGSEGVs (NVIDIA `insertBarrier`), so a lingered display is dead and reconnect loses the session. Reported + written by luxus (punktfunk-overlay#9) | **Yes** — the race is upstream's `paint_pipewire` vs `destroy_buffer`; our patches only make the paint path heavier | +| `0010-wlserver-give-the-seat-s-stub-keyboard-the-compiled-.patch` | Set the compiled keymap on `wlserver.wlr.virtual_keyboard_device` too. gamescope builds a keymap from `XKB_DEFAULT_*` but only puts it on `keyboard_group`, while the SEAT carries the keymap-less stub that `wlserver_keyboardfocus()` re-binds on every focus change — so clients get no keymap and fall back to their own `us`, and a headless session (no libinput devices) never recovers | **Yes** — the stub's own comment says it exists "only to set the keymap"; it just never did | ### Why the headless patch matters @@ -103,6 +104,7 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap | `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) | | `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) | | `+pfhdr7` | …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability) | +| `+pfhdr8` | …and the seat's keyboard carries the `XKB_DEFAULT_*` keymap, so the session follows the box's configured layout | Bump it whenever a patch adds or changes something the host must know about before it spawns. diff --git a/packaging/gamescope/patches/0010-wlserver-give-the-seat-s-stub-keyboard-the-compiled-.patch b/packaging/gamescope/patches/0010-wlserver-give-the-seat-s-stub-keyboard-the-compiled-.patch new file mode 100644 index 00000000..4d3cb95a --- /dev/null +++ b/packaging/gamescope/patches/0010-wlserver-give-the-seat-s-stub-keyboard-the-compiled-.patch @@ -0,0 +1,67 @@ +From d958d5451d0df3678cdccbb5d7306c15b504c877 Mon Sep 17 00:00:00 2001 +From: enricobuehler +Date: Sat, 15 Aug 2026 20:15:12 +0200 +Subject: [PATCH] wlserver: give the seat's stub keyboard the compiled keymap +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +gamescope reads XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS and compiles a keymap from them, +but that keymap only ever lands on wlserver.keyboard_group. The seat carries a DIFFERENT object: +wlserver_keyboardfocus() calls wlr_seat_set_keyboard(seat, wlserver.wlr.virtual_keyboard_device) +on every focus change, and that stub — created a few lines above with wlr_keyboard_init(kbd, +nullptr, "virtual") and the comment "only used to set the keymap" — never has one set. + +With a NULL keymap wlroots advertises none, so every client falls back to its own compiled-in +default and gamescope's session is us/pc105 whatever XKB_DEFAULT_LAYOUT says. The group does +reach the seat, but only from wlserver_handle_key/_modifiers, i.e. from a real libinput key +event — and the next focus change swaps the stub back in anyway. A HEADLESS session (--backend +headless, no libinput devices at all) therefore never gets a keymap onto the seat at all. + +Observed on a Bazzite gaming-mode host configured de/nodeadkeys via localectl: both gamescope +Xwayland servers report evdev/pc105/us, and every ISO key types its US neighbour (# -> backslash, +adiaeresis -> apostrophe, minus -> slash). Injected input (libei/EIS) hits exactly the same path, +which is how a remote client with a German keyboard ends up typing US characters. + +Set the keymap on the stub too. +--- + src/meson.build | 3 ++- + src/wlserver.cpp | 8 ++++++++ + 2 files changed, 10 insertions(+), 1 deletion(-) + +diff --git a/src/meson.build b/src/meson.build +index fe854af..91bba6b 100644 +--- a/src/meson.build ++++ b/src/meson.build +@@ -187,7 +187,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip() + # +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability) + # +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability) + # +pfhdr7 — …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability) +-version_tag = vcs_tag + '+pfhdr7' + ' (' + compiler_name + ' ' + compiler_version + ')' ++# +pfhdr8 — …and the seat's keyboard carries the XKB_DEFAULT_* keymap (honours the box layout) ++version_tag = vcs_tag + '+pfhdr8' + ' (' + compiler_name + ' ' + compiler_version + ')' + + gamescope_version_conf = configuration_data() + gamescope_version_conf.set('VCS_TAG', version_tag) +diff --git a/src/wlserver.cpp b/src/wlserver.cpp +index 92f2807..3354da7 100644 +--- a/src/wlserver.cpp ++++ b/src/wlserver.cpp +@@ -2042,6 +2042,14 @@ bool wlserver_init( void ) { + struct wlr_keyboard *keyboard = &wlserver.keyboard_group->keyboard; + wlr_keyboard_set_repeat_info(keyboard, 25, 600); + wlr_keyboard_set_keymap(keyboard, keymap); ++ // The seat carries the STUB keyboard, not the group: wlserver_keyboardfocus() binds ++ // wlserver.wlr.virtual_keyboard_device on every focus change, and the group only reaches the ++ // seat from wlserver_handle_key/_modifiers — i.e. from a real libinput key event. Left with a ++ // NULL keymap the stub makes wlroots advertise no keymap at all, so every client (Xwayland ++ // included) keeps its own built-in "us" and XKB_DEFAULT_* is silently ignored. A headless ++ // session has no libinput devices, so it never recovers: the very next focus change puts the ++ // keymap-less stub back. Give the stub the keymap its own comment above says it exists for. ++ wlr_keyboard_set_keymap(wlserver.wlr.virtual_keyboard_device, keymap); + wlserver.keyboard_group_modifiers.notify = wlserver_handle_modifiers; + wl_signal_add(&keyboard->events.modifiers, &wlserver.keyboard_group_modifiers); + wlserver.keyboard_group_key.notify = wlserver_handle_key; +-- +2.50.1 (Apple Git-155) +