fix(input): a German keyboard typed US characters — nothing carried the box's layout into the session
ci / bun-nix (pull_request) Successful in 21s
ci / web (pull_request) Successful in 1m5s
ci / docs-site (pull_request) Successful in 9m7s
ci / rust-arm64 (pull_request) Successful in 9m47s
android / android (pull_request) Successful in 15m41s
ci / rust (pull_request) Successful in 16m49s
ci / bun-nix (pull_request) Successful in 21s
ci / web (pull_request) Successful in 1m5s
ci / docs-site (pull_request) Successful in 9m7s
ci / rust-arm64 (pull_request) Successful in 9m47s
android / android (pull_request) Successful in 15m41s
ci / rust (pull_request) Successful in 16m49s
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.
This commit is contained in:
@@ -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<String>,
|
||||
pub model: Option<String>,
|
||||
pub layout: Option<String>,
|
||||
pub variant: Option<String>,
|
||||
pub options: Option<String>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
(
|
||||
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<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<PathBuf> = 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)");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String>| 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<String> {
|
||||
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<String> {
|
||||
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 <list>` 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)
|
||||
|
||||
@@ -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`)?
|
||||
///
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
From d958d5451d0df3678cdccbb5d7306c15b504c877 Mon Sep 17 00:00:00 2001
|
||||
From: enricobuehler <enrico.buehler@unom.io>
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user