diff --git a/crates/punktfunk-host/protocols/fake-input.xml b/crates/punktfunk-host/protocols/fake-input.xml
new file mode 100644
index 0000000..e932e70
--- /dev/null
+++ b/crates/punktfunk-host/protocols/fake-input.xml
@@ -0,0 +1,73 @@
+
+
+
+ SPDX-FileCopyrightText: 2015 Martin Gräßlin
+ SPDX-License-Identifier: LGPL-2.1-or-later
+
+
+
+ This interface allows other processes to provide fake input events.
+ Purpose is on the one hand side to provide testing facilities like XTest
+ on X11, but also to support use cases like remote control (a remote
+ desktop server). The compositor gates the interface: it is only exposed
+ to clients authorized through their .desktop X-KDE-Wayland-Interfaces, so
+ binding it is the authorization — no per-event confirmation dialog.
+
+
+
+ A FakeInput is required to authenticate itself by providing the
+ application name and the reason for fake input. The compositor may use
+ this information to decide whether to allow or deny the request.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs
index 21f4390..2fc164d 100644
--- a/crates/punktfunk-host/src/inject.rs
+++ b/crates/punktfunk-host/src/inject.rs
@@ -24,6 +24,9 @@ pub trait InputInjector {
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). Not yet implemented.
Libei,
/// libei directly against gamescope's own EIS socket (no portal): input lands in the
@@ -47,6 +50,16 @@ pub fn open(backend: Backend) -> Result> {
anyhow::bail!("wlroots virtual input requires Linux + a Wayland compositor")
}
}
+ Backend::KwinFakeInput => {
+ #[cfg(target_os = "linux")]
+ {
+ Ok(Box::new(kwin_fake_input::KwinFakeInjector::open()?))
+ }
+ #[cfg(not(target_os = "linux"))]
+ {
+ anyhow::bail!("KWin fake_input requires Linux + a KWin Wayland session")
+ }
+ }
Backend::Libei => {
#[cfg(target_os = "linux")]
{
@@ -90,12 +103,18 @@ pub fn open(backend: Backend) -> Result> {
/// 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 and GNOME implement RemoteDesktop but not the wlr protocols, so use libei.
-/// `PUNKTFUNK_INPUT_BACKEND=wlr|libei|gamescope|uinput` overrides the auto-detection.
+/// 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|uinput` overrides the auto-detection.
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,
"uinput" => return Backend::Uinput,
@@ -112,16 +131,26 @@ pub fn default_backend() -> Backend {
}
#[cfg(not(target_os = "windows"))]
{
- if crate::config::config()
- .compositor
- .as_deref()
- .is_some_and(|v| v.trim().eq_ignore_ascii_case("gamescope"))
- {
- return Backend::GamescopeEi;
+ // 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") {
+ 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") || d.contains("GNOME") {
+ if d.contains("KDE") {
+ Backend::KwinFakeInput
+ } else if d.contains("GNOME") {
Backend::Libei
} else {
Backend::WlrVirtual
@@ -478,6 +507,9 @@ pub mod gamepad {
}
}
#[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")]
diff --git a/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs b/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs
new file mode 100644
index 0000000..92b4e6e
--- /dev/null
+++ b/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs
@@ -0,0 +1,209 @@
+//! 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 — which
+//! on a headless box (single per-session virtual output at the origin, scale 1) equals the streamed
+//! output's pixels.
+
+#![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 wayland_client::protocol::wl_registry::{self, WlRegistry};
+use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
+
+// 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;
+
+/// Registry-bound globals (the Wayland dispatch state).
+#[derive(Default)]
+struct State {
+ fake: Option,
+}
+
+impl Dispatch for State {
+ fn event(
+ state: &mut Self,
+ registry: &WlRegistry,
+ event: wl_registry::Event,
+ _: &(),
+ _: &Connection,
+ qh: &QueueHandle,
+ ) {
+ if let wl_registry::Event::Global {
+ name,
+ interface,
+ version,
+ } = event
+ {
+ if interface == "org_kde_kwin_fake_input" {
+ state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
+ }
+ }
+ }
+}
+
+// fake_input emits no events.
+impl Dispatch for State {
+ fn event(
+ _: &mut Self,
+ _: &FakeInput,
+ _: ::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ }
+}
+
+pub struct KwinFakeInjector {
+ conn: Connection,
+ queue: EventQueue,
+ state: State,
+ fake: FakeInput,
+}
+
+impl KwinFakeInjector {
+ pub fn open() -> Result {
+ 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();
+
+ tracing::info!("KWin fake_input ready (headless keyboard/mouse/touch — no portal)");
+ Ok(Self {
+ conn,
+ queue,
+ state,
+ fake,
+ })
+ }
+}
+
+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;
+ let h = event.flags & 0xffff;
+ if w > 0 && h > 0 {
+ let x = event.x.clamp(0, w as i32) as f64;
+ let y = event.y.clamp(0, h as i32) as f64;
+ self.fake.pointer_motion_absolute(x, y);
+ }
+ }
+ 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;
+ let h = event.flags & 0xffff;
+ if w > 0 && h > 0 {
+ let x = event.x.clamp(0, w as i32) as f64;
+ let y = event.y.clamp(0, h as i32) as f64;
+ 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(())
+ }
+}