580b1ea7a7
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Steam Deck pass-through (design/steam-deck-passthrough-plan.md), code-complete + all CI checks green on Linux + adversarially reviewed; on-glass validation pending: - usbip/`vhci_hcd` virtual Deck transport (inject/linux/steam_usbip.rs) for non-SteamOS hosts (Bazzite/generic) — presents a real interface-2 USB Deck so Steam Input promotes it. In-process vhci attach (loopback OP_REQ_IMPORT handshake → sysfs attach) with a bounded `usbip`-CLI fallback; detach on drop. - Backed by a vendored, libusb-free trim of the `usbip` crate (crates/punktfunk-host/vendor/usbip-sim, MIT + NOTICE; host/cdc/hid + rusb/nusb removed; interrupt-IN paced by bInterval). - Selection ladder raw_gadget (SteamOS fast-path) → usbip (universal) → UHID, with PUNKTFUNK_STEAM_USBIP / PUNKTFUNK_USBIP_ATTACH knobs. - Shared Deck descriptors + the 0x83/0xAE feature contract + a Steam-accepted serial consolidated into steam_proto.rs; the raw_gadget backend reuses them. - Linux client leave-shortcuts: Ctrl+Alt+Shift+D + holding the escape chord (L1+R1+Start+Select) >=1.5s end the session (short press still exits fullscreen); the chord state resets across sessions. Also bundles in-progress work already staged in the tree: - host(kwin): xdg-output logical-geometry mapping so the KWin fake_input backend places absolute coordinates correctly under display scaling. - docs: design/README index entries + design/controller-only-mode.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
438 lines
18 KiB
Rust
438 lines
18 KiB
Rust
//! Headless input injection on KWin via the privileged `org_kde_kwin_fake_input` protocol — the
|
||
//! exact path KDE's own headless RDP server (`krdpserver`) uses. KWin advertises this restricted
|
||
//! global only to a client authorized through its installed `.desktop` `X-KDE-Wayland-Interfaces`
|
||
//! (we ship `io.unom.Punktfunk.Host.desktop`, which lists `org_kde_kwin_fake_input` alongside
|
||
//! `zkde_screencast_unstable_v1`). Binding the global IS the authorization, so injection needs **no
|
||
//! RemoteDesktop portal and no "Allow remote control?" dialog** — it works with no user present,
|
||
//! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's
|
||
//! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux
|
||
//! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr
|
||
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space.
|
||
//!
|
||
//! Global compositor space is *logical* pixels (post display-scaling), which only equals the streamed
|
||
//! output's physical pixels at scale 1. Under a fractional/integer scale the logical edge sits at
|
||
//! `physical / scale`, so feeding the raw streamed pixel coordinate lands the cursor `scale×` too far
|
||
//! toward the bottom-right (top-left stays put). We therefore track each output's logical geometry
|
||
//! (position + size) via `xdg-output` and map the normalized client position into the matching
|
||
//! output's logical rectangle — the same shape the libei backend uses with its EI region.
|
||
|
||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||
|
||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||
use anyhow::{Context, Result};
|
||
use punktfunk_core::input::InputKind;
|
||
use std::time::{Duration, Instant};
|
||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum};
|
||
use wayland_protocols::xdg::xdg_output::zv1::client::{
|
||
zxdg_output_manager_v1::ZxdgOutputManagerV1,
|
||
zxdg_output_v1::{self, ZxdgOutputV1},
|
||
};
|
||
|
||
// Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the
|
||
// KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR.
|
||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||
pub mod fake {
|
||
use wayland_client;
|
||
use wayland_client::protocol::*;
|
||
|
||
pub mod __interfaces {
|
||
use wayland_client::protocol::__interfaces::*;
|
||
wayland_scanner::generate_interfaces!("protocols/fake-input.xml");
|
||
}
|
||
use self::__interfaces::*;
|
||
|
||
wayland_scanner::generate_client_code!("protocols/fake-input.xml");
|
||
}
|
||
|
||
use fake::org_kde_kwin_fake_input::OrgKdeKwinFakeInput as FakeInput;
|
||
|
||
/// Highest interface version we drive. `keyboard_key` arrived at v4; KWin advertises ≥4.
|
||
const MAX_VERSION: u32 = 4;
|
||
|
||
/// `wl_pointer.axis` values used by `axis`.
|
||
const AXIS_VERTICAL: u32 = 0;
|
||
const AXIS_HORIZONTAL: u32 = 1;
|
||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend).
|
||
const SCROLL_HORIZONTAL: u32 = 1;
|
||
|
||
/// One tracked output: its physical mode (to match the streamed resolution) and its logical geometry
|
||
/// (the global-compositor-space rectangle absolute coordinates are addressed in). `logical_w == 0`
|
||
/// means xdg-output hasn't reported its size yet.
|
||
struct OutputTrack {
|
||
/// Registry global id — also the dispatch user-data, so events route back to this entry.
|
||
name: u32,
|
||
wl_output: WlOutput,
|
||
xdg_output: Option<ZxdgOutputV1>,
|
||
/// Physical pixel mode from `wl_output.mode` (the `current` mode); matched against the streamed WxH.
|
||
mode_w: i32,
|
||
mode_h: i32,
|
||
/// Logical (post-scale) geometry from `xdg-output`.
|
||
logical_x: i32,
|
||
logical_y: i32,
|
||
logical_w: i32,
|
||
logical_h: i32,
|
||
}
|
||
|
||
/// Registry-bound globals (the Wayland dispatch state).
|
||
#[derive(Default)]
|
||
struct State {
|
||
fake: Option<FakeInput>,
|
||
xdg_mgr: Option<ZxdgOutputManagerV1>,
|
||
outputs: Vec<OutputTrack>,
|
||
}
|
||
|
||
impl State {
|
||
/// Create the `xdg_output` for a tracked output once both it and the manager exist.
|
||
fn ensure_xdg_output(o: &mut OutputTrack, mgr: &ZxdgOutputManagerV1, qh: &QueueHandle<State>) {
|
||
if o.xdg_output.is_none() {
|
||
o.xdg_output = Some(mgr.get_xdg_output(&o.wl_output, qh, o.name));
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Dispatch<WlRegistry, ()> for State {
|
||
fn event(
|
||
state: &mut Self,
|
||
registry: &WlRegistry,
|
||
event: wl_registry::Event,
|
||
_: &(),
|
||
_: &Connection,
|
||
qh: &QueueHandle<Self>,
|
||
) {
|
||
match event {
|
||
wl_registry::Event::Global {
|
||
name,
|
||
interface,
|
||
version,
|
||
} => match interface.as_str() {
|
||
"org_kde_kwin_fake_input" => {
|
||
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
|
||
}
|
||
"wl_output" => {
|
||
// v1 carries `mode` (all we need); bind no higher than the proxy's max (4).
|
||
let wl_output: WlOutput = registry.bind(name, version.min(4), qh, name);
|
||
let mut o = OutputTrack {
|
||
name,
|
||
wl_output,
|
||
xdg_output: None,
|
||
mode_w: 0,
|
||
mode_h: 0,
|
||
logical_x: 0,
|
||
logical_y: 0,
|
||
logical_w: 0,
|
||
logical_h: 0,
|
||
};
|
||
if let Some(mgr) = state.xdg_mgr.clone() {
|
||
State::ensure_xdg_output(&mut o, &mgr, qh);
|
||
}
|
||
state.outputs.push(o);
|
||
}
|
||
"zxdg_output_manager_v1" => {
|
||
let mgr: ZxdgOutputManagerV1 = registry.bind(name, version.min(3), qh, ());
|
||
// Outputs bound before the manager have no xdg_output yet — create them now.
|
||
for o in state.outputs.iter_mut() {
|
||
State::ensure_xdg_output(o, &mgr, qh);
|
||
}
|
||
state.xdg_mgr = Some(mgr);
|
||
}
|
||
_ => {}
|
||
},
|
||
wl_registry::Event::GlobalRemove { name } => {
|
||
state.outputs.retain(|o| {
|
||
if o.name == name {
|
||
if let Some(x) = &o.xdg_output {
|
||
x.destroy();
|
||
}
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
});
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// fake_input emits no events.
|
||
impl Dispatch<FakeInput, ()> for State {
|
||
fn event(
|
||
_: &mut Self,
|
||
_: &FakeInput,
|
||
_: <FakeInput as Proxy>::Event,
|
||
_: &(),
|
||
_: &Connection,
|
||
_: &QueueHandle<Self>,
|
||
) {
|
||
}
|
||
}
|
||
|
||
impl Dispatch<WlOutput, u32> for State {
|
||
fn event(
|
||
state: &mut Self,
|
||
_: &WlOutput,
|
||
event: wl_output::Event,
|
||
name: &u32,
|
||
_: &Connection,
|
||
_: &QueueHandle<Self>,
|
||
) {
|
||
// Only the *current* mode matters — a real monitor also advertises its other supported modes.
|
||
if let wl_output::Event::Mode {
|
||
flags: WEnum::Value(flags),
|
||
width,
|
||
height,
|
||
..
|
||
} = event
|
||
{
|
||
if flags.contains(wl_output::Mode::Current) {
|
||
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
|
||
o.mode_w = width;
|
||
o.mode_h = height;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Dispatch<ZxdgOutputV1, u32> for State {
|
||
fn event(
|
||
state: &mut Self,
|
||
_: &ZxdgOutputV1,
|
||
event: zxdg_output_v1::Event,
|
||
name: &u32,
|
||
_: &Connection,
|
||
_: &QueueHandle<Self>,
|
||
) {
|
||
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
|
||
match event {
|
||
zxdg_output_v1::Event::LogicalPosition { x, y } => {
|
||
o.logical_x = x;
|
||
o.logical_y = y;
|
||
}
|
||
zxdg_output_v1::Event::LogicalSize { width, height } => {
|
||
o.logical_w = width;
|
||
o.logical_h = height;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The manager has no events.
|
||
impl Dispatch<ZxdgOutputManagerV1, ()> for State {
|
||
fn event(
|
||
_: &mut Self,
|
||
_: &ZxdgOutputManagerV1,
|
||
_: <ZxdgOutputManagerV1 as Proxy>::Event,
|
||
_: &(),
|
||
_: &Connection,
|
||
_: &QueueHandle<Self>,
|
||
) {
|
||
}
|
||
}
|
||
|
||
pub struct KwinFakeInjector {
|
||
conn: Connection,
|
||
queue: EventQueue<State>,
|
||
state: State,
|
||
fake: FakeInput,
|
||
/// When output geometry was last re-read; throttles the per-event roundtrip (see `refresh_geometry`).
|
||
last_refresh: Option<Instant>,
|
||
}
|
||
|
||
/// How often the fake_input backend re-reads output geometry from the compositor. Output add/remove
|
||
/// (a new session's virtual output) and live scale/resolution changes are infrequent, so a lazy
|
||
/// poll on the injector's own thread is plenty and adds at most one local-socket roundtrip twice a
|
||
/// second — versus a blocking roundtrip on every single mouse-move event.
|
||
const GEO_REFRESH: Duration = Duration::from_millis(500);
|
||
|
||
impl KwinFakeInjector {
|
||
pub fn open() -> Result<Self> {
|
||
let conn = Connection::connect_to_env()
|
||
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
|
||
let mut queue = conn.new_event_queue();
|
||
let qh = queue.handle();
|
||
let _registry = conn.display().get_registry(&qh, ());
|
||
let mut state = State::default();
|
||
queue
|
||
.roundtrip(&mut state)
|
||
.context("Wayland registry roundtrip")?;
|
||
|
||
let fake = state.fake.clone().context(
|
||
"KWin does not expose org_kde_kwin_fake_input to this client — install the host's \
|
||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||
KWin authorizes it (the grant is cached per-exe on first connect), or this is not a \
|
||
KWin session",
|
||
)?;
|
||
// Authenticate (the legacy handshake; for an interface-authorized client KWin accepts it
|
||
// without a dialog — same as krdpserver/krfb headless).
|
||
fake.authenticate("punktfunk".into(), "remote streaming input".into());
|
||
queue
|
||
.roundtrip(&mut state)
|
||
.context("fake_input authenticate roundtrip")?;
|
||
conn.flush().ok();
|
||
|
||
// Settle output geometry (wl_output + xdg-output were bound during the registry roundtrip
|
||
// above; their logical_size arrives on a follow-up roundtrip). Best-effort — falls back to
|
||
// scale-1 mapping if xdg-output is absent.
|
||
let mut injector = Self {
|
||
conn,
|
||
queue,
|
||
state,
|
||
fake,
|
||
last_refresh: None,
|
||
};
|
||
injector.refresh_geometry();
|
||
tracing::info!(
|
||
outputs = injector.state.outputs.len(),
|
||
"KWin fake_input ready (headless keyboard/mouse/touch — no portal)"
|
||
);
|
||
Ok(injector)
|
||
}
|
||
|
||
/// Re-read output geometry, throttled to [`GEO_REFRESH`]. A `roundtrip` both flushes any pending
|
||
/// `get_xdg_output` requests and reads the geometry events back. A wl_output that *appeared* this
|
||
/// round only gets its xdg_output created mid-dispatch, so its `logical_size` lands on a later
|
||
/// roundtrip — keep going (bounded) until every output is settled.
|
||
fn refresh_geometry(&mut self) {
|
||
let now = Instant::now();
|
||
if let Some(t) = self.last_refresh {
|
||
if now.duration_since(t) < GEO_REFRESH {
|
||
return;
|
||
}
|
||
}
|
||
self.last_refresh = Some(now);
|
||
for _ in 0..3 {
|
||
if self.queue.roundtrip(&mut self.state).is_err() {
|
||
return;
|
||
}
|
||
let pending =
|
||
self.state.xdg_mgr.is_some() && self.state.outputs.iter().any(|o| o.logical_w == 0);
|
||
if !pending {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resolve the logical (global-compositor-space) rectangle to map a normalized client position
|
||
/// into. Prefer the output whose physical mode matches the streamed `phys_w`×`phys_h` (the
|
||
/// per-session virtual output); fall back to the sole output, then — if xdg-output is unavailable
|
||
/// — to the streamed pixels at the origin (the pre-scaling behavior, correct at scale 1).
|
||
fn logical_target(&self, phys_w: i32, phys_h: i32) -> (f64, f64, f64, f64) {
|
||
let usable = || {
|
||
self.state
|
||
.outputs
|
||
.iter()
|
||
.filter(|o| o.logical_w > 0 && o.logical_h > 0)
|
||
};
|
||
let chosen = usable()
|
||
.find(|o| o.mode_w == phys_w && o.mode_h == phys_h)
|
||
.or_else(|| {
|
||
let mut it = usable();
|
||
match (it.next(), it.next()) {
|
||
(Some(only), None) => Some(only),
|
||
_ => None,
|
||
}
|
||
});
|
||
match chosen {
|
||
Some(o) => (
|
||
o.logical_x as f64,
|
||
o.logical_y as f64,
|
||
o.logical_w as f64,
|
||
o.logical_h as f64,
|
||
),
|
||
None => (0.0, 0.0, phys_w as f64, phys_h as f64),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl InputInjector for KwinFakeInjector {
|
||
fn inject(&mut self, event: &InputEvent) -> Result<()> {
|
||
match event.kind {
|
||
InputKind::MouseMove => {
|
||
self.fake.pointer_motion(event.x as f64, event.y as f64);
|
||
}
|
||
InputKind::MouseMoveAbs => {
|
||
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||
let h = (event.flags & 0xffff) as i32;
|
||
if w > 0 && h > 0 {
|
||
self.refresh_geometry();
|
||
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||
// Normalize in the streamed (physical) pixel space, then place inside the output's
|
||
// logical rectangle — so display scaling no longer offsets the cursor.
|
||
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
|
||
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
|
||
self.fake
|
||
.pointer_motion_absolute(lx + nx * lw, ly + ny * lh);
|
||
}
|
||
}
|
||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||
if let Some(btn) = gs_button_to_evdev(event.code) {
|
||
let st = u32::from(event.kind == InputKind::MouseButtonDown);
|
||
self.fake.button(btn, st);
|
||
}
|
||
}
|
||
InputKind::MouseScroll => {
|
||
// GameStream sends WHEEL_DELTA(120)-scaled units; a notch ≈ 15px. Vertical flips
|
||
// sign on the Wayland axis, horizontal passes through — same as the wlr backend.
|
||
let horizontal = event.code == SCROLL_HORIZONTAL;
|
||
let axis = if horizontal {
|
||
AXIS_HORIZONTAL
|
||
} else {
|
||
AXIS_VERTICAL
|
||
};
|
||
let notches = event.x as f64 / 120.0;
|
||
let sign = if horizontal { 1.0 } else { -1.0 };
|
||
self.fake.axis(axis, sign * notches * 15.0);
|
||
}
|
||
InputKind::KeyDown | InputKind::KeyUp => {
|
||
// Raw evdev keycode; KWin resolves it through the session's own keymap (and tracks
|
||
// modifier state itself, so no separate modifiers request is needed).
|
||
if let Some(evdev) = vk_to_evdev(event.code as u8) {
|
||
let st = u32::from(event.kind == InputKind::KeyDown);
|
||
self.fake.keyboard_key(evdev as u32, st);
|
||
} else {
|
||
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
||
}
|
||
}
|
||
// Touch: id = event.code, coords in the client surface w×h packed into flags (same
|
||
// absolute mapping as MouseMoveAbs). Each event is its own frame.
|
||
InputKind::TouchDown | InputKind::TouchMove => {
|
||
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||
let h = (event.flags & 0xffff) as i32;
|
||
if w > 0 && h > 0 {
|
||
self.refresh_geometry();
|
||
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
|
||
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
|
||
let x = lx + nx * lw;
|
||
let y = ly + ny * lh;
|
||
if event.kind == InputKind::TouchDown {
|
||
self.fake.touch_down(event.code, x, y);
|
||
} else {
|
||
self.fake.touch_motion(event.code, x, y);
|
||
}
|
||
self.fake.touch_frame();
|
||
}
|
||
}
|
||
InputKind::TouchUp => {
|
||
self.fake.touch_up(event.code);
|
||
self.fake.touch_frame();
|
||
}
|
||
// Gamepads are injected through uinput, not the compositor.
|
||
InputKind::GamepadButton | InputKind::GamepadAxis => {}
|
||
}
|
||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||
self.queue
|
||
.dispatch_pending(&mut self.state)
|
||
.context("wayland dispatch")?;
|
||
self.conn.flush().context("wayland flush")?;
|
||
Ok(())
|
||
}
|
||
}
|