Fix/wayland input keyboard layout (#16462)

* fix(linux): respect Wayland keyboard layouts for uinput

Resolve printable keys from GNOME input sources or the Wayland keymap and KDE active layout group. Refresh layout metadata on input at most once per second, and preserve the keycode and owned modifiers until release.

Preserve the original Legacy character on client key release and handle Caps Lock shortcuts without introducing an unwanted Shift modifier.

Validated with GNOME/KDE full-service input and disconnect checks, plus Windows/Linux native keyboard capture and release checks.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): harden Wayland keyboard layout detection

Keep the last usable map on transient discovery failures and preserve the
legacy character path when no map is available, with explicit warnings.
Retain each injected key representation through release and handle IBus
default layout and variant metadata without compiling a layout named default.

Use native GNOME and Plasma sources, including KWin sessions without the
optional layout service. Read the Xwayland keymap and effective group from
the same keyboard on other desktops. Load XCB/XKB libraries dynamically and
accept the unix/:N local display spelling.

Validation: 24/24 temporary Linux production-module and IPC checks passed,
including an actual read-only Xwayland query. The two latest routing/display
regressions failed before their fixes. Rustfmt and git diff --check passed.
No Cargo/Flutter commands or full remote application tests were rerun.

Xwayland state freshness and synchronous desktop I/O remain known limits.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): bound input waits for layout refresh

Run throttled Wayland layout discovery in a single background worker and give the triggering input a 25 ms wait budget. Later input uses the existing map without waiting or queuing more queries. Preserve source error handling and skip known Legacy clipboard-only text.

Document Xwayland freshness and per-character modifier ownership limits. Desktop calls remain uncancelled; the budget bounds input waiting, subject to OS scheduling.

Validation: Linux module compilation with cached dependencies, 23 existing temporary module/IPC checks, and stalled-XCB timing probes passed. Rechecked cache/error/release cases after the final lock-scope change. No permanent regression tests or Cargo/Flutter commands.
Signed-off-by: fufesou <linlong1266@gmail.com>

* docs(linux): record layout query latency measurements

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): reply to failed uinput key state queries

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): prefer non-keypad character mappings

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): preserve text after unmappable uinput characters

Continue processing a text sequence when one character cannot be resolved
in the host layout. Return the first mapping error after the supported
characters are injected, so the existing throttled log still reports failure.

Keep modifier-query, injection and release errors fail-fast. This fixes
German a^bc losing bc without adding clipboard or fixed-US fallback.
The unsupported caret remains an explicit mapping error.

Validation: three temporary Linux checks (nine inputs) passed using the
production resolver and sequence methods, real framed IPC and native XKB
decoding. The suffix check failed before the fix. Formatting and diff checks
passed; no permanent tests, Cargo/Flutter commands or full app build added.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): preserve Latin shortcuts on non-Latin layouts

Detect Ctrl/Alt/Meta independently of CapsLock. If the active XKB map lacks
an ASCII shortcut letter, reuse the existing physical letter mapping while
preserving explicit Shift and the press-time key for release. Existing
layout mappings and unsupported ordinary text retain their behavior.

Honor disable-uinput-layout-fallback and log this compatibility path with
the existing throttle.

Validation: 12 temporary Linux production-module/IPC checks passed,
including 43 non-Latin shortcut/layout/lock/Shift cases. The new checks
failed before the fix. Formatting and git diff --check passed. No real
mobile/compositor session or Cargo/Flutter build was run.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): preserve missing punctuation shortcuts

Allow missing ASCII graphic shortcuts to reuse the existing physical key
table when Ctrl, Alt or Meta is active. This restores Ctrl+[ and Ctrl+]
with a valid Russian layout instead of discarding their character events.

Retain the table's Shift requirement for punctuation and use existing
modifier ownership and press-time release tracking. Uppercase letters do
not synthesize Shift. Active-layout mappings, ordinary text errors and
disable-uinput-layout-fallback retain their behavior.

Validation: 14 temporary Linux production-module/IPC checks passed after
recompiling the final source with rustc and cached dependencies. Includes
19 punctuation/lock/Shift combinations and 43 existing Latin-shortcut
cases. The two new checks failed before the fix. Formatting and
git diff --check passed. No Cargo/Flutter build or real mobile/native
Wayland session was run.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): respect held Shift when resolving shortcuts

Derive Shift-held candidates from the same XKB keymap and query the
caller's Shift state for character shortcuts. On US layouts, Ctrl+Shift+<
now uses the comma key instead of the extra ISO key that produces >.

Keep caller-held Shift out of synthesized modifier ownership. Preserve
ordinary input and letter-shortcut identity, and do not replace an
ordinary shortcut with a keypad alias to satisfy the held-Shift lookup.

Validation: 16 temporary Linux production-module/IPC checks passed with
native XKB event replay and cached dependencies via rustc. Includes
30 US text/shortcut cases and 40 UK/DE/FR character/lock cases. The new
Ctrl+Shift+< check failed before the fix. Changed sections pass formatting
and git diff --check. No Cargo/Flutter build or real mobile/native Wayland
session was run; device-state and packet-modifier handling use fixtures.

Signed-off-by: fufesou <linlong1266@gmail.com>

* docs(android): document paired-punctuation input limitation

Signed-off-by: fufesou <linlong1266@gmail.com>

* docs(linux): explain Xwayland layout synchronization limits

Document the focus-scoped modifier updates observed on GNOME and KWin. Core and physical X11 keyboards can retain an old layout group while native Wayland windows have focus; reconnecting or waiting cannot request the missing update.

Validation: 16 existing module/IPC probes and the live GNOME mapping probe passed. Live KDE queries reproduced native French versus stale Xwayland US. Formatting and diff checks passed. This is a comment-only change; strict Xwayland-only behavior is preserved.
Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): query native Sway and Hyprland keyboard layouts

Read the uinput keyboard's active layout through compositor IPC and
validate its group against the native Wayland keymap. Match the IPC
server to the Wayland session, and accept numbered Hyprland device
names while rejecting ambiguous keyboard selections.

Validation: 21 temporary production-module and framed-IPC checks passed.
Live Sway US/FR group and a/q mapping checks passed with Xwayland disabled;
the GNOME French layout probe also passed. Formatting checks passed.
A live Hyprland session and a full application build were not tested.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): defer unverified native compositor layout queries

Remove the Sway/Hyprland adapter that paired a uinput device's layout
group with a seat keymap based only on matching layout names. Different
device options can make a synthesized AltGr key toggle CapsLock instead.

Restore the existing best-effort Xwayland source for other desktops and
document the device-keymap information missing from the examined native
queries. GNOME/KDE discovery and injection/cache behavior are unchanged.

Validation: 16 temporary production-module/IPC checks passed after the
removal, plus read-only GNOME French and Xwayland mapping checks. Rust
formatting and diff checks passed. No full application build or mobile
end-to-end test was run for this follow-up.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): reject unreliable GNOME per-window layout sources

GNOME restores per-window layouts without persisting MRU. Reject MRU
selection when per-window input sources are enabled with multiple sources,
and invalidate the cached map and source identity for this typed error.
Transient discovery failures still retain the last valid mapping.

Reuse the existing logged/disableable compatibility policy. This contains
stale-map acceptance; it does not add effective per-window layout discovery.
Single-source and global-source GNOME configurations retain their path.

Validation: the old code failed the temporary stale-cache regression.
All 3 focused production-module checks pass after the fix, covering cache
invalidation/recovery, compatibility modes, single/default source mapping,
UK punctuation, and last-good retention after an IBus connection failure.
Compiled with rustc and cached Linux dependencies; formatting/diff checks
passed. No permanent tests, Cargo/Flutter build or mobile session added.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): reject inactive IBus layout sources

Read the current GlobalEngine description instead of looking up the
persisted MRU engine by name. GNOME can suppress IBus for password entry
without updating MRU, leaving the old engine's layout valid but inactive.

Report an engine-ID mismatch as UnreliableSource so the existing cache
invalidation path drops that mapping. Keep transient query-error handling
and layout/variant normalization. Do not infer a US compositor layout from
GNOME's xkb:us::eng echo engine.

Validation: the old code failed the inactive-engine cache and default-layout
checks. All 3 temporary production-module/D-Bus checks pass with this fix,
covering invalidation/recovery, compatibility policies, metadata sentinels,
query failures and ordinary French XKB selection. Compiled with rustc and
cached Linux dependencies; formatting/diff checks pass. No permanent tests,
Cargo/Flutter build or live password/mobile session added.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): prewarm uinput layout discovery at startup

Start the existing layout worker when the uinput keyboard client connects,
before the server accepts remote input. Reuse its throttle and single-worker
logic; keep the existing 25 ms input wait and background completion policy.
Prewarming reduces cold-start fallback without introducing input queues or
promising readiness while a desktop query remains outstanding.

Verified with three temporary Linux production-module checks using cached
dependencies: a 125 ms query resolves UK @ before first input, a 200 ms
outstanding query stays nonblocking and publishes its map, and a failed
startup query retries successfully. All three fail their startup assertions
before the change. No permanent tests or Cargo/Flutter builds were added.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): preserve first input wait during layout prewarming

Keep the startup query's completion receiver until the first relevant input
takes it. That input can wait for the already-running query within the
existing 25 ms deadline, without holding the receiver handoff lock. Later
input remains nonblocking and the existing single-worker guard is unchanged.
Prefer a due refresh to an old completed startup notification.

Verified with four temporary Linux production-module checks compiled with
rustc -C opt-level=3 and cached dependencies: joining a pending UK query,
one bounded wait followed by nonblocking input, due refresh after prewarm,
and recovery after query failure. Both lost-wait checks fail before the fix.
Formatting and git diff --check pass. No permanent tests or full build added.

An unoptimized delayed-query probe still exceeded 25 ms and used fallback;
this restores the wait opportunity, not an initial-readiness guarantee.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(uinput): remove unnecessary change

Signed-off-by: fufesou <linlong1266@gmail.com>

* refactor(linux): simplify uinput character releases

Release the mapping stored for the same character. Linux character input already pairs down/up events, so opposite-case matching is unnecessary.

Validation: git diff --check passed. Static comparisons confirmed that Map-mode dispatch, raw-key injection and keycode offsets match the PR base. No Cargo/Flutter commands or live-session tests were run.
Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): normalize Unicode CapsLock shortcuts

Normalize single-scalar Unicode lowercase for CapsLock shortcuts without adding Shift. For lowercase expansions, resolve the original symbol with the actual CapsLock state instead of truncating it. Document the existing first-seat discovery limitation.

Validation: two temporary Rust context checks against lookup fixtures failed before the fix and passed afterward. No Cargo/Flutter build or end-to-end input test was run.
Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): derive CapsLock shortcut keys from XKB

Keep the actual CapsLock state when resolving shortcut characters. Use the existing Caps-off and Caps+Shift mappings to omit generated Shift only when XKB confirms the same physical key and remaining modifiers, preserving the Turkish I/ı and İ/i key identities.

Validation: three temporary production-module checks passed on 192.168.5.32 with native libxkbcommon; two failed before this fix. Covered Turkish, German, Cyrillic and ASCII shortcuts, explicit Shift, ordinary text and UK punctuation. No full application build, IPC or end-to-end input test was run.
Signed-off-by: fufesou <linlong1266@gmail.com>

* refactor(linux): remove CapsLock shortcut special cases

Use the existing lock-state synchronization and ordinary XKB mappings for mobile soft-keyboard input. Remove CapsLock-specific shortcut remapping and compatibility-path lowercasing while retaining held-Shift punctuation handling.

Validation: rebuilt the Linux Rust library and reused the unchanged Flutter UI. Actual Android Gboard input matched UK punctuation on GNOME and KDE; French/German switching on KDE passed after the refresh interval. The user confirmed real iPad input on both hosts, including smart quotes. Formatting and whitespace checks passed. The optional Ctrl/Shift toolbar automation was inconclusive and is not counted as passing coverage.
Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): press Level3 before Shift for uinput characters

Preserve mapping candidate preference while storing generated modifiers in Level3-before-Shift order. This avoids activating Compose with lv3:ralt_switch_multikey; releases retain their existing reverse order.

Verified on Linux with temporary production-mapper/libxkbcommon tests: Lithuanian ! fails before this change and passes afterward; all 24 UK punctuation/lock-state cases pass with released modifiers and unchanged lock state. Formatting and diff checks pass. No full application build or mobile end-to-end test was run for this change.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): preserve legacy uinput IPC without a keymap

Send sequences and character clicks through their original IPC messages when the layout cache is unavailable, avoiding unnecessary synchronous key-state queries. Keep throttled fallback warnings and leave layout discovery, retry policy, and key-down/up pairing unchanged.

Verified with three temporary production-method checks on Linux using a recording transport and injected query errors. The unavailable-map routing check fails before the fix and passes afterward; raw-click routing and available-map query-error handling remain unchanged. Changed-code formatting and diff checks pass. No full application build or end-to-end input test was run.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): skip layout queries for legacy uinput fallback

When no keymap is available, single-character input must use the legacy
KeyDown/KeyUp path without querying lock or modifier state first. Retain
the legacy key in the existing press record so discovery completing
before a repeat or release cannot change the selected representation.

Validated with four temporary production-method checks on Linux using a
controlled cache/resolver and recording transport: missing-map fallback
with query failures, press/release across cache transitions, unchanged
raw-key dispatch, and Ctrl+C without extra Shift or queries. The initial
fallback check failed before this change. Valid-map errors still surface.

Changed-code formatting and git diff --check pass. No full Cargo/Flutter
build or live service/mobile test was run for this change.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-10-09 11:17:04 +08:00
committed by GitHub
parent 9b9e5f113a
commit 3f4bfe535e
12 changed files with 1349 additions and 10 deletions
Generated
+2
View File
@@ -7175,6 +7175,7 @@ dependencies = [
"uuid",
"virtual_display",
"wallpaper",
"wayland-client",
"winapi 0.3.9",
"windows 0.61.1",
"windows-service",
@@ -7184,6 +7185,7 @@ dependencies = [
"wol-rs",
"x11-clipboard 0.8.1",
"x11rb 0.12.0",
"xkbcommon-dl",
"xxhash-rust",
"zstd",
]
+2
View File
@@ -202,6 +202,8 @@ gtk = "0.18"
termios = "0.3"
terminfo = "0.8"
winit = "0.30"
wayland-client = "0.31"
xkbcommon-dl = { version = "0.4", features = ["x11"] }
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
@@ -335,6 +335,10 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
// clipboard
oldValue = '';
}
// This length-only diff assumes the IME appends at the end. If an IME
// leaves the caret inside paired punctuation, later insertions can replay
// the closing symbol: "()" -> "(a)" sends ")" instead of "a". This is a
// client text-diff issue, independent of the host keyboard layout.
if (newValue.length == oldValue.length) {
// ?
} else if (newValue.length < oldValue.length) {
+2
View File
@@ -267,6 +267,8 @@ pub enum DataKeyboard {
#[serde(tag = "t", content = "c")]
pub enum DataKeyboardResponse {
GetKeyState(bool),
#[cfg(target_os = "linux")]
GetKeyStateError(String),
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
+3
View File
@@ -2489,6 +2489,9 @@ pub fn handle_key_(evt: &KeyEvent) {
return;
}
#[cfg(target_os = "linux")]
super::uinput::prepare_layout(evt);
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let mut _lock_mode_handler = None;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
+213 -10
View File
@@ -10,6 +10,12 @@ use hbb_common::{
ResultType,
};
mod layout;
pub(super) fn prepare_layout(event: &base::message_proto::KeyEvent) {
layout::prepare_layout(event);
}
static IPC_CONN_TIMEOUT: u64 = 1000;
static IPC_REQUEST_TIMEOUT: u64 = 1000;
static IPC_POSTFIX_KEYBOARD: &str = "_uinput_keyboard";
@@ -17,24 +23,182 @@ static IPC_POSTFIX_MOUSE: &str = "_uinput_mouse";
static IPC_POSTFIX_CONTROL: &str = "_uinput_control";
pub mod client {
use super::*;
use super::{layout::LayoutKey, *};
use std::{collections::HashMap, time::Duration};
const LAYOUT_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(5);
struct LayoutContext {
locks: (bool, bool),
// None for ordinary input; otherwise whether the shortcut holds Shift.
shortcut_shift: Option<bool>,
}
impl LayoutContext {
fn resolve(&self, chr: char) -> ResultType<LayoutKey> {
layout::resolve(chr, self.locks, self.shortcut_shift)
}
}
pub struct UInputKeyboard {
conn: Connection,
rt: Runtime,
// Ownership is per character. The Linux connection turns character
// downs into clicks; overlapping direct-backend holds can release a
// generated modifier still needed by another character.
layout_keys: HashMap<char, LayoutKey>,
}
impl UInputKeyboard {
pub async fn new() -> ResultType<Self> {
let conn = ipc::connect(IPC_CONN_TIMEOUT, IPC_POSTFIX_KEYBOARD).await?;
let rt = Runtime::new()?;
Ok(Self { conn, rt })
layout::prewarm_layout();
Ok(Self {
conn,
rt,
layout_keys: HashMap::new(),
})
}
fn send(&mut self, data: Data) -> ResultType<()> {
self.rt.block_on(self.conn.send(&data))
}
fn send_raw_key(&mut self, keycode: u16, down: bool) -> ResultType<()> {
let key = Key::Raw(keycode);
let event = if down {
DataKeyboard::KeyDown(key)
} else {
DataKeyboard::KeyUp(key)
};
self.send(Data::Keyboard(event))
}
fn press_layout_key(&mut self, chr: char, mapping: LayoutKey) -> ResultType<()> {
if let Some(pressed) = self.layout_keys.get(&chr).cloned() {
return self.send_layout_key_down(&pressed);
}
let mut pressed = LayoutKey {
key: mapping.key,
modifiers: Vec::new(),
};
for keycode in mapping.modifiers {
let query = Data::Keyboard(DataKeyboard::GetKeyState(Key::Raw(keycode)));
if !self.send_get_key_state(query)? {
pressed.modifiers.push(keycode);
}
}
let result = self.send_layout_key_down(&pressed);
if let Err(error) = result {
if let Err(release_error) = self.release_layout_key(&pressed) {
bail!(
"{}; failed to release uinput modifiers: {}",
error,
release_error
);
}
return Err(error);
}
self.layout_keys.insert(chr, pressed);
Ok(())
}
fn send_layout_key_down(&mut self, pressed: &LayoutKey) -> ResultType<()> {
// Character dispatch can release Shift before a repeated key-down.
for keycode in &pressed.modifiers {
self.send_raw_key(*keycode, true)?;
}
self.send(Data::Keyboard(DataKeyboard::KeyDown(pressed.key)))
}
fn release_layout_key(&mut self, pressed: &LayoutKey) -> ResultType<()> {
let mut result = self.send(Data::Keyboard(DataKeyboard::KeyUp(pressed.key)));
for keycode in pressed.modifiers.iter().rev() {
// Attempt every release even if an earlier send failed.
result = result.and(self.send_raw_key(*keycode, false));
}
result
}
fn layout_key_down(&mut self, chr: char) -> ResultType<()> {
if let Some(pressed) = self.layout_keys.get(&chr).cloned() {
return self.send_layout_key_down(&pressed);
}
let mapping = if layout::is_available() {
self.layout_context()?.resolve(chr)?
} else {
// Discovery may finish before key-up; retain the legacy key for release.
LayoutKey {
key: Key::Layout(chr),
modifiers: Vec::new(),
}
};
self.press_layout_key(chr, mapping)
}
fn layout_key_up(&mut self, chr: char) -> ResultType<()> {
if let Some(pressed) = self.layout_keys.remove(&chr) {
self.release_layout_key(&pressed)?;
}
Ok(())
}
fn layout_key_sequence(&mut self, sequence: &str) -> ResultType<()> {
if sequence.is_empty() {
return Ok(());
}
let context = self.layout_context()?;
// Keep supported text after a mapping failure and report the first error.
// Modifier and IPC failures must still stop the sequence immediately.
let mut result = Ok(());
for chr in sequence.chars() {
let mapping = match context.resolve(chr) {
Ok(mapping) => mapping,
Err(error) => {
result = result.and(Err(error));
continue;
}
};
self.press_layout_key(chr, mapping)?;
self.layout_key_up(chr)?;
}
result
}
fn layout_context(&mut self) -> ResultType<LayoutContext> {
let caps_lock =
self.send_get_key_state(Data::Keyboard(DataKeyboard::GetKeyState(Key::CapsLock)))?;
let num_lock =
self.send_get_key_state(Data::Keyboard(DataKeyboard::GetKeyState(Key::NumLock)))?;
let shortcut = self
.send_get_key_state(Data::Keyboard(DataKeyboard::GetKeyState(Key::Control)))?
|| self.send_get_key_state(Data::Keyboard(DataKeyboard::GetKeyState(Key::Alt)))?
|| self.send_get_key_state(Data::Keyboard(DataKeyboard::GetKeyState(Key::Meta)))?;
let shortcut_shift = if shortcut {
Some(
self.send_get_key_state(Data::Keyboard(DataKeyboard::GetKeyState(Key::Shift)))?,
)
} else {
None
};
Ok(LayoutContext {
locks: (caps_lock, num_lock),
shortcut_shift,
})
}
fn log_layout_error(result: &ResultType<()>) {
if let Err(error) = result {
hbb_common::throttled_log!(
LAYOUT_ERROR_LOG_INTERVAL,
error,
"Uinput layout-aware keyboard input failed: {}",
error
);
}
}
fn send_get_key_state(&mut self, data: Data) -> ResultType<bool> {
self.rt.block_on(self.conn.send(&data))?;
@@ -45,6 +209,9 @@ pub mod client {
Ok(Some(Data::KeyboardResponse(ipc::DataKeyboardResponse::GetKeyState(state)))) => {
Ok(state)
}
Ok(Some(Data::KeyboardResponse(ipc::DataKeyboardResponse::GetKeyStateError(
error,
)))) => bail!("Failed to query uinput key state: {}", error),
Ok(Some(resp)) => {
// FATAL error!!!
bail!(
@@ -90,25 +257,39 @@ pub mod client {
}
fn key_sequence(&mut self, sequence: &str) {
// Sequence events are normally handled in the --server process before reaching here.
// Forward via IPC as a fallback — input_text_wayland can still handle ASCII chars
// via keysym/uinput, though non-ASCII will be skipped (no clipboard in --service).
log::debug!(
"UInputKeyboard::key_sequence called (len={})",
sequence.len()
);
allow_err!(self.send(Data::Keyboard(DataKeyboard::Sequence(sequence.to_string()))));
if !layout::is_available() {
Self::log_layout_error(
&self.send(Data::Keyboard(DataKeyboard::Sequence(sequence.to_string()))),
);
return;
}
Self::log_layout_error(&self.layout_key_sequence(sequence));
}
// TODO: handle error???
fn key_down(&mut self, key: Key) -> enigo::ResultType {
if let Key::Layout(chr) = key {
let result = self.layout_key_down(chr);
Self::log_layout_error(&result);
return result.map_err(Into::into);
}
allow_err!(self.send(Data::Keyboard(DataKeyboard::KeyDown(key))));
Ok(())
}
fn key_up(&mut self, key: Key) {
if let Key::Layout(chr) = key {
Self::log_layout_error(&self.layout_key_up(chr));
return;
}
allow_err!(self.send(Data::Keyboard(DataKeyboard::KeyUp(key))));
}
fn key_click(&mut self, key: Key) {
if let Key::Layout(chr) = key {
if layout::is_available() {
Self::log_layout_error(&self.layout_key_sequence(&chr.to_string()));
return;
}
}
allow_err!(self.send(Data::Keyboard(DataKeyboard::KeyClick(key))));
}
}
@@ -203,6 +384,8 @@ pub mod service {
use std::os::unix::io::AsRawFd;
use std::{collections::HashMap, sync::Mutex};
const RAW_KEY_STATE_ERROR_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
lazy_static::lazy_static! {
static ref KEY_MAP: HashMap<enigo::Key, evdev::Key> = HashMap::from(
[
@@ -686,6 +869,9 @@ pub mod service {
} else {
match keyboard.get_key_state() {
Ok(keys) => match key {
enigo::Key::Raw(code) if *code >= layout::XKB_KEYCODE_OFFSET => {
keys.contains(evdev::Key::new(*code - layout::XKB_KEYCODE_OFFSET))
}
enigo::Key::Shift => {
keys.contains(evdev::Key::KEY_LEFTSHIFT)
|| keys.contains(evdev::Key::KEY_RIGHTSHIFT)
@@ -704,6 +890,23 @@ pub mod service {
}
_ => false,
},
Err(error) if matches!(key, enigo::Key::Raw(_)) => {
hbb_common::throttled_log!(
RAW_KEY_STATE_ERROR_LOG_INTERVAL,
error,
"Failed to query uinput layout modifier state: {}",
error
);
// Unknown modifier state must not be treated as released.
ipc_send_data(
stream,
&Data::KeyboardResponse(
ipc::DataKeyboardResponse::GetKeyStateError(error.to_string()),
),
)
.await;
return;
}
Err(_e) => {
// log::debug!("Failed to get key state: {}", &_e);
false
+298
View File
@@ -0,0 +1,298 @@
use hbb_common::{anyhow::anyhow, bail, ResultType};
use std::{collections::HashMap, ops::RangeInclusive, ptr::NonNull};
use xkbcommon_dl::{self as xkb, keysyms};
mod cache;
mod source;
mod wayland;
mod xwayland;
pub(super) use cache::{is_available, prepare_layout, prewarm_layout, resolve};
pub(super) const XKB_KEYCODE_OFFSET: u16 = 8;
const CAPS_LOCK_BIT: usize = 1;
const NUM_LOCK_BIT: usize = 2;
const SHIFT_BIT: usize = 4;
const LAYOUT_STATE_COUNT: usize = 8;
const DEFAULT_GROUP: u32 = 0;
#[derive(Clone)]
pub(super) struct LayoutKey {
pub key: enigo::Key,
pub modifiers: Vec<u16>,
}
struct Keymap {
maps: [HashMap<char, LayoutKey>; LAYOUT_STATE_COUNT],
}
impl Keymap {
fn load(source: &source::Source) -> ResultType<Self> {
let keymap = XkbKeymap::open(source)?;
let mut state = State::new(&keymap)?;
let caps = state.locked_mask(keysyms::Caps_Lock);
let num = state.locked_mask(keysyms::Num_Lock);
let shift = state.modifier(&[keysyms::Shift_L, keysyms::Shift_R]);
let level3 = state.modifier(&[keysyms::ISO_Level3_Shift]);
let modifiers = [shift, level3];
let maps = std::array::from_fn(|index| {
let locked = if index & CAPS_LOCK_BIT != 0 { caps } else { 0 }
| if index & NUM_LOCK_BIT != 0 { num } else { 0 };
let held_shift = if index & SHIFT_BIT != 0 { shift } else { None };
let modifiers = if held_shift.is_some() {
std::slice::from_ref(&level3)
} else {
&modifiers
};
state.mappings(locked, modifiers, held_shift)
});
Ok(Self { maps })
}
}
struct XkbKeymap {
api: &'static xkb::XkbCommon,
map: NonNull<xkb::xkb_keymap>,
group: u32,
}
impl XkbKeymap {
fn open(source: &source::Source) -> ResultType<Self> {
let api = xkb::xkbcommon_option().ok_or_else(|| anyhow!("Cannot load libxkbcommon"))?;
let mut keymap = Self {
api,
map: compile(api, source)?,
group: DEFAULT_GROUP,
};
let groups = unsafe { (api.xkb_keymap_num_layouts)(keymap.map.as_ptr()) };
if let source::Source::Wayland { group, .. } | source::Source::Xwayland { group, .. } =
source
{
if groups > 1 {
keymap.group =
group.ok_or_else(|| anyhow!("Cannot determine the active XKB group"))?;
}
}
if keymap.group >= groups {
bail!("The detected keyboard layout group is absent from the keymap");
}
Ok(keymap)
}
fn keycodes(&self) -> RangeInclusive<u32> {
unsafe {
(self.api.xkb_keymap_min_keycode)(self.map.as_ptr())
..=(self.api.xkb_keymap_max_keycode)(self.map.as_ptr())
}
}
}
impl Drop for XkbKeymap {
fn drop(&mut self) {
unsafe { (self.api.xkb_keymap_unref)(self.map.as_ptr()) };
}
}
fn compile(api: &xkb::XkbCommon, source: &source::Source) -> ResultType<NonNull<xkb::xkb_keymap>> {
let context =
unsafe { (api.xkb_context_new)(xkb::xkb_context_flags::XKB_CONTEXT_NO_ENVIRONMENT_NAMES) };
let context = NonNull::new(context).ok_or_else(|| anyhow!("Cannot create XKB context"))?;
let flags = xkb::xkb_keymap_compile_flags::XKB_KEYMAP_COMPILE_NO_FLAGS;
let map = unsafe {
match source {
source::Source::Gnome(names) => {
let names = xkb::xkb_rule_names {
rules: std::ptr::null(),
model: names.model.as_ptr(),
layout: names.layout.as_ptr(),
variant: names.variant.as_ptr(),
options: names.options.as_ptr(),
};
(api.xkb_keymap_new_from_names)(context.as_ptr(), &names, flags)
}
source::Source::Wayland { keymap: bytes, .. }
| source::Source::Xwayland { keymap: bytes, .. } => (api.xkb_keymap_new_from_string)(
context.as_ptr(),
bytes.as_ptr().cast(),
xkb::xkb_keymap_format::XKB_KEYMAP_FORMAT_TEXT_V1,
flags,
),
}
};
unsafe { (api.xkb_context_unref)(context.as_ptr()) };
NonNull::new(map).ok_or_else(|| anyhow!("Cannot compile the detected XKB keyboard map"))
}
#[derive(Clone, Copy)]
struct Modifier {
keycode: u16,
mask: u32,
}
struct State<'a> {
keymap: &'a XkbKeymap,
state: NonNull<xkb::xkb_state>,
}
impl<'a> State<'a> {
fn new(keymap: &'a XkbKeymap) -> ResultType<Self> {
let state = unsafe { (keymap.api.xkb_state_new)(keymap.map.as_ptr()) };
let state = NonNull::new(state).ok_or_else(|| anyhow!("Cannot create XKB state"))?;
let mut state = Self { keymap, state };
state.set_modifiers(0, 0);
Ok(state)
}
fn set_modifiers(&mut self, depressed: u32, locked: u32) {
unsafe {
(self.keymap.api.xkb_state_update_mask)(
self.state.as_ptr(),
depressed,
0,
locked,
0,
0,
self.keymap.group,
);
}
}
fn symbol(&self, keycode: u16) -> u32 {
unsafe {
(self.keymap.api.xkb_state_key_get_one_sym)(self.state.as_ptr(), u32::from(keycode))
}
}
fn update_key(&mut self, keycode: u16, direction: xkb::xkb_key_direction) {
unsafe {
(self.keymap.api.xkb_state_update_key)(
self.state.as_ptr(),
u32::from(keycode),
direction,
)
};
}
fn locked_mask(&mut self, symbol: u32) -> u32 {
let keycode = self
.keymap
.keycodes()
.filter_map(supported_keycode)
.find(|keycode| self.symbol(*keycode) == symbol);
let Some(keycode) = keycode else { return 0 };
self.update_key(keycode, xkb::xkb_key_direction::XKB_KEY_DOWN);
self.update_key(keycode, xkb::xkb_key_direction::XKB_KEY_UP);
let mask = unsafe {
(self.keymap.api.xkb_state_serialize_mods)(
self.state.as_ptr(),
xkb::xkb_state_component::XKB_STATE_MODS_LOCKED,
)
};
self.set_modifiers(0, 0);
mask
}
fn modifier(&mut self, symbols: &[u32]) -> Option<Modifier> {
// Prefer physical modifier keys to XKB's synthetic <LVL3> keycode.
let preferred = [
evdev::Key::KEY_LEFTSHIFT,
evdev::Key::KEY_RIGHTSHIFT,
evdev::Key::KEY_RIGHTALT,
];
let preferred = preferred
.iter()
.map(|key| u32::from(key.code() + XKB_KEYCODE_OFFSET));
for keycode in preferred
.chain(self.keymap.keycodes())
.filter_map(supported_keycode)
{
if !symbols.contains(&self.symbol(keycode)) {
continue;
}
self.update_key(keycode, xkb::xkb_key_direction::XKB_KEY_DOWN);
let mask = unsafe {
(self.keymap.api.xkb_state_serialize_mods)(
self.state.as_ptr(),
xkb::xkb_state_component::XKB_STATE_MODS_DEPRESSED,
)
};
self.update_key(keycode, xkb::xkb_key_direction::XKB_KEY_UP);
self.set_modifiers(0, 0);
if mask != 0 {
return Some(Modifier { keycode, mask });
}
}
None
}
fn mappings(
&mut self,
locked: u32,
modifiers: &[Option<Modifier>],
held_shift: Option<Modifier>,
) -> HashMap<char, LayoutKey> {
let mut mappings = HashMap::new();
let mut keypad_mappings = HashMap::new();
for combination in 0..(1usize << modifiers.len()) {
let selected: Vec<_> = modifiers
.iter()
.enumerate()
.filter(|(index, _)| combination & (1 << *index) != 0)
.filter_map(|(_, modifier)| *modifier)
.collect();
// Held Shift affects the symbol, but belongs to the caller.
let mask = selected.iter().fold(
held_shift.map_or(0, |modifier| modifier.mask),
|mask, modifier| mask | modifier.mask,
);
self.set_modifiers(mask, locked);
for keycode in self.keymap.keycodes().filter_map(supported_keycode) {
let symbol = self.symbol(keycode);
// Keypad aliases can produce the same text but different shortcuts.
let candidates = if (keysyms::KP_Space..=keysyms::KP_Equal).contains(&symbol) {
&mut keypad_mappings
} else {
&mut mappings
};
let codepoint = unsafe { (self.keymap.api.xkb_keysym_to_utf32)(symbol) };
if let Some(character) =
char::from_u32(codepoint).filter(|character| *character != '\0')
{
candidates.entry(character).or_insert_with(|| LayoutKey {
key: enigo::Key::Raw(keycode),
// Press Level3 first to avoid Shift+RightAlt activating Compose.
// Candidate preference still follows the original modifier order.
modifiers: selected
.iter()
.rev()
.map(|modifier| modifier.keycode)
.collect(),
});
}
}
}
// Shift must not redirect an ordinary shortcut to a keypad alias.
if held_shift.is_none() {
for (character, mapping) in keypad_mappings {
mappings.entry(character).or_insert(mapping);
}
}
mappings
}
}
impl Drop for State<'_> {
fn drop(&mut self) {
unsafe { (self.keymap.api.xkb_state_unref)(self.state.as_ptr()) };
}
}
fn supported_keycode(keycode: u32) -> Option<u16> {
let keycode = u16::try_from(keycode).ok()?;
let code = keycode.checked_sub(XKB_KEYCODE_OFFSET)?;
// Match the keys enabled by create_uinput_keyboard().
let supported = (evdev::Key::KEY_ESC.code()..=evdev::Key::BTN_TRIGGER_HAPPY40.code())
.contains(&code)
&& !format!("{:?}", evdev::Key::new(code)).contains("unknown");
supported.then_some(keycode)
}
+259
View File
@@ -0,0 +1,259 @@
use super::{source::Source, Keymap, LayoutKey, CAPS_LOCK_BIT, NUM_LOCK_BIT, SHIFT_BIT};
use crate::server::input_service::is_ascii_printable;
use base::message_proto::{key_event, KeyEvent, KeyboardMode};
use hbb_common::{bail, lazy_static, ResultType};
use std::{
sync::{mpsc, Mutex, RwLock, TryLockError},
thread,
time::{Duration, Instant},
};
const LAYOUT_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
// GNOME 46/Plasma 6.6 probes (200-400 initial samples): preparation p99 7-16 ms;
// repeated refresh medians were 2-6 ms. KDE query outliers exceeded 100 ms,
// so this limits input waiting while the background query continues.
const LAYOUT_INPUT_WAIT: Duration = Duration::from_millis(25);
const LAYOUT_WARNING_INTERVAL: Duration = Duration::from_secs(5);
lazy_static::lazy_static! {
static ref REFRESH: Mutex<Refresh> = Mutex::new(Refresh::default());
// Only the first relevant input takes the startup query's completion receiver.
static ref PREWARM_RECEIVER: Mutex<Option<mpsc::Receiver<()>>> = Mutex::new(None);
static ref CURRENT: RwLock<Result<Keymap, String>> =
RwLock::new(Err("Uinput keyboard layout has not been detected".to_owned()));
}
#[derive(Default)]
struct Refresh {
checked: Option<Instant>,
source: Option<Source>,
// Cover scheduling until the worker acquires REFRESH.
running: bool,
}
pub(in crate::server::uinput) fn prepare_layout(event: &KeyEvent) {
if !crate::server::input_service::wayland_use_uinput() || !needs_layout(event) {
return;
}
let deadline = Instant::now() + LAYOUT_INPUT_WAIT;
let receiver = {
let mut prewarm = PREWARM_RECEIVER.lock().unwrap();
// Prefer a due refresh over an old, already-completed startup query.
start_refresh().or(prewarm.take())
};
if let Some(receiver) = receiver {
wait_for_refresh(receiver, deadline);
}
}
pub(in crate::server::uinput) fn prewarm_layout() {
// Use server startup time for discovery before the first remote character.
// This reduces cold-start fallback; a still-pending query cannot guarantee readiness.
let mut prewarm = PREWARM_RECEIVER.lock().unwrap();
if let Some(receiver) = start_refresh() {
*prewarm = Some(receiver);
}
}
fn start_refresh() -> Option<mpsc::Receiver<()>> {
// A stalled desktop query must not block later input or spawn more workers.
let mut refresh = match REFRESH.try_lock() {
Ok(refresh) => refresh,
Err(TryLockError::WouldBlock) => return None,
Err(TryLockError::Poisoned(error)) => {
hbb_common::throttled_log!(
LAYOUT_WARNING_INTERVAL,
error,
"Uinput layout refresh lock is poisoned: {}",
error
);
return None;
}
};
if refresh.running
|| refresh
.checked
.is_some_and(|checked| checked.elapsed() < LAYOUT_REFRESH_INTERVAL)
{
return None;
}
let (completed, receiver) = mpsc::channel();
refresh.running = true;
if let Err(error) = thread::Builder::new()
.name("uinput-layout".to_owned())
.spawn(move || refresh_layout(completed))
{
refresh.running = false;
refresh.checked = Some(Instant::now());
hbb_common::throttled_log!(
LAYOUT_WARNING_INTERVAL,
error,
"Cannot start uinput layout refresh: {}",
error
);
return None;
}
drop(refresh);
Some(receiver)
}
fn refresh_layout(completed: mpsc::Sender<()>) {
let mut refresh = REFRESH.lock().unwrap();
if let Err(error) = refresh.update() {
let retained = {
let mut current = CURRENT.write().unwrap();
if current.is_err() {
*current = Err(error.to_string());
}
current.is_ok()
};
hbb_common::throttled_log!(
LAYOUT_WARNING_INTERVAL,
warn,
"Uinput layout refresh failed (previous mapping retained: {}): {}",
retained,
error
);
}
refresh.checked = Some(Instant::now());
refresh.running = false;
drop(refresh);
// An input timeout drops its receiver but leaves this worker running.
if completed.send(()).is_err() {
hbb_common::log::trace!("Uinput layout refresh finished without an input waiter");
}
}
fn wait_for_refresh(receiver: mpsc::Receiver<()>, deadline: Instant) {
match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
Ok(()) => {}
Err(mpsc::RecvTimeoutError::Timeout) => hbb_common::throttled_log!(
LAYOUT_WARNING_INTERVAL,
warn,
"Uinput layout refresh exceeded the {} ms input wait; query continues in the background",
LAYOUT_INPUT_WAIT.as_millis()
),
Err(mpsc::RecvTimeoutError::Disconnected) => hbb_common::throttled_log!(
LAYOUT_WARNING_INTERVAL,
error,
"Uinput layout refresh worker stopped before completion"
),
}
}
impl Refresh {
fn update(&mut self) -> ResultType<()> {
let source = Source::read().map_err(|error| {
if error.is::<super::source::UnreliableSource>() {
// Unlike a transient query failure, this invalidates the old map.
self.source = None;
*CURRENT.write().unwrap() = Err(error.to_string());
}
error
})?;
if self.source.as_ref() == Some(&source) {
return Ok(());
}
let keymap = match Keymap::load(&source) {
Ok(keymap) => keymap,
Err(error) => {
// A newly observed layout cannot use the previous layout's map.
self.source = None;
*CURRENT.write().unwrap() = Err(error.to_string());
return Err(error);
}
};
*CURRENT.write().unwrap() = Ok(keymap);
self.source = Some(source);
Ok(())
}
}
fn needs_layout(event: &KeyEvent) -> bool {
let mode = event.mode.enum_value_or(KeyboardMode::Legacy);
if mode == KeyboardMode::Map {
return false;
}
match &event.union {
Some(key_event::Union::Seq(sequence)) => {
// Translate shortcuts use Key::Layout even for non-ASCII text.
!sequence.is_empty()
&& (mode == KeyboardMode::Translate || sequence.chars().all(is_ascii_printable))
}
Some(key_event::Union::Chr(_)) => mode == KeyboardMode::Legacy && event.down,
Some(key_event::Union::Unicode(character)) => {
mode == KeyboardMode::Legacy
&& char::from_u32(*character).is_some_and(is_ascii_printable)
}
_ => false,
}
}
pub(in crate::server::uinput) fn is_available() -> bool {
let current = CURRENT.read().unwrap();
if let Err(error) = current.as_ref() {
hbb_common::throttled_log!(
LAYOUT_WARNING_INTERVAL,
warn,
"Uinput layout unavailable: {}; using legacy character mapping (may differ from the host layout)",
error
);
}
current.is_ok()
}
pub(in crate::server::uinput) fn resolve(
character: char,
locks: (bool, bool),
shortcut_shift: Option<bool>,
) -> ResultType<LayoutKey> {
let current = CURRENT.read().unwrap();
let keymap = match current.as_ref() {
Ok(keymap) => keymap,
Err(error) => {
hbb_common::throttled_log!(
LAYOUT_WARNING_INTERVAL,
warn,
"Uinput layout unavailable: {}; using legacy character mapping (may differ from the host layout)",
error
);
return Ok(LayoutKey {
key: enigo::Key::Layout(character),
modifiers: Vec::new(),
});
}
};
let index = usize::from(locks.0) * CAPS_LOCK_BIT + usize::from(locks.1) * NUM_LOCK_BIT;
// Letter shortcuts keep their physical key even when Shift changes case.
// Other shortcuts prefer a symbol compatible with the caller's held Shift.
let shifted = (shortcut_shift == Some(true) && !character.is_alphabetic())
.then(|| keymap.maps[index | SHIFT_BIT].get(&character))
.flatten();
if let Some(mapping) = shifted.or_else(|| keymap.maps[index].get(&character)) {
return Ok(mapping.clone());
}
if shortcut_shift.is_some() && character.is_ascii_graphic() {
return legacy_shortcut(character);
}
bail!("Character cannot be generated in the detected XKB layout")
}
fn legacy_shortcut(character: char) -> ResultType<LayoutKey> {
// Uppercase letters must not synthesize Shift for a shortcut, but shifted
// punctuation still needs the legacy table's modifier.
let (key, shift) =
super::super::service::map_key(&enigo::Key::Layout(character.to_ascii_lowercase()))?;
hbb_common::throttled_log!(
LAYOUT_WARNING_INTERVAL,
warn,
"Uinput layout lacks an ASCII shortcut character; using its legacy physical mapping"
);
Ok(LayoutKey {
key: enigo::Key::Raw(key.code() + super::XKB_KEYCODE_OFFSET),
modifiers: if shift {
vec![evdev::Key::KEY_LEFTSHIFT.code() + super::XKB_KEYCODE_OFFSET]
} else {
Vec::new()
},
})
}
+179
View File
@@ -0,0 +1,179 @@
use dbus::blocking::Connection;
use gtk::{
gio::{self, prelude::SettingsExt},
glib::variant::FromVariant,
};
use hbb_common::{anyhow::anyhow, bail, ResultType};
use std::{error::Error, ffi::CString, fmt, time::Duration};
mod ibus;
pub(super) const DBUS_TIMEOUT: Duration = Duration::from_millis(250);
const GNOME_INPUT_SCHEMA: &str = "org.gnome.desktop.input-sources";
const GNOME_DEFAULT_LAYOUT: &str = "us";
#[derive(Debug)]
pub(super) struct UnreliableSource(pub &'static str);
impl fmt::Display for UnreliableSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.0)
}
}
impl Error for UnreliableSource {}
#[derive(PartialEq, Eq)]
pub(super) enum Source {
Gnome(Names),
Wayland { keymap: Vec<u8>, group: Option<u32> },
Xwayland { keymap: Vec<u8>, group: Option<u32> },
}
#[derive(PartialEq, Eq)]
pub(super) struct Names {
pub model: CString,
pub layout: CString,
pub variant: CString,
pub options: CString,
}
impl Source {
pub fn read() -> ResultType<Self> {
let connection = Connection::new_session()?;
if name_has_owner(&connection, "org.gnome.Shell")? {
if let Some(names) = gnome_layout()? {
return Ok(Self::Gnome(names));
}
return Ok(Self::Wayland {
keymap: super::wayland::read_keymap()?,
group: None,
});
}
let kde_layouts = name_has_owner(&connection, "org.kde.keyboard")?;
// KWin 5 does not publish the layout service for a single-layout map.
if kde_layouts || name_has_owner(&connection, "org.kde.KWin")? {
return Ok(Self::Wayland {
keymap: super::wayland::read_keymap()?,
group: if kde_layouts {
Some(kde_layout_group(&connection)?)
} else {
None
},
});
}
// Other compositors need the effective keymap and active group of our
// uinput device, including per-device options and custom keymap overrides.
// wl_keyboard exposes a seat map that may belong to a physical keyboard.
// Equal layout names/group indices do not prove map identity: Caps can
// act as AltGr in one map and toggle CapsLock in the other.
//
// Sway GET_INPUTS/GET_SEATS expose names and an index, not the device map.
// GET_CONFIG returns saved config text, not effective runtime input
// settings; parsing it cannot reliably account for runtime overrides.
// Hyprland's devices reply reports RMLVO (rules/model/layout/variant/options),
// but kb_file can override it. Those fields alone cannot reconstruct the
// loaded map. This does not rule out additional compositor-specific APIs.
//
// Reliable native discovery needs a compositor query associating the
// device's actual map (or verified effective configuration) with its group.
// uinput supplies keycodes; it cannot report the compositor's XKB policy.
// Xwayland remains best effort; see xwayland::read for its freshness limits.
// zwp_virtual_keyboard_manager_v1 needs a separate injection backend and
// is not universally supported; it does not query an existing uinput map.
super::xwayland::read()
}
}
fn name_has_owner(connection: &Connection, name: &str) -> ResultType<bool> {
let proxy = connection.with_proxy(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
DBUS_TIMEOUT,
);
let (owned,): (bool,) = proxy.method_call("org.freedesktop.DBus", "NameHasOwner", (name,))?;
Ok(owned)
}
fn kde_layout_group(connection: &Connection) -> ResultType<u32> {
let proxy = connection.with_proxy("org.kde.keyboard", "/Layouts", DBUS_TIMEOUT);
let (group,): (u32,) = proxy.method_call("org.kde.KeyboardLayouts", "getLayout", ())?;
Ok(group)
}
fn gnome_layout() -> ResultType<Option<Names>> {
let schemas = gio::SettingsSchemaSource::default()
.ok_or_else(|| anyhow!("GSettings schemas are unavailable"))?;
let schema = schemas
.lookup(GNOME_INPUT_SCHEMA, true)
.ok_or_else(|| anyhow!("GNOME input source schema is unavailable"))?;
let settings = gio::Settings::new_full(&schema, gio::SettingsBackend::NONE, None);
let sources: Vec<(String, String)> = required_setting(&settings, "sources")?;
// Focus changes restore a window's source without persisting MRU. Mark it
// unreliable so refresh also discards a previously accepted MRU mapping.
if sources.len() > 1 && required_setting::<bool>(&settings, "per-window")? {
return Err(UnreliableSource(
"GNOME per-window input sources cannot be determined from persisted MRU",
)
.into());
}
let recent: Vec<(String, String)> = required_setting(&settings, "mru-sources")?;
// GNOME initially selects the first configured source when MRU is empty.
// The IBus reader verifies the active engine before accepting persisted MRU.
let selected = recent
.iter()
.find(|source| sources.contains(source))
.or(sources.first());
let Some((layout, variant)) = gnome_source_layout(selected)? else {
// IBus engines without an XKB override use the compositor's keymap.
return Ok(None);
};
if layout.is_empty() {
bail!("GNOME input source has an empty layout");
}
// Match GNOME Shell's session-wide options; it does not merge IBus
// engine layout-option metadata into the compositor keymap.
let options: Vec<String> = required_setting(&settings, "xkb-options")?;
// Older GNOME releases use the default model and have no xkb-model setting.
let model: String = if schema.has_key("xkb-model") {
required_setting(&settings, "xkb-model")?
} else {
String::new()
};
Ok(Some(Names {
model: CString::new(model)?,
layout: CString::new(layout)?,
variant: CString::new(variant)?,
options: CString::new(options.join(","))?,
}))
}
fn gnome_source_layout(
selected: Option<&(String, String)>,
) -> ResultType<Option<(String, String)>> {
let Some((kind, id)) = selected else {
// GNOME Shell selects this layout when no input source is configured.
return Ok(Some((GNOME_DEFAULT_LAYOUT.to_owned(), String::new())));
};
match kind.as_str() {
"xkb" => {
let (layout, variant) = id.split_once('+').unwrap_or((id.as_str(), ""));
Ok(Some((layout.to_owned(), variant.to_owned())))
}
"ibus" => ibus::layout(id),
_ => bail!("Unsupported GNOME input source type: {}", kind),
}
}
fn required_setting<T: FromVariant>(settings: &gio::Settings, key: &str) -> ResultType<T> {
if !settings
.settings_schema()
.is_some_and(|schema| schema.has_key(key))
{
bail!("Missing GNOME setting {}", key);
}
settings
.value(key)
.get()
.ok_or_else(|| anyhow!("Invalid GNOME setting {}", key))
}
+86
View File
@@ -0,0 +1,86 @@
use dbus::{
arg::{RefArg, Variant},
blocking::{stdintf::org_freedesktop_dbus::Properties, Connection},
channel::Channel,
};
use hbb_common::{anyhow::anyhow, bail, ResultType};
use std::{env, fs, io::ErrorKind, path::PathBuf};
const IBUS_SERVICE: &str = "org.freedesktop.IBus";
const IBUS_PATH: &str = "/org/freedesktop/IBus";
// IBusEngineDesc serialization preserves these field positions for its clients.
const ENGINE_TYPE_FIELD: usize = 0;
const ENGINE_NAME_FIELD: usize = 2;
const ENGINE_LAYOUT_FIELD: usize = 9;
const ENGINE_VARIANT_FIELD: usize = 14;
pub(super) fn layout(id: &str) -> ResultType<Option<(String, String)>> {
let connection = Connection::from(Channel::open_private(&address()?)?);
let bus = connection.with_proxy(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
super::DBUS_TIMEOUT,
);
let _: (String,) = bus.method_call("org.freedesktop.DBus", "Hello", ())?;
let proxy = connection.with_proxy(IBUS_SERVICE, IBUS_PATH, super::DBUS_TIMEOUT);
let engine: Variant<Box<dyn RefArg>> = proxy.get(IBUS_SERVICE, "GlobalEngine")?;
let fields: Vec<_> = engine
.0
.as_iter()
.ok_or_else(|| anyhow!("Invalid IBus engine description"))?
.collect();
if field(&fields, ENGINE_TYPE_FIELD)? != "IBusEngineDesc" {
bail!("IBus returned an unexpected engine description");
}
// GNOME may suppress IBus for password entry without updating persisted MRU.
// Its xkb:us::eng echo engine does not identify the compositor's layout.
if field(&fields, ENGINE_NAME_FIELD)? != id {
return Err(
super::UnreliableSource("MRU-selected IBus engine is not currently active").into(),
);
}
let layout = field(&fields, ENGINE_LAYOUT_FIELD)?;
if layout.is_empty() || layout == "default" {
return Ok(None);
}
let variant = field(&fields, ENGINE_VARIANT_FIELD)?;
let variant = if variant == "default" { "" } else { variant };
Ok(Some((layout.to_owned(), variant.to_owned())))
}
fn field<'a>(fields: &[&'a dyn RefArg], index: usize) -> ResultType<&'a str> {
fields
.get(index)
.and_then(|value| (*value).as_str())
.ok_or_else(|| anyhow!("Invalid IBus engine field {}", index))
}
fn address() -> ResultType<String> {
if let Some(address) = env::var_os("IBUS_ADDRESS") {
return address
.into_string()
.map_err(|_| anyhow!("Invalid IBus address"));
}
let settings = fs::read_to_string(address_file()?)?;
settings
.lines()
.find_map(|line| line.strip_prefix("IBUS_ADDRESS="))
.map(str::to_owned)
.ok_or_else(|| anyhow!("IBus address file contains no address"))
}
fn address_file() -> ResultType<PathBuf> {
if let Some(path) = env::var_os("IBUS_ADDRESS_FILE") {
return Ok(path.into());
}
let machine = match fs::read_to_string("/var/lib/dbus/machine-id") {
Err(error) if error.kind() == ErrorKind::NotFound => fs::read_to_string("/etc/machine-id")?,
result => result?,
};
let display = env::var("WAYLAND_DISPLAY")?;
Ok(gtk::glib::user_config_dir().join("ibus/bus").join(format!(
"{}-unix-{}",
machine.trim(),
display
)))
}
+151
View File
@@ -0,0 +1,151 @@
use hbb_common::{anyhow::anyhow, bail, libc, ResultType};
use std::{
ffi::CStr,
fs::File,
io,
os::{
fd::{AsRawFd, OwnedFd, RawFd},
unix::fs::FileExt,
},
time::{Duration, Instant},
};
use wayland_client::{
protocol::{wl_keyboard, wl_registry, wl_seat},
Connection, Dispatch, QueueHandle, WEnum,
};
const KEYMAP_TIMEOUT: Duration = Duration::from_secs(1);
const SEAT_VERSION: u32 = 7;
#[derive(Default)]
struct KeyboardState {
seat_bound: bool,
keymap: Option<ResultType<Vec<u8>>>,
}
pub(super) fn read_keymap() -> ResultType<Vec<u8>> {
let connection = Connection::connect_to_env()?;
let mut queue = connection.new_event_queue();
connection.display().get_registry(&queue.handle(), ());
let mut state = KeyboardState::default();
let deadline = Instant::now() + KEYMAP_TIMEOUT;
loop {
queue.dispatch_pending(&mut state)?;
if let Some(keymap) = state.keymap.take() {
return keymap;
}
if Instant::now() >= deadline {
bail!("Timed out reading the Wayland keyboard map");
}
queue.flush()?;
if let Some(reader) = queue.prepare_read() {
wait_for_read(reader.connection_fd().as_raw_fd(), deadline)?;
reader.read()?;
}
}
}
fn wait_for_read(fd: RawFd, deadline: Instant) -> ResultType<()> {
let mut descriptor = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let timeout = i32::try_from(remaining.as_millis())?;
let ready = unsafe { libc::poll(&mut descriptor, 1, timeout) };
if ready < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error.into());
}
if ready == 0 {
bail!("Timed out reading the Wayland keyboard map");
}
if descriptor.revents & libc::POLLIN == 0 {
bail!("Wayland keyboard connection closed: {}", descriptor.revents);
}
return Ok(());
}
}
fn read_keymap_file(fd: OwnedFd, size: u32) -> ResultType<Vec<u8>> {
let file = File::from(fd);
if file.metadata()?.len() < u64::from(size) {
bail!("Wayland keyboard map is shorter than its advertised size");
}
let mut bytes = vec![0; usize::try_from(size)?];
// Compositors can share this file description with other clients.
file.read_exact_at(&mut bytes, 0)?;
CStr::from_bytes_with_nul(&bytes)?;
Ok(bytes)
}
impl Dispatch<wl_registry::WlRegistry, ()> for KeyboardState {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
handle: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
// Multi-seat sessions are unsupported: neither registry order nor
// keyboard capability identifies the seat receiving our uinput events.
// The first seat may have no keyboard or expose an unrelated keymap.
if interface == "wl_seat" && !state.seat_bound {
registry.bind::<wl_seat::WlSeat, _, _>(name, version.min(SEAT_VERSION), handle, ());
state.seat_bound = true;
}
}
}
}
impl Dispatch<wl_seat::WlSeat, ()> for KeyboardState {
fn event(
_: &mut Self,
seat: &wl_seat::WlSeat,
event: wl_seat::Event,
_: &(),
_: &Connection,
handle: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities {
capabilities: WEnum::Value(capabilities),
} = event
{
if capabilities.contains(wl_seat::Capability::Keyboard) {
seat.get_keyboard(handle, ());
}
}
}
}
impl Dispatch<wl_keyboard::WlKeyboard, ()> for KeyboardState {
fn event(
state: &mut Self,
_: &wl_keyboard::WlKeyboard,
event: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_keyboard::Event::Keymap { format, fd, size } = event {
state.keymap = Some(match format {
WEnum::Value(wl_keyboard::KeymapFormat::XkbV1) => read_keymap_file(fd, size),
_ => Err(anyhow!(
"Wayland compositor did not provide an XKB keyboard map"
)),
});
}
}
}
+150
View File
@@ -0,0 +1,150 @@
use super::{source::Source, XkbKeymap, DEFAULT_GROUP};
use hbb_common::{anyhow::anyhow, bail, libc, libloading::Library, ResultType};
use std::{
ffi::{c_void, CStr, CString},
ptr::NonNull,
};
use xkbcommon_dl::{self as xkb, x11};
struct Connection {
pointer: NonNull<c_void>,
disconnect: unsafe extern "C" fn(*mut c_void),
_library: Library,
}
impl Connection {
fn open() -> ResultType<Self> {
let display = CString::new(std::env::var("DISPLAY")?)?;
if display.as_bytes().is_empty() {
bail!("Xwayland DISPLAY is empty");
}
if !display.as_bytes().starts_with(b":")
&& !display.as_bytes().starts_with(b"unix:")
&& !display.as_bytes().starts_with(b"unix/:")
{
bail!("Xwayland layout detection requires a local DISPLAY");
}
unsafe {
let library = Library::new("libxcb.so.1")?;
let connect = *library
.get::<unsafe extern "C" fn(*const libc::c_char, *mut libc::c_int) -> *mut c_void>(
b"xcb_connect\0",
)?;
let disconnect =
*library.get::<unsafe extern "C" fn(*mut c_void)>(b"xcb_disconnect\0")?;
let has_error = *library.get::<unsafe extern "C" fn(*mut c_void) -> libc::c_int>(
b"xcb_connection_has_error\0",
)?;
let pointer = NonNull::new(connect(display.as_ptr(), std::ptr::null_mut()))
.ok_or_else(|| anyhow!("Cannot create Xwayland connection"))?;
let connection = Self {
pointer,
disconnect,
_library: library,
};
let error = has_error(connection.pointer.as_ptr());
if error != 0 {
bail!("Cannot connect to Xwayland (XCB error {})", error);
}
Ok(connection)
}
}
}
impl Drop for Connection {
fn drop(&mut self) {
unsafe { (self.disconnect)(self.pointer.as_ptr()) };
}
}
pub(super) fn read() -> ResultType<Source> {
// Xwayland receives the group through focus-scoped wl_keyboard.modifiers.
// With a native window focused, both core and slave X11 keyboards can
// retain the last X11-focused group (verified on GNOME and KWin).
// Reopening XCB or waiting cannot request the missing compositor update.
// Prefer native GNOME/KDE sources; do not steal focus to refresh Xwayland.
let api = xkb::xkbcommon_option().ok_or_else(|| anyhow!("Cannot load libxkbcommon"))?;
let x11 = x11::xkbcommon_x11_option().ok_or_else(|| anyhow!("Cannot load libxkbcommon-x11"))?;
let connection = Connection::open()?;
let device = keyboard_device(x11, &connection)?;
let keymap = read_keymap(x11, &connection, device)?;
let state = unsafe {
(x11.xkb_x11_state_new_from_device)(
keymap.map.as_ptr(),
connection.pointer.as_ptr(),
device,
)
};
let state =
NonNull::new(state).ok_or_else(|| anyhow!("Cannot read Xwayland keyboard state"))?;
let group = unsafe {
(api.xkb_state_serialize_layout)(
state.as_ptr(),
xkb::xkb_state_component::XKB_STATE_LAYOUT_EFFECTIVE,
)
};
unsafe { (api.xkb_state_unref)(state.as_ptr()) };
let text = unsafe {
(api.xkb_keymap_get_as_string)(
keymap.map.as_ptr(),
xkb::xkb_keymap_format::XKB_KEYMAP_FORMAT_TEXT_V1,
)
};
let text = NonNull::new(text.cast_mut())
.ok_or_else(|| anyhow!("Cannot serialize Xwayland keyboard map"))?;
let bytes = unsafe { CStr::from_ptr(text.as_ptr()).to_bytes_with_nul().to_vec() };
unsafe { libc::free(text.as_ptr().cast()) };
Ok(Source::Xwayland {
keymap: bytes,
group: Some(group),
})
}
fn read_keymap(
x11: &x11::XkbCommonX11,
connection: &Connection,
device: i32,
) -> ResultType<XkbKeymap> {
let api = xkb::xkbcommon_option().ok_or_else(|| anyhow!("Cannot load libxkbcommon"))?;
let context =
unsafe { (api.xkb_context_new)(xkb::xkb_context_flags::XKB_CONTEXT_NO_ENVIRONMENT_NAMES) };
let context = NonNull::new(context).ok_or_else(|| anyhow!("Cannot create XKB context"))?;
let map = unsafe {
(x11.xkb_x11_keymap_new_from_device)(
context.as_ptr(),
connection.pointer.as_ptr(),
device,
xkb::xkb_keymap_compile_flags::XKB_KEYMAP_COMPILE_NO_FLAGS,
)
};
unsafe { (api.xkb_context_unref)(context.as_ptr()) };
let map = NonNull::new(map).ok_or_else(|| anyhow!("Cannot read Xwayland keyboard map"))?;
Ok(XkbKeymap {
api,
map,
group: DEFAULT_GROUP,
})
}
fn keyboard_device(api: &x11::XkbCommonX11, connection: &Connection) -> ResultType<i32> {
let supported = unsafe {
(api.xkb_x11_setup_xkb_extension)(
connection.pointer.as_ptr(),
x11::XKB_X11_MIN_MAJOR_XKB_VERSION,
x11::XKB_X11_MIN_MINOR_XKB_VERSION,
x11::xkb_x11_setup_xkb_extension_flags::XKB_X11_SETUP_XKB_EXTENSION_NO_FLAGS,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if supported == 0 {
bail!("Xwayland does not support the required XKB extension");
}
let device = unsafe { (api.xkb_x11_get_core_keyboard_device_id)(connection.pointer.as_ptr()) };
if device < 0 {
bail!("Xwayland has no core keyboard device");
}
Ok(device)
}