diff --git a/crates/punktfunk-host/src/gamestream/input.rs b/crates/punktfunk-host/src/gamestream/input.rs index 0e554cd..77e8474 100644 --- a/crates/punktfunk-host/src/gamestream/input.rs +++ b/crates/punktfunk-host/src/gamestream/input.rs @@ -71,6 +71,9 @@ fn decode_input_packet(p: &[u8]) -> Option { MAGIC_KEY_DOWN | MAGIC_KEY_UP => { // char flags, short keyCode (LE), char modifiers, short zero2. The client stuffs a // 0x80 high byte on key-down; Sunshine masks to the low-byte VK (`& 0xFF`). + // Moonlight VKs are LAYOUT-SEMANTIC (the client's layout already resolved them) — + // tag them so the Windows injector maps them under the receiving app's layout + // instead of the fixed US-positional table the first-party clients use. let key_code = (u16::from_le_bytes([*b.get(1)?, *b.get(2)?]) & 0x00FF) as u32; let modifiers = *b.get(3)? as u32; let kind = if magic == MAGIC_KEY_DOWN { @@ -78,7 +81,13 @@ fn decode_input_packet(p: &[u8]) -> Option { } else { InputKind::KeyUp }; - ev(kind, key_code, 0, 0, modifiers) + ev( + kind, + key_code, + 0, + 0, + modifiers | crate::inject::KEY_FLAG_SEMANTIC_VK, + ) } // UTF-8 text, gamepad, pen, touch, haptics — not yet injected. _ => return None, @@ -125,13 +134,14 @@ mod tests { #[test] fn decodes_key_down_masking_high_byte() { - // keyCode 0x80A4 (LE a4 80) → VK 0xA4 (VK_LMENU); modifiers 0x04 (Alt). + // keyCode 0x80A4 (LE a4 80) → VK 0xA4 (VK_LMENU); modifiers 0x04 (Alt). GameStream keys + // are additionally tagged layout-semantic (Moonlight resolved the VK under its layout). let pt = wrap(MAGIC_KEY_DOWN, &[0x00, 0xa4, 0x80, 0x04, 0x00, 0x00]); let ev = decode(&pt); assert_eq!(ev.len(), 1); assert_eq!(ev[0].kind, InputKind::KeyDown); assert_eq!(ev[0].code, 0xA4); - assert_eq!(ev[0].flags, 0x04); + assert_eq!(ev[0].flags, 0x04 | crate::inject::KEY_FLAG_SEMANTIC_VK); } #[test] diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index c7b581e..663167e 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -5,13 +5,23 @@ //! protocols — `zwlr_virtual_pointer_manager_v1` + `zwp_virtual_keyboard_manager_v1` — which //! 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 a -//! standard evdev/US xkb keymap and track modifier state so the compositor resolves shifted -//! keysyms correctly. +//! 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. use anyhow::Result; use punktfunk_core::input::{InputEvent, InputKind}; +/// In-process tag on a key event's `flags`: the VK in `code` is **layout-semantic** (already +/// resolved under the sending client's keyboard layout — the GameStream/Moonlight convention) +/// rather than the punktfunk-native **US-positional** convention (the physical key's US-layout VK, +/// which every first-party client sends — the client's local layout never touches the wire). +/// The Windows injector maps semantic VKs through the foreground app's layout and positional VKs +/// through a fixed table; conflating the two is exactly the German y↔z / ö→ü scramble. +/// Set ONLY by `gamestream::input::decode`; the punktfunk/1 ingest strips it from wire events, so +/// a network client can never flip the host's key-decoding convention. +pub const KEY_FLAG_SEMANTIC_VK: u32 = 0x8000_0000; + /// Injects input events into the host session. Not `Send`: an injector owns compositor /// resources (a Wayland connection, an xkb state) and lives entirely on the control thread /// that creates it. diff --git a/crates/punktfunk-host/src/inject/windows/sendinput.rs b/crates/punktfunk-host/src/inject/windows/sendinput.rs index b09d54f..d1a415a 100644 --- a/crates/punktfunk-host/src/inject/windows/sendinput.rs +++ b/crates/punktfunk-host/src/inject/windows/sendinput.rs @@ -1,9 +1,18 @@ //! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of //! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games, -//! scancode keyboard, scroll, buttons. The client already sends Windows VK codes, so there is no -//! keycode table. Survives UAC/lock desktop switches with Sunshine's retry-on-failure model: the -//! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when -//! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead. +//! scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's +//! retry-on-failure model: the thread stays bound to its desktop and only reattaches +//! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input +//! desktop switched) — no per-event reattach overhead. +//! +//! **Keyboard conventions** (see [`crate::inject::KEY_FLAG_SEMANTIC_VK`]): first-party punktfunk +//! clients send **US-positional** VKs (the physical key's US-layout VK — layout-independent by +//! construction, the mirror of the Linux host's `vk_to_evdev`), resolved here through the fixed +//! [`positional_vk_to_scan`] table. GameStream/Moonlight clients send **layout-semantic** VKs +//! (Sunshine's model), resolved under the foreground app's layout. Never resolve a positional VK +//! through a layout: this thread runs in the SYSTEM service, whose layout is unrelated to the +//! user's, and any layout re-reads a *position* as a *character* — on a German host that is +//! exactly the y↔z swap / ü-on-ö scramble. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it. #![deny(clippy::undocumented_unsafe_blocks)] @@ -16,15 +25,16 @@ use windows::Win32::System::StationsAndDesktops::{ HDESK, }; use windows::Win32::UI::Input::KeyboardAndMouse::{ - MapVirtualKeyExW, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, - KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MAPVK_VK_TO_VSC_EX, - MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, - MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, - MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, - MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY, + GetKeyboardLayout, MapVirtualKeyExW, SendInput, HKL, INPUT, INPUT_0, INPUT_KEYBOARD, + INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, + MAPVK_VK_TO_VSC_EX, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, + MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, + MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, + MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY, }; use windows::Win32::UI::WindowsAndMessaging::{ - GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + GetForegroundWindow, GetSystemMetrics, GetWindowThreadProcessId, SM_CXVIRTUALSCREEN, + SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, }; use super::InputInjector; @@ -238,17 +248,39 @@ impl InputInjector for SendInputInjector { } InputKind::KeyDown | InputKind::KeyUp => { let down = event.kind == InputKind::KeyDown; - // client sends Windows VK let vk = (event.code & 0xff) as u16; - // SAFETY: `MapVirtualKeyExW` is a pure value translation (VK → scancode); all three - // args are by-value (`u32`, the `MAPVK_VK_TO_VSC_EX` map-type constant, a `None` - // HKL). It dereferences no pointer and returns a `u32` — FFI-`unsafe` only. - let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, None) }; - if sc_ex == 0 { - return Ok(()); // unmappable -> drop - } - let extended = (sc_ex & 0xe000) == 0xe000 || forced_extended(vk); - let scan = (sc_ex & 0xff) as u16; + let semantic = (event.flags & crate::inject::KEY_FLAG_SEMANTIC_VK) != 0; + // Positional wire VKs (first-party clients) resolve through the fixed US table — + // never through a layout (module docs). The table covers only the layout-VARIANT + // typing area; everything else (F-row, nav, numpad, modifiers) is layout-invariant + // and falls through to `MapVirtualKeyExW` (same result under any layout, and it + // keeps the proven extended-bit handling). Semantic VKs (Moonlight) skip the table + // and resolve under the FOREGROUND app's layout — the layout the receiving app + // will decode our scancode with (Sunshine's model; the service thread's own layout + // is not the user's). + let table = if semantic { + None + } else { + positional_vk_to_scan(vk) + }; + let (scan, extended) = match table { + Some(scan) => (scan, forced_extended(vk)), // typing area: never E0-extended + None => { + let hkl = if semantic { foreground_hkl() } else { None }; + // SAFETY: `MapVirtualKeyExW` is a pure value translation (VK → scancode); + // all three args are by-value (`u32`, the `MAPVK_VK_TO_VSC_EX` map-type + // constant, an optional `HKL` handle used only as a lookup key). It + // dereferences no pointer and returns a `u32` — FFI-`unsafe` only. + let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, hkl) }; + if sc_ex == 0 { + return Ok(()); // unmappable -> drop + } + ( + (sc_ex & 0xff) as u16, + (sc_ex & 0xe000) == 0xe000 || forced_extended(vk), + ) + } + }; let mut flags = KEYEVENTF_SCANCODE; if extended { flags |= KEYEVENTF_EXTENDEDKEY; @@ -312,3 +344,115 @@ fn forced_extended(vk: u16) -> bool { 0x21..=0x28 | 0x2D | 0x2E | 0x5B | 0x5C | 0x5D | 0xA3 | 0xA5 | 0x90 ) } + +/// US-positional VK → set-1 make scancode for the layout-**variant** typing area (letters, the +/// digit row, OEM punctuation, the ISO 102nd key). The exact mirror of the Linux host's +/// `crate::inject::vk_to_evdev` — for these keys the evdev code IS the set-1 scancode — and of +/// every first-party client's capture table, so the positional round trip is +/// identity-by-construction. Layout-invariant keys are deliberately absent (the +/// `MapVirtualKeyExW` fallback resolves them identically under any layout, with its proven +/// extended-key handling). All listed keys are plain make codes — never E0-extended. +fn positional_vk_to_scan(vk: u16) -> Option { + Some(match vk { + 0x30 => 0x0B, // VK_0 + 0x31..=0x39 => vk - 0x31 + 0x02, // VK_1..VK_9 → 0x02..0x0A + 0x41 => 0x1E, // A + 0x42 => 0x30, // B + 0x43 => 0x2E, // C + 0x44 => 0x20, // D + 0x45 => 0x12, // E + 0x46 => 0x21, // F + 0x47 => 0x22, // G + 0x48 => 0x23, // H + 0x49 => 0x17, // I + 0x4A => 0x24, // J + 0x4B => 0x25, // K + 0x4C => 0x26, // L + 0x4D => 0x32, // M + 0x4E => 0x31, // N + 0x4F => 0x18, // O + 0x50 => 0x19, // P + 0x51 => 0x10, // Q + 0x52 => 0x13, // R + 0x53 => 0x1F, // S + 0x54 => 0x14, // T + 0x55 => 0x16, // U + 0x56 => 0x2F, // V + 0x57 => 0x11, // W + 0x58 => 0x2D, // X + 0x59 => 0x15, // Y (US position — a QWERTZ host renders it as Z) + 0x5A => 0x2C, // Z (US position) + 0xBA => 0x27, // VK_OEM_1 ;: (DE: ö) + 0xBB => 0x0D, // VK_OEM_PLUS =+ + 0xBC => 0x33, // VK_OEM_COMMA ,< + 0xBD => 0x0C, // VK_OEM_MINUS -_ (DE: ß) + 0xBE => 0x34, // VK_OEM_PERIOD .> + 0xBF => 0x35, // VK_OEM_2 /? + 0xC0 => 0x29, // VK_OEM_3 `~ (DE: ^) + 0xDB => 0x1A, // VK_OEM_4 [{ (DE: ü) + 0xDC => 0x2B, // VK_OEM_5 \| + 0xDD => 0x1B, // VK_OEM_6 ]} + 0xDE => 0x28, // VK_OEM_7 '" (DE: ä) + 0xE2 => 0x56, // VK_OEM_102 <>| (ISO key next to left shift) + _ => return None, + }) +} + +/// The keyboard layout of the thread owning the foreground window — the layout the app receiving +/// our injected scancodes will decode them under (Sunshine's model for semantic Moonlight VKs). +/// `None` when there is no foreground window (secure desktop, transient) — the caller then falls +/// back to the current thread's layout, today's behavior. +fn foreground_hkl() -> Option { + // SAFETY: three read-only queries. `GetForegroundWindow` takes nothing and returns a possibly + // null `HWND` (checked). `GetWindowThreadProcessId` reads the window's owning thread id (the + // process-id out-param is `None`, allowed). `GetKeyboardLayout` maps a thread id to its input + // locale by value. No pointer we own is dereferenced; a stale/foreign `tid` yields a null HKL, + // which is filtered. + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd.is_invalid() { + return None; + } + let tid = GetWindowThreadProcessId(hwnd, None); + let hkl = GetKeyboardLayout(tid); + (!hkl.is_invalid()).then_some(hkl) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The positional table must mirror the Linux host's `vk_to_evdev` exactly — for the typing + /// area the evdev code IS the set-1 scancode, so any divergence would make the same wire VK + /// land on different physical keys on the two hosts. + #[test] + fn positional_table_mirrors_linux_vk_to_evdev() { + let mut checked = 0; + for vk in 0x01..=0xFEu16 { + if let Some(scan) = positional_vk_to_scan(vk) { + assert_eq!( + Some(scan), + crate::inject::vk_to_evdev(vk as u8), + "vk 0x{vk:02X}: sendinput scancode diverges from vk_to_evdev" + ); + checked += 1; + } + } + assert_eq!(checked, 48, "typing-area coverage changed unexpectedly"); + } + + /// The German-scramble regression pins: the US-position VKs the first-party clients send for + /// the physical Y/Z/ö/ü keys must resolve to those physical positions, not through a layout. + #[test] + fn positional_pins_for_the_qwertz_scramble() { + assert_eq!(positional_vk_to_scan(0x59), Some(0x15)); // VK_Y → US-Y position (QWERTZ: Z key) + assert_eq!(positional_vk_to_scan(0x5A), Some(0x2C)); // VK_Z → US-Z position (QWERTZ: Y key) + assert_eq!(positional_vk_to_scan(0xBA), Some(0x27)); // VK_OEM_1 → ;: position (QWERTZ: ö) + assert_eq!(positional_vk_to_scan(0xDB), Some(0x1A)); // VK_OEM_4 → [{ position (QWERTZ: ü) + // Layout-invariant keys stay out of the table (resolved via MapVirtualKeyExW). + assert_eq!(positional_vk_to_scan(0x70), None); // VK_F1 + assert_eq!(positional_vk_to_scan(0x0D), None); // VK_RETURN + assert_eq!(positional_vk_to_scan(0xA0), None); // VK_LSHIFT + } +} diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 15e1962..29a4ece 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1015,8 +1015,19 @@ async fn serve_session( if rich_tx.send(rich).is_err() { break; } - } else if let Some(ev) = InputEvent::decode(&d) { + } else if let Some(mut ev) = InputEvent::decode(&d) { input_count += 1; + // Wire hygiene: KEY_FLAG_SEMANTIC_VK is an in-process tag (GameStream ingest + // only) — strip it from network events so a client can't flip the host's + // key-decoding convention. Other kinds keep flags verbatim (MouseMoveAbs packs + // its reference extent there). + if matches!( + ev.kind, + punktfunk_core::input::InputKind::KeyDown + | punktfunk_core::input::InputKind::KeyUp + ) { + ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK; + } if input_tx.send(ev).is_err() { break; }