991d28909b
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m33s
windows-host / package (push) Successful in 16m29s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m3s
ci / bench (push) Successful in 5m8s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
arch / build-publish (push) Successful in 15m42s
android / android (push) Successful in 16m17s
deb / build-publish (push) Successful in 12m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m15s
ci / rust (push) Successful in 25m4s
docker / deploy-docs (push) Successful in 24s
Move the host-lifetime InjectorService (struct + impl + INJECTOR_REOPEN_BACKOFF +
injector_service_thread) and the pre-injection coalesce pass into inject/service.rs,
alongside the coalesce unit tests. libei_ei_source stays in the facade as an open()
helper. Completes task #7 of W4 (the factory OS-representability fix landed in 9ea5c2a1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
340 lines
16 KiB
Rust
340 lines
16 KiB
Rust
//! Input injection (plan §4): turn client [`punktfunk_core::input::InputEvent`]s into host input.
|
|
//!
|
|
//! The headless Sway compositor runs with `WLR_LIBINPUT_NO_DEVICES=1`, so kernel `uinput`
|
|
//! devices are never picked up. Instead we inject through the wlroots virtual-input Wayland
|
|
//! 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 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};
|
|
|
|
#[path = "inject/keymap.rs"]
|
|
mod keymap;
|
|
#[cfg(target_os = "linux")]
|
|
pub(crate) use keymap::gs_button_to_evdev;
|
|
pub use keymap::KEY_FLAG_SEMANTIC_VK;
|
|
// vk_to_evdev is consumed by the Linux injectors (kwin/libei/wlr) and — on Windows — only by the
|
|
// SendInput mirror test; keep the shared `crate::inject::vk_to_evdev` re-export unconditionally.
|
|
#[cfg_attr(not(target_os = "linux"), allow(unused_imports))]
|
|
pub use keymap::vk_to_evdev;
|
|
|
|
/// Device-agnostic dedup for the rich HID-output feedback plane (0xCD), shared by the virtual-pad
|
|
/// managers ([`uhid_manager`]).
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[path = "inject/hidout_dedup.rs"]
|
|
pub mod hidout_dedup;
|
|
|
|
/// 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.
|
|
pub trait InputInjector {
|
|
fn inject(&mut self, event: &InputEvent) -> Result<()>;
|
|
}
|
|
|
|
/// Preferred injection backend. Which variants exist is **per-OS**: the factory ([`open`]) is a
|
|
/// single per-target block, so it can only be handed a backend that exists on the target — an
|
|
/// impossible OS/backend pairing is a compile error, not a runtime `bail!` (plan §2.3).
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Backend {
|
|
/// wlroots virtual pointer + keyboard Wayland protocols — the headless-Sway path.
|
|
WlrVirtual,
|
|
/// KWin `org_kde_kwin_fake_input` — direct injection, no RemoteDesktop portal / approval dialog
|
|
/// (authorized by the host's `.desktop`). The headless KDE-Desktop path; what krdpserver uses.
|
|
KwinFakeInput,
|
|
/// libei via `reis` — Wayland-native (RemoteDesktop portal).
|
|
Libei,
|
|
/// libei directly against gamescope's own EIS socket (no portal): input lands in the
|
|
/// nested game — the SteamOS-like session.
|
|
GamescopeEi,
|
|
}
|
|
|
|
/// Preferred injection backend. Windows has exactly one path (`SendInput`).
|
|
#[cfg(target_os = "windows")]
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Backend {
|
|
/// Windows `SendInput` (Win32 KeyboardAndMouse) — the Windows host path.
|
|
SendInput,
|
|
}
|
|
|
|
/// Preferred injection backend. No injector exists on this platform; [`open`] rejects it.
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Backend {
|
|
/// Placeholder so the host still builds; the platform has no input injection.
|
|
Unsupported,
|
|
}
|
|
|
|
/// Open the injector for `backend`. The body is one per-OS block: on each target `backend` can only
|
|
/// name a backend that platform has, so there are no cross-OS `bail!` arms (plan §2.3).
|
|
#[cfg(target_os = "linux")]
|
|
pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
|
match backend {
|
|
Backend::WlrVirtual => Ok(Box::new(wlr::WlrootsInjector::open()?)),
|
|
Backend::KwinFakeInput => Ok(Box::new(kwin_fake_input::KwinFakeInjector::open()?)),
|
|
Backend::Libei => Ok(Box::new(
|
|
libei::LibeiInjector::open_with(libei_ei_source())?,
|
|
)),
|
|
Backend::GamescopeEi => Ok(Box::new(libei::LibeiInjector::open_with(
|
|
libei::EiSource::SocketPathFile(crate::vdisplay::gamescope_ei_socket_file()),
|
|
)?)),
|
|
}
|
|
}
|
|
|
|
/// Open the injector for `backend` (Windows: always `SendInput`).
|
|
#[cfg(target_os = "windows")]
|
|
pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
|
match backend {
|
|
Backend::SendInput => Ok(Box::new(sendinput::SendInputInjector::open()?)),
|
|
}
|
|
}
|
|
|
|
/// No input-injection backend exists on this platform.
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
pub fn open(_backend: Backend) -> Result<Box<dyn InputInjector>> {
|
|
anyhow::bail!("no input-injection backend on this platform")
|
|
}
|
|
|
|
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
|
|
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
|
|
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
|
|
/// protocols. **KWin** exposes `org_kde_kwin_fake_input` (direct injection, no portal / approval
|
|
/// dialog — the only headless-capable path; what krdpserver uses), so prefer it there. **GNOME**
|
|
/// has neither fake_input nor the wlr protocols, so it uses libei via the RemoteDesktop portal
|
|
/// (which needs a user to approve, or a pre-seeded grant — not truly headless).
|
|
/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope` overrides the auto-detection.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn default_backend() -> Backend {
|
|
if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") {
|
|
match v.trim().to_ascii_lowercase().as_str() {
|
|
"wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual,
|
|
"kwin" | "fakeinput" | "fake_input" | "kwin-fake-input" => {
|
|
return Backend::KwinFakeInput
|
|
}
|
|
"libei" | "ei" | "portal" => return Backend::Libei,
|
|
"gamescope" | "gamescope-ei" => return Backend::GamescopeEi,
|
|
other => tracing::warn!(
|
|
value = other,
|
|
"unknown PUNKTFUNK_INPUT_BACKEND — auto-detecting"
|
|
),
|
|
}
|
|
}
|
|
// An explicit compositor pick (set per connect / mid-stream) is the strongest signal.
|
|
let compositor = crate::config::config().compositor.clone();
|
|
if let Some(c) = compositor.as_deref() {
|
|
let c = c.trim();
|
|
if c.eq_ignore_ascii_case("gamescope") {
|
|
return Backend::GamescopeEi;
|
|
}
|
|
if c.eq_ignore_ascii_case("kwin") {
|
|
return Backend::KwinFakeInput;
|
|
}
|
|
if c.eq_ignore_ascii_case("wlroots")
|
|
|| c.eq_ignore_ascii_case("sway")
|
|
// Hyprland kept the wlr virtual-input protocols, so it injects through the same
|
|
// backend as sway/river (design/hyprland-support.md D4).
|
|
|| c.eq_ignore_ascii_case("hyprland")
|
|
{
|
|
return Backend::WlrVirtual;
|
|
}
|
|
// mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below.
|
|
}
|
|
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
|
|
let d = desktop.to_ascii_uppercase();
|
|
if d.contains("KDE") {
|
|
Backend::KwinFakeInput
|
|
} else if d.contains("GNOME") {
|
|
Backend::Libei
|
|
} else {
|
|
Backend::WlrVirtual
|
|
}
|
|
}
|
|
|
|
/// The Windows host has a single injection backend.
|
|
#[cfg(target_os = "windows")]
|
|
pub fn default_backend() -> Backend {
|
|
Backend::SendInput
|
|
}
|
|
|
|
/// No injector on this platform.
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
pub fn default_backend() -> Backend {
|
|
Backend::Unsupported
|
|
}
|
|
|
|
#[path = "inject/service.rs"]
|
|
mod service;
|
|
pub(crate) use service::InjectorService;
|
|
|
|
/// How the libei backend reaches its EIS server. KWin goes through the `RemoteDesktop` *portal*
|
|
/// (with a pre-seeded grant), but GNOME's portal `Start()` needs an interactive approval a
|
|
/// headless host can't answer — so GNOME goes straight to Mutter's *direct* RemoteDesktop EIS
|
|
/// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses.
|
|
#[cfg(target_os = "linux")]
|
|
fn libei_ei_source() -> libei::EiSource {
|
|
let gnome = crate::config::config()
|
|
.compositor
|
|
.as_deref()
|
|
.is_some_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|
|
|| std::env::var("XDG_CURRENT_DESKTOP")
|
|
.unwrap_or_default()
|
|
.to_ascii_uppercase()
|
|
.contains("GNOME");
|
|
if gnome {
|
|
libei::EiSource::MutterEis
|
|
} else {
|
|
libei::EiSource::Portal
|
|
}
|
|
}
|
|
|
|
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
|
|
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
|
|
// `#[path]` keeps every `crate::inject::*` module name flat.
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/dualsense.rs"]
|
|
pub mod dualsense;
|
|
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
|
|
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
|
|
#[cfg(target_os = "windows")]
|
|
#[path = "inject/windows/dualsense_edge_windows.rs"]
|
|
pub mod dualsense_edge_windows;
|
|
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
|
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[path = "inject/proto/dualsense_proto.rs"]
|
|
pub mod dualsense_proto;
|
|
/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel.
|
|
#[cfg(target_os = "windows")]
|
|
#[path = "inject/windows/dualsense_windows.rs"]
|
|
pub mod dualsense_windows;
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/dualshock4.rs"]
|
|
pub mod dualshock4;
|
|
/// Transport-independent DualShock 4 HID codec, shared by the Linux UHID backend ([`dualshock4`])
|
|
/// and the Windows UMDF-driver backend ([`dualshock4_windows`]).
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[path = "inject/proto/dualshock4_proto.rs"]
|
|
pub mod dualshock4_proto;
|
|
/// Windows: virtual DualShock 4 via the same UMDF minidriver + shared-memory channel (device-type 1).
|
|
#[cfg(target_os = "windows")]
|
|
#[path = "inject/windows/dualshock4_windows.rs"]
|
|
pub mod dualshock4_windows;
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/gamepad.rs"]
|
|
pub mod gamepad;
|
|
/// Windows: virtual Xbox 360 pads via the in-tree XUSB companion UMDF driver (classic XInput).
|
|
#[cfg(target_os = "windows")]
|
|
#[path = "inject/windows/gamepad_windows.rs"]
|
|
pub mod gamepad;
|
|
/// Windows: small RAII wrappers (`Shm` section+view, `SwDevice` devnode) shared by the three gamepad
|
|
/// backends (DualSense / DualShock 4 / XUSB), so each per-pad resource closes deterministically on drop.
|
|
#[cfg(target_os = "windows")]
|
|
#[path = "inject/windows/gamepad_raii.rs"]
|
|
mod gamepad_raii;
|
|
/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for
|
|
/// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff
|
|
/// retry.
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[path = "inject/pad_gate.rs"]
|
|
pub mod pad_gate;
|
|
/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the
|
|
/// `Vec<Option<Pad>>` table, `active_mask` unplug sweep, and gate-checked create every backend
|
|
/// manager used to copy-paste (G12).
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[path = "inject/pad_slots.rs"]
|
|
pub mod pad_slots;
|
|
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/steam_controller.rs"]
|
|
pub mod steam_controller;
|
|
/// Linux: virtual Steam Controller 2 (Triton, `28DE:1302`) via UHID — as-is raw passthrough of a
|
|
/// client-captured physical pad; Steam Input drives the hidraw node (no kernel driver binds it).
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/steam_controller2.rs"]
|
|
pub mod steam_controller2;
|
|
/// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel
|
|
/// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
|
|
#[cfg(target_os = "windows")]
|
|
#[path = "inject/windows/steam_deck_windows.rs"]
|
|
pub mod steam_deck_windows;
|
|
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
|
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
|
|
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/steam_gadget.rs"]
|
|
pub mod steam_gadget;
|
|
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
|
|
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend
|
|
/// ([`steam_controller`]) and the Windows UMDF backend ([`steam_deck_windows`]).
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[path = "inject/proto/steam_proto.rs"]
|
|
pub mod steam_proto;
|
|
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
|
|
/// Shared by the Linux and Windows DualSense/DS4 backends (the slot-less pads that must fold the
|
|
/// Steam back grips); the Deck motion rescale is Linux-only but harmless to compile on Windows.
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[path = "inject/proto/steam_remap.rs"]
|
|
pub mod steam_remap;
|
|
/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean,
|
|
/// Steam-Input-promotable virtual-Deck transport on non-SteamOS hosts (Bazzite/generic), where
|
|
/// `dummy_hcd`/`raw_gadget` aren't built. In-tree + signed; no module build, no MOK.
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/steam_usbip.rs"]
|
|
pub mod steam_usbip;
|
|
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/switch_pro.rs"]
|
|
pub mod switch_pro;
|
|
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
|
|
/// replies, used by the Linux UHID backend ([`switch_pro`]).
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/proto/switch_proto.rs"]
|
|
pub mod switch_proto;
|
|
/// Transport-independent Steam Controller 2 (Triton) contract: descriptor, SDL-documented report
|
|
/// layout, the typed fallback serializer, and the rumble-output parser. Linux-only consumer today
|
|
/// ([`steam_controller2`]).
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/proto/triton_proto.rs"]
|
|
pub mod triton_proto;
|
|
/// Linux: virtual Steam Controller 2 over **USB/IP** — a real USB device byte-matched to the
|
|
/// physical wired pad's captured descriptors, so Steam lists it (the UHID leg is confirmed
|
|
/// invisible to Steam). Preferred transport of [`steam_controller2`].
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/triton_usbip.rs"]
|
|
pub mod triton_usbip;
|
|
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
|
|
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
|
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[path = "inject/uhid_manager.rs"]
|
|
pub mod uhid_manager;
|
|
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
pub mod gamepad {
|
|
#[derive(Default)]
|
|
pub struct GamepadManager;
|
|
impl GamepadManager {
|
|
pub fn new() -> Self {
|
|
GamepadManager
|
|
}
|
|
pub fn handle(&mut self, _ev: &crate::gamestream::gamepad::GamepadEvent) {}
|
|
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {}
|
|
}
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/kwin_fake_input.rs"]
|
|
mod kwin_fake_input;
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/libei.rs"]
|
|
mod libei;
|
|
#[cfg(target_os = "windows")]
|
|
#[path = "inject/windows/sendinput.rs"]
|
|
mod sendinput;
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "inject/linux/wlr.rs"]
|
|
mod wlr;
|