rename: lumen → punktfunk, everywhere
ci / rust (push) Has been cancelled

Full project rename, decided 2026-06-10:
- Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs.
- C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h,
  PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl.
  PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants).
- Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1.
  WIRE BREAK: clients must be rebuilt from this revision.
- Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / ….
- Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the
  persistent identity is unchanged, pinned fingerprints stay valid).
- Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection
  (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated.
- scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated.

Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of
"desktop but no apps/settings" over the stream: plasmashell launched without
XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and
rendered an empty menu. The script sets the complete KDE session env (menu prefix,
KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell.

Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS,
zero lumen references left outside .git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:11:59 +00:00
parent b8b23c8fb2
commit bfd64ce871
119 changed files with 1245 additions and 1185 deletions
+515
View File
@@ -0,0 +1,515 @@
//! Virtual gamepads via `/dev/uinput`, cloning the kernel `xpad` identity ("Microsoft X-Box
//! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero
//! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client
//! controller, managed by [`GamepadManager`] from decoded
//! [`GamepadFrame`](crate::gamestream::gamepad::GamepadFrame)s.
//!
//! Rumble flows the *other* way on the same fd: games upload force-feedback effects
//! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with
//! `EV_FF` writes; [`GamepadManager::pump_rumble`] services that protocol non-blockingly
//! (the control thread calls it every tick) and reports mixed `(low, high)` motor levels for
//! the host to send to the client. Note: a game's `EVIOCSFF` ioctl BLOCKS until we answer
//! `UI_END_FF_UPLOAD`, so the pump must run regularly.
//!
//! All ioctl numbers/struct layouts below were verified against this generation's
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
use anyhow::{bail, Result};
use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd};
use std::time::Instant;
// ioctls (x86_64).
const UI_DEV_CREATE: libc::c_ulong = 0x5501;
const UI_DEV_DESTROY: libc::c_ulong = 0x5502;
const UI_DEV_SETUP: libc::c_ulong = 0x405c_5503;
const UI_ABS_SETUP: libc::c_ulong = 0x401c_5504;
const UI_SET_EVBIT: libc::c_ulong = 0x4004_5564;
const UI_SET_KEYBIT: libc::c_ulong = 0x4004_5565;
const UI_SET_FFBIT: libc::c_ulong = 0x4004_556b;
const UI_BEGIN_FF_UPLOAD: libc::c_ulong = 0xc068_55c8;
const UI_END_FF_UPLOAD: libc::c_ulong = 0x4068_55c9;
const UI_BEGIN_FF_ERASE: libc::c_ulong = 0xc00c_55ca;
const UI_END_FF_ERASE: libc::c_ulong = 0x400c_55cb;
// Event types/codes.
const EV_SYN: u16 = 0x00;
const EV_KEY: u16 = 0x01;
const EV_ABS: u16 = 0x03;
const EV_FF: u16 = 0x15;
const EV_UINPUT: u16 = 0x0101;
const SYN_REPORT: u16 = 0;
const UI_FF_UPLOAD: u16 = 1;
const UI_FF_ERASE: u16 = 2;
const FF_RUMBLE: u16 = 0x50;
const FF_GAIN: u16 = 0x60;
const ABS_X: u16 = 0x00;
const ABS_Y: u16 = 0x01;
const ABS_Z: u16 = 0x02;
const ABS_RX: u16 = 0x03;
const ABS_RY: u16 = 0x04;
const ABS_RZ: u16 = 0x05;
const ABS_HAT0X: u16 = 0x10;
const ABS_HAT0Y: u16 = 0x11;
const BTN_SOUTH: u16 = 0x130; // A
const BTN_EAST: u16 = 0x131; // B
const BTN_NORTH: u16 = 0x133; // X (kernel calls it BTN_NORTH/BTN_X)
const BTN_WEST: u16 = 0x134; // Y
const BTN_TL: u16 = 0x136;
const BTN_TR: u16 = 0x137;
const BTN_SELECT: u16 = 0x13a;
const BTN_START: u16 = 0x13b;
const BTN_MODE: u16 = 0x13c;
const BTN_THUMBL: u16 = 0x13d;
const BTN_THUMBR: u16 = 0x13e;
/// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead.
const BUTTON_MAP: [(u32, u16); 11] = [
(gamepad::BTN_A, BTN_SOUTH),
(gamepad::BTN_B, BTN_EAST),
(gamepad::BTN_X, BTN_NORTH),
(gamepad::BTN_Y, BTN_WEST),
(gamepad::BTN_LB, BTN_TL),
(gamepad::BTN_RB, BTN_TR),
(gamepad::BTN_BACK, BTN_SELECT),
(gamepad::BTN_START, BTN_START),
(gamepad::BTN_GUIDE, BTN_MODE),
(gamepad::BTN_LS_CLK, BTN_THUMBL),
(gamepad::BTN_RS_CLK, BTN_THUMBR),
];
#[repr(C)]
struct InputId {
bustype: u16,
vendor: u16,
product: u16,
version: u16,
}
#[repr(C)]
struct UinputSetup {
id: InputId,
name: [u8; 80],
ff_effects_max: u32,
}
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct AbsInfo {
value: i32,
minimum: i32,
maximum: i32,
fuzz: i32,
flat: i32,
resolution: i32,
}
#[repr(C)]
struct UinputAbsSetup {
code: u16,
_pad: u16,
absinfo: AbsInfo,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct InputEventRaw {
time: libc::timeval,
type_: u16,
code: u16,
value: i32,
}
/// `struct ff_effect` (48 bytes; the union starts 8-aligned at offset 16).
#[repr(C)]
#[derive(Clone, Copy)]
struct FfEffect {
type_: u16,
id: i16,
direction: u16,
trigger_button: u16,
trigger_interval: u16,
replay_length: u16,
replay_delay: u16,
_pad: u16,
/// Union; for `FF_RUMBLE`: `u16 strong_magnitude` at [0..2], `u16 weak_magnitude` at [2..4].
u: [u8; 32],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct UinputFfUpload {
request_id: u32,
retval: i32,
effect: FfEffect,
old: FfEffect,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct UinputFfErase {
request_id: u32,
retval: i32,
effect_id: u32,
}
// Layouts verified by compiling a probe against this generation's <linux/uinput.h> (x86_64).
const _: () = {
assert!(std::mem::size_of::<UinputSetup>() == 92);
assert!(std::mem::size_of::<UinputAbsSetup>() == 28);
assert!(std::mem::size_of::<InputEventRaw>() == 24);
assert!(std::mem::size_of::<FfEffect>() == 48);
assert!(std::mem::size_of::<UinputFfUpload>() == 104);
assert!(std::mem::size_of::<UinputFfErase>() == 12);
};
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
bail!("{what}: {}", std::io::Error::last_os_error());
}
Ok(())
}
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
bail!("{what}: {}", std::io::Error::last_os_error());
}
Ok(())
}
/// One FF effect a game uploaded: rumble magnitudes + playback state.
struct Effect {
strong: u16,
weak: u16,
/// `Some(deadline)` while playing (replay length 0 = until stopped).
playing: Option<Option<Instant>>,
replay_ms: u16,
}
/// One virtual X-Box-360 pad backed by a uinput device.
pub struct VirtualPad {
fd: OwnedFd,
prev_buttons: u32,
effects: HashMap<i16, Effect>,
next_effect_id: i16,
gain: u32,
/// Last `(low, high)` reported, to dedup.
last_mix: (u16, u16),
}
impl VirtualPad {
pub fn create(index: usize) -> Result<VirtualPad> {
use std::os::fd::FromRawFd;
let raw = unsafe {
libc::open(
c"/dev/uinput".as_ptr(),
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if raw < 0 {
bail!(
"open /dev/uinput: {} (install the udev rule granting the 'input' group access \
— see scripts/60-punktfunk.rules — and add the user to the 'input' group)",
std::io::Error::last_os_error()
);
}
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
ioctl_int(raw, UI_SET_EVBIT, EV_ABS as i32, "UI_SET_EVBIT(EV_ABS)")?;
ioctl_int(raw, UI_SET_EVBIT, EV_FF as i32, "UI_SET_EVBIT(EV_FF)")?;
for (_, key) in BUTTON_MAP {
ioctl_int(raw, UI_SET_KEYBIT, key as i32, "UI_SET_KEYBIT")?;
}
ioctl_int(
raw,
UI_SET_FFBIT,
FF_RUMBLE as i32,
"UI_SET_FFBIT(FF_RUMBLE)",
)?;
ioctl_int(raw, UI_SET_FFBIT, FF_GAIN as i32, "UI_SET_FFBIT(FF_GAIN)")?;
let stick = AbsInfo {
minimum: -32768,
maximum: 32767,
fuzz: 16,
flat: 128,
..Default::default()
};
let trigger = AbsInfo {
minimum: 0,
maximum: 255,
..Default::default()
};
let hat = AbsInfo {
minimum: -1,
maximum: 1,
..Default::default()
};
for (code, info) in [
(ABS_X, stick),
(ABS_Y, stick),
(ABS_RX, stick),
(ABS_RY, stick),
(ABS_Z, trigger),
(ABS_RZ, trigger),
(ABS_HAT0X, hat),
(ABS_HAT0Y, hat),
] {
let mut a = UinputAbsSetup {
code,
_pad: 0,
absinfo: info,
};
ioctl_ptr(raw, UI_ABS_SETUP, &mut a, "UI_ABS_SETUP")?;
}
// The xpad identity: SDL keys its built-in mapping off bustype/vendor/product/version.
let mut setup = UinputSetup {
id: InputId {
bustype: 0x0003, // BUS_USB
vendor: 0x045e,
product: 0x028e,
version: 0x0110,
},
name: [0; 80],
ff_effects_max: 16, // must be > 0 or FF uploads are never delivered
};
let name = b"Microsoft X-Box 360 pad";
setup.name[..name.len()].copy_from_slice(name);
ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?;
ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?;
tracing::info!(index, "virtual gamepad created (X-Box 360 pad via uinput)");
Ok(VirtualPad {
fd,
prev_buttons: 0,
effects: HashMap::new(),
next_effect_id: 0,
gain: 0xFFFF,
last_mix: (0, 0),
})
}
fn emit(&self, type_: u16, code: u16, value: i32) {
let ev = InputEventRaw {
time: libc::timeval {
tv_sec: 0,
tv_usec: 0,
},
type_,
code,
value,
};
let bytes = unsafe {
std::slice::from_raw_parts(
&ev as *const _ as *const u8,
std::mem::size_of::<InputEventRaw>(),
)
};
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
let _ = unsafe {
libc::write(
self.fd.as_raw_fd(),
bytes.as_ptr() as *const libc::c_void,
bytes.len(),
)
};
}
/// Apply one decoded frame: button transitions, axes, D-pad hat, one SYN_REPORT.
pub fn apply(&mut self, f: &GamepadFrame) {
let changed = self.prev_buttons ^ f.buttons;
for (bit, key) in BUTTON_MAP {
if changed & bit != 0 {
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
}
}
self.prev_buttons = f.buttons;
// Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow).
self.emit(EV_ABS, ABS_X, f.ls_x as i32);
self.emit(EV_ABS, ABS_Y, -(f.ls_y as i32));
self.emit(EV_ABS, ABS_RX, f.rs_x as i32);
self.emit(EV_ABS, ABS_RY, -(f.rs_y as i32));
self.emit(EV_ABS, ABS_Z, f.left_trigger as i32);
self.emit(EV_ABS, ABS_RZ, f.right_trigger as i32);
let hat_x = ((f.buttons & gamepad::BTN_DPAD_RIGHT != 0) as i32)
- ((f.buttons & gamepad::BTN_DPAD_LEFT != 0) as i32);
let hat_y = ((f.buttons & gamepad::BTN_DPAD_DOWN != 0) as i32)
- ((f.buttons & gamepad::BTN_DPAD_UP != 0) as i32);
self.emit(EV_ABS, ABS_HAT0X, hat_x);
self.emit(EV_ABS, ABS_HAT0Y, hat_y);
self.emit(EV_SYN, SYN_REPORT, 0);
}
/// Service the FF protocol on this pad's fd (non-blocking). Returns the new mixed
/// `(low, high)` motor levels if they changed since last call.
fn pump_ff(&mut self) -> Option<(u16, u16)> {
let raw = self.fd.as_raw_fd();
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
loop {
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n != buf.len() as isize {
break; // EAGAIN / short read — queue drained
}
let ev: InputEventRaw = unsafe { std::ptr::read(buf.as_ptr() as *const _) };
match (ev.type_, ev.code) {
(EV_UINPUT, UI_FF_UPLOAD) => {
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
up.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
let mut e = up.effect;
if e.id == -1 {
e.id = self.next_effect_id;
self.next_effect_id = self.next_effect_id.wrapping_add(1);
}
if e.type_ == FF_RUMBLE {
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
let slot = self.effects.entry(e.id).or_insert(Effect {
strong: 0,
weak: 0,
playing: None,
replay_ms: 0,
});
slot.strong = strong;
slot.weak = weak;
slot.replay_ms = e.replay_length;
}
up.effect.id = e.id; // hand the assigned slot back to the kernel
up.retval = 0;
let _ = ioctl_ptr(raw, UI_END_FF_UPLOAD, &mut up, "UI_END_FF_UPLOAD");
}
}
(EV_UINPUT, UI_FF_ERASE) => {
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
er.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
self.effects.remove(&(er.effect_id as i16));
er.retval = 0;
let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE");
}
}
(EV_FF, FF_GAIN) => self.gain = (ev.value as u32).min(0xFFFF),
(EV_FF, code) => {
if let Some(e) = self.effects.get_mut(&(code as i16)) {
e.playing = if ev.value != 0 {
Some((e.replay_ms > 0).then(|| {
Instant::now()
+ std::time::Duration::from_millis(e.replay_ms as u64)
}))
} else {
None
};
}
}
_ => {}
}
}
// Mix: sum playing effects (expiring finished ones), scale by gain.
let now = Instant::now();
let (mut strong, mut weak) = (0u32, 0u32);
for e in self.effects.values_mut() {
if let Some(deadline) = e.playing {
if deadline.is_some_and(|d| now >= d) {
e.playing = None;
} else {
strong = strong.saturating_add(e.strong as u32);
weak = weak.saturating_add(e.weak as u32);
}
}
}
// Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor.
let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16;
let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16;
(self.last_mix != (low, high)).then(|| {
self.last_mix = (low, high);
(low, high)
})
}
}
impl Drop for VirtualPad {
fn drop(&mut self) {
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
}
}
/// All virtual pads of a session, driven from decoded controller events.
#[derive(Default)]
pub struct GamepadManager {
pads: Vec<Option<VirtualPad>>,
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events.
broken: bool,
}
impl GamepadManager {
pub fn new() -> GamepadManager {
GamepadManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
broken: false,
}
}
/// Handle one decoded controller event (create/destroy by mask, then apply state).
pub fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
use crate::gamestream::gamepad::GamepadEvent;
match ev {
GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival");
self.ensure(*index as usize);
}
GamepadEvent::State(f) => {
let idx = f.index as usize;
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
for (i, slot) in self.pads.iter_mut().enumerate() {
if slot.is_some() && f.active_mask & (1 << i) == 0 {
tracing::info!(index = i, "controller unplugged");
*slot = None;
}
}
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
self.ensure(idx);
if let Some(pad) = self.pads[idx].as_mut() {
pad.apply(f);
}
}
}
}
fn ensure(&mut self, idx: usize) {
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
return;
}
match VirtualPad::create(idx) {
Ok(p) => self.pads[idx] = Some(p),
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
self.broken = true;
}
}
}
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
for (i, slot) in self.pads.iter_mut().enumerate() {
if let Some(pad) = slot {
if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high);
}
}
}
}
}
+409
View File
@@ -0,0 +1,409 @@
//! libei input injection — the portable EI-sender path.
//!
//! Two ways to reach an EIS server ([`EiSource`]):
//! * **Portal** — `org.freedesktop.portal.RemoteDesktop` via `ashpd` (KWin, GNOME/Mutter),
//! which hands us the EIS socket fd after the session grant.
//! * **Socket** — connect directly to a compositor's own EIS socket. gamescope runs an EIS
//! server and exports its path to its children as `LIBEI_SOCKET`; our gamescope backend
//! relays that path through a file so the injector can connect (no portal involved).
//!
//! Either way, `reis` drives the connection as an EI *sender*: bind the seat's
//! pointer/keyboard/scroll/button capabilities and, per device, `start_emulating` → emit →
//! `frame`. The session and the EIS connection must stay alive and the event stream must be
//! polled continuously (resume/pause/ping/modifier traffic), so the whole thing runs on a
//! dedicated thread with its own tokio runtime; the synchronous control thread reaches it
//! through an unbounded channel and [`LibeiInjector::inject`] merely enqueues.
//!
//! Keyboard codes are Linux evdev (the same space our VK→evdev table produces) and the
//! compositor supplies the keymap, so — unlike the wlr path — there is no keymap to upload and
//! no modifier mask to serialize: pressing the modifier *keys* (which Moonlight sends as normal
//! key events) is enough.
use super::{gs_button_to_evdev, vk_to_evdev, InputInjector};
use anyhow::{anyhow, Result};
use ashpd::desktop::{
remote_desktop::{
ConnectToEISOptions, DeviceType, RemoteDesktop, SelectDevicesOptions, StartOptions,
},
CreateSessionOptions, PersistMode,
};
use futures_util::StreamExt;
use punktfunk_core::input::{InputEvent, InputKind};
use reis::ei;
use reis::event::{DeviceCapability, EiEvent};
use std::os::unix::net::UnixStream;
use std::time::{Duration, Instant};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
const SCROLL_HORIZONTAL: u32 = 1;
/// Where to find the EIS server.
#[derive(Clone, Debug)]
pub enum EiSource {
/// `org.freedesktop.portal.RemoteDesktop` (KWin, GNOME/Mutter).
Portal,
/// A file containing the EIS socket path/name (gamescope's relayed `LIBEI_SOCKET`); polled
/// until it appears, since the compositor may still be starting.
SocketPathFile(std::path::PathBuf),
}
/// Handle held by the control thread; forwards events to the libei worker thread.
pub struct LibeiInjector {
tx: UnboundedSender<InputEvent>,
}
impl LibeiInjector {
pub fn open() -> Result<Self> {
Self::open_with(EiSource::Portal)
}
pub fn open_with(source: EiSource) -> Result<Self> {
let (tx, rx) = unbounded_channel::<InputEvent>();
std::thread::Builder::new()
.name("punktfunk-libei".into())
.spawn(move || worker(rx, source))
.map_err(|e| anyhow!("spawn libei worker thread: {e}"))?;
// Return immediately — the portal/socket handshake must NOT run on the caller's
// (control) thread, or a slow/denied setup would freeze the ENet control stream and
// drop the client. The worker establishes the session asynchronously and logs its
// status; events enqueue until devices resume (a few startup events may be dropped).
Ok(Self { tx })
}
}
impl InputInjector for LibeiInjector {
fn inject(&mut self, event: &InputEvent) -> Result<()> {
self.tx
.send(*event)
.map_err(|_| anyhow!("libei worker thread has exited"))
}
}
/// Worker thread entry: build a tokio runtime and run the session to completion.
fn worker(rx: UnboundedReceiver<InputEvent>, source: EiSource) {
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
tracing::error!(error = %e, "libei: build tokio runtime failed");
return;
}
};
rt.block_on(session_main(rx, source));
}
/// Open the portal/socket + EIS (bounded), then pump events until disconnect or shutdown.
async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
// Keep `_rd`/`_session` bound for the whole loop — dropping the portal session closes the
// EIS connection. Bound the setup so a headless approval dialog (un-bypassed grant) can't
// hang the worker forever.
let (_portal, context, mut events) = match tokio::time::timeout(
Duration::from_secs(30),
connect(source),
)
.await
{
Ok(Ok(t)) => t,
Ok(Err(e)) => {
tracing::error!(error = %format!("{e:#}"), "libei: portal/EIS setup failed");
return;
}
Err(_) => {
tracing::error!(
"libei: EIS setup timed out (headless approval needed / kde-authorized grant not seeded / gamescope socket never appeared)"
);
return;
}
};
tracing::info!("libei: EIS connected — awaiting devices");
let mut state = EiState::new();
loop {
tokio::select! {
ei = events.next() => match ei {
Some(Ok(ev)) => state.handle_ei(ev, &context),
Some(Err(e)) => { tracing::warn!(error = %e, "libei: event stream error"); break; }
None => { tracing::info!("libei: EIS disconnected"); break; }
},
msg = rx.recv() => match msg {
Some(input) => state.inject(&input, &context),
None => { tracing::info!("libei: injector closed — ending session"); break; }
},
}
}
}
/// Tie down the verbose tuple the connect step returns. The portal pair must stay alive for
/// the whole session (dropping it closes the EIS connection); `None` for the direct-socket path.
type Connected = (
Option<(RemoteDesktop, ashpd::desktop::Session<RemoteDesktop>)>,
ei::Context,
reis::tokio::EiConvertEventStream,
);
/// Reach an EIS server per `source` and run the EI sender handshake.
async fn connect(source: EiSource) -> Result<Connected> {
let (portal, stream) = match source {
EiSource::Portal => {
let (rd, session, fd) = connect_portal().await?;
(Some((rd, session)), UnixStream::from(fd))
}
EiSource::SocketPathFile(file) => (None, connect_socket_file(&file).await?),
};
let context = ei::Context::new(stream).map_err(|e| anyhow!("reis EI context: {e}"))?;
let (_conn, events) = context
.handshake_tokio("punktfunk-host", ei::handshake::ContextType::Sender)
.await
.map_err(|e| anyhow!("EI handshake: {e}"))?;
Ok((portal, context, events))
}
/// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd.
async fn connect_portal() -> Result<(
RemoteDesktop,
ashpd::desktop::Session<RemoteDesktop>,
std::os::fd::OwnedFd,
)> {
let rd = RemoteDesktop::new()
.await
.map_err(|e| anyhow!("open RemoteDesktop portal (is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?): {e}"))?;
let session = rd
.create_session(CreateSessionOptions::default())
.await
.map_err(|e| anyhow!("create RemoteDesktop session: {e}"))?;
rd.select_devices(
&session,
SelectDevicesOptions::default()
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
.set_persist_mode(PersistMode::DoNot),
)
.await
.map_err(|e| anyhow!("select_devices: {e}"))?
.response()
.map_err(|e| anyhow!("select_devices response: {e}"))?;
let started = rd
.start(&session, None, StartOptions::default())
.await
.map_err(|e| anyhow!("start RemoteDesktop session: {e}"))?;
let granted = started
.response()
.map_err(|e| anyhow!("RemoteDesktop start denied: {e}"))?;
tracing::info!(devices = ?granted.devices(), "libei: portal granted devices");
let fd = rd
.connect_to_eis(&session, ConnectToEISOptions::default())
.await
.map_err(|e| anyhow!("connect_to_eis (RemoteDesktop portal version < 2?): {e}"))?;
Ok((rd, session, fd))
}
/// Poll `file` for the EIS socket path (the gamescope backend relays `LIBEI_SOCKET` there once
/// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`,
/// mirroring libei's own `LIBEI_SOCKET` semantics.
async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
let path = loop {
match std::fs::read_to_string(file) {
Ok(s) if !s.trim().is_empty() => break s.trim().to_string(),
_ => tokio::time::sleep(Duration::from_millis(300)).await,
}
};
let full = if path.starts_with('/') {
std::path::PathBuf::from(&path)
} else {
let runtime = std::env::var("XDG_RUNTIME_DIR").map_err(|_| {
anyhow!("XDG_RUNTIME_DIR unset (needed to resolve EIS socket '{path}')")
})?;
std::path::Path::new(&runtime).join(&path)
};
tracing::info!(socket = %full.display(), "libei: connecting to EIS socket");
UnixStream::connect(&full).map_err(|e| anyhow!("connect EIS socket {}: {e}", full.display()))
}
/// One EI device and its emulation state.
struct DeviceSlot {
device: reis::event::Device,
/// The device is resumed (allowed to emit). Devices arrive paused and may pause again.
resumed: bool,
/// We have issued `start_emulating` since the last resume.
emulating: bool,
}
/// Tracks bound devices + the serial/sequence/timebase the EI protocol requires.
struct EiState {
devices: Vec<DeviceSlot>,
last_serial: u32,
sequence: u32,
start: Instant,
}
impl EiState {
fn new() -> Self {
Self {
devices: Vec::new(),
last_serial: 0,
sequence: 0,
start: Instant::now(),
}
}
fn now_us(&self) -> u64 {
self.start.elapsed().as_micros() as u64
}
/// Apply a server event: bind capabilities, track devices, and follow resume/pause.
fn handle_ei(&mut self, ev: EiEvent, ctx: &ei::Context) {
match ev {
EiEvent::SeatAdded(e) => {
e.seat.bind_capabilities(
DeviceCapability::Pointer
| DeviceCapability::PointerAbsolute
| DeviceCapability::Keyboard
| DeviceCapability::Scroll
| DeviceCapability::Button,
);
let _ = ctx.flush();
}
EiEvent::DeviceAdded(e) => {
tracing::info!(device = ?e.device.name(), ty = ?e.device.device_type(), "libei: device added");
self.devices.push(DeviceSlot {
device: e.device,
resumed: false,
emulating: false,
});
}
EiEvent::DeviceRemoved(e) => {
self.devices.retain(|d| d.device != e.device);
}
EiEvent::DeviceResumed(e) => {
self.last_serial = e.serial;
if let Some(d) = self.devices.iter_mut().find(|d| d.device == e.device) {
d.resumed = true;
d.emulating = false; // must re-issue start_emulating after a resume
}
}
EiEvent::DevicePaused(e) => {
if let Some(d) = self.devices.iter_mut().find(|d| d.device == e.device) {
d.resumed = false;
d.emulating = false;
}
}
// Informational: the server reports resulting modifier/group state; we don't set it.
EiEvent::KeyboardModifiers(e) => self.last_serial = e.serial,
_ => {}
}
}
/// Index of a resumed device exposing `cap`.
fn device_for(&self, cap: DeviceCapability) -> Option<usize> {
self.devices
.iter()
.position(|d| d.resumed && d.device.has_capability(cap))
}
/// Ensure the device at `idx` is in `start_emulating` state before we emit on it.
fn ensure_emulating(&mut self, idx: usize, dev: &ei::Device) {
if !self.devices[idx].emulating {
dev.start_emulating(self.last_serial, self.sequence);
self.sequence = self.sequence.wrapping_add(1);
self.devices[idx].emulating = true;
}
}
/// Translate and emit one client input event, committing it as a single `frame`.
fn inject(&mut self, ev: &InputEvent, ctx: &ei::Context) {
let cap = match ev.kind {
InputKind::MouseMove => DeviceCapability::Pointer,
InputKind::MouseMoveAbs => DeviceCapability::PointerAbsolute,
InputKind::MouseButtonDown | InputKind::MouseButtonUp => DeviceCapability::Button,
InputKind::MouseScroll => DeviceCapability::Scroll,
InputKind::KeyDown | InputKind::KeyUp => DeviceCapability::Keyboard,
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
};
let Some(idx) = self.device_for(cap) else {
return; // no resumed device with this capability yet
};
let dev = self.devices[idx].device.device().clone();
self.ensure_emulating(idx, &dev);
let mut emitted = true;
let slot = &self.devices[idx].device;
match ev.kind {
InputKind::MouseMove => match slot.interface::<ei::Pointer>() {
Some(p) => p.motion_relative(ev.x as f32, ev.y as f32),
None => emitted = false,
},
InputKind::MouseMoveAbs => {
let w = ((ev.flags >> 16) & 0xffff) as f32;
let h = (ev.flags & 0xffff) as f32;
match (
slot.interface::<ei::PointerAbsolute>(),
slot.regions().first(),
) {
(Some(p), Some(region)) if w > 0.0 && h > 0.0 => {
// Map the normalized client position into the device's first region.
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
let x = region.x as f32 + nx * region.width as f32;
let y = region.y as f32 + ny * region.height as f32;
p.motion_absolute(x, y);
}
_ => emitted = false,
}
}
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
match (slot.interface::<ei::Button>(), gs_button_to_evdev(ev.code)) {
(Some(b), Some(btn)) => {
let st = if ev.kind == InputKind::MouseButtonDown {
ei::button::ButtonState::Press
} else {
ei::button::ButtonState::Released
};
b.button(btn, st);
}
_ => emitted = false,
}
}
InputKind::MouseScroll => match slot.interface::<ei::Scroll>() {
Some(s) => {
// GameStream sends WHEEL_DELTA(120)-scaled deltas in `x`; ei scroll_discrete
// uses the same 120-per-detent unit. Positive GameStream = up (vertical),
// which is negative on the ei axis, but = RIGHT (horizontal), which is
// already positive there (moonlight-qt/Sunshine pass horizontal through
// unnegated) — only the vertical axis flips.
if ev.code == SCROLL_HORIZONTAL {
s.scroll_discrete(ev.x, 0);
} else {
s.scroll_discrete(0, -ev.x);
}
}
None => emitted = false,
},
InputKind::KeyDown | InputKind::KeyUp => {
match (slot.interface::<ei::Keyboard>(), vk_to_evdev(ev.code as u8)) {
(Some(k), Some(evdev)) => {
let st = if ev.kind == InputKind::KeyDown {
ei::keyboard::KeyState::Press
} else {
ei::keyboard::KeyState::Released
};
k.key(evdev as u32, st);
}
_ => {
emitted = false;
tracing::debug!(vk = ev.code, "libei: unmapped VK keycode — dropped");
}
}
}
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false,
}
if emitted {
dev.frame(self.last_serial, self.now_us());
}
let _ = ctx.flush();
}
}
+273
View File
@@ -0,0 +1,273 @@
//! Input injection through the wlroots virtual-input Wayland protocols
//! (`zwlr_virtual_pointer_manager_v1` + `zwp_virtual_keyboard_manager_v1`) — the headless-Sway
//! path. We connect as an ordinary Wayland client (the host inherits Sway's
//! `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, upload a standard evdev/US xkb
//! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state
//! so the compositor resolves shifted keysyms correctly.
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{bail, Context, Result};
use punktfunk_core::input::InputKind;
use std::io::Write;
use std::os::fd::{AsFd, FromRawFd};
use std::time::Instant;
use wayland_client::protocol::{wl_output::WlOutput, wl_pointer, wl_registry, wl_seat::WlSeat};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
};
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
};
use xkbcommon::xkb;
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
const SCROLL_HORIZONTAL: u32 = 1;
/// Globals bound from the registry (the Wayland dispatch state).
#[derive(Default)]
struct Globals {
pointer_mgr: Option<ZwlrVirtualPointerManagerV1>,
keyboard_mgr: Option<ZwpVirtualKeyboardManagerV1>,
seat: Option<WlSeat>,
output: Option<WlOutput>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
match interface.as_str() {
"zwlr_virtual_pointer_manager_v1" => {
state.pointer_mgr = Some(registry.bind(name, version.min(2), qh, ()));
}
"zwp_virtual_keyboard_manager_v1" => {
state.keyboard_mgr = Some(registry.bind(name, version.min(1), qh, ()));
}
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"wl_output" if state.output.is_none() => {
state.output = Some(registry.bind(name, version.min(3), qh, ()));
}
_ => {}
}
}
}
}
// The managers, the two virtual devices, the seat and the output emit no events we use.
macro_rules! ignore_events {
($($t:ty),* $(,)?) => {$(
impl Dispatch<$t, ()> for Globals {
fn event(_: &mut Self, _: &$t, _: <$t as Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
)*};
}
ignore_events!(
WlSeat,
WlOutput,
ZwlrVirtualPointerManagerV1,
ZwlrVirtualPointerV1,
ZwpVirtualKeyboardManagerV1,
ZwpVirtualKeyboardV1,
);
pub struct WlrootsInjector {
conn: Connection,
queue: EventQueue<Globals>,
globals: Globals,
pointer: ZwlrVirtualPointerV1,
keyboard: ZwpVirtualKeyboardV1,
xkb_state: xkb::State,
_keymap_file: std::fs::File, // keep the memfd alive for the compositor's mmap
start: Instant,
}
impl WlrootsInjector {
pub fn open() -> Result<Self> {
let conn = Connection::connect_to_env()
.context("connect to Wayland (is Sway up + WAYLAND_DISPLAY/XDG_RUNTIME_DIR set?)")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut globals = Globals::default();
queue
.roundtrip(&mut globals)
.context("Wayland registry roundtrip")?;
let pointer_mgr = globals
.pointer_mgr
.clone()
.context("compositor lacks zwlr_virtual_pointer_manager_v1")?;
let keyboard_mgr = globals
.keyboard_mgr
.clone()
.context("compositor lacks zwp_virtual_keyboard_manager_v1")?;
let seat = globals
.seat
.clone()
.context("compositor advertised no wl_seat")?;
let pointer = pointer_mgr.create_virtual_pointer_with_output(
Some(&seat),
globals.output.as_ref(),
&qh,
(),
);
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
// A standard evdev/US keymap so raw evdev keycodes resolve to the right keysyms.
let ctx = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let keymap = xkb::Keymap::new_from_names(
&ctx,
"evdev",
"pc105",
"us",
"",
None,
xkb::KEYMAP_COMPILE_NO_FLAGS,
)
.context("compile xkb keymap")?;
let keymap_str = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1);
let xkb_state = xkb::State::new(&keymap);
let file = memfd_with(&keymap_str)?;
let size = keymap_str.len() as u32 + 1; // include the trailing NUL
keyboard.keymap(1 /* XKB_V1 */, file.as_fd(), size);
queue
.roundtrip(&mut globals)
.context("keymap upload roundtrip")?;
conn.flush().ok();
tracing::info!(
output = globals.output.is_some(),
"wlroots virtual input ready (pointer + keyboard)"
);
Ok(Self {
conn,
queue,
globals,
pointer,
keyboard,
xkb_state,
_keymap_file: file,
start: Instant::now(),
})
}
fn now_ms(&self) -> u32 {
self.start.elapsed().as_millis() as u32
}
/// Update xkb state for a key and tell the compositor the resulting modifier mask.
fn send_modifiers(&mut self, evdev: u16, down: bool) {
let kc = xkb::Keycode::new(evdev as u32 + 8); // evdev -> xkb keycode
let dir = if down {
xkb::KeyDirection::Down
} else {
xkb::KeyDirection::Up
};
self.xkb_state.update_key(kc, dir);
let depressed = self.xkb_state.serialize_mods(xkb::STATE_MODS_DEPRESSED);
let latched = self.xkb_state.serialize_mods(xkb::STATE_MODS_LATCHED);
let locked = self.xkb_state.serialize_mods(xkb::STATE_MODS_LOCKED);
let group = self.xkb_state.serialize_layout(xkb::STATE_LAYOUT_EFFECTIVE);
self.keyboard.modifiers(depressed, latched, locked, group);
}
}
impl InputInjector for WlrootsInjector {
fn inject(&mut self, event: &InputEvent) -> Result<()> {
let t = self.now_ms();
match event.kind {
InputKind::MouseMove => {
self.pointer.motion(t, event.x as f64, event.y as f64);
self.pointer.frame();
}
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 u32;
let y = event.y.clamp(0, h as i32) as u32;
self.pointer.motion_absolute(t, x, y, w, h);
self.pointer.frame();
}
}
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
if let Some(btn) = gs_button_to_evdev(event.code) {
let st = if event.kind == InputKind::MouseButtonDown {
wl_pointer::ButtonState::Pressed
} else {
wl_pointer::ButtonState::Released
};
self.pointer.button(t, btn, st);
self.pointer.frame();
}
}
InputKind::MouseScroll => {
let axis = if event.code == SCROLL_HORIZONTAL {
wl_pointer::Axis::HorizontalScroll
} else {
wl_pointer::Axis::VerticalScroll
};
// GameStream sends WHEEL_DELTA(120)-scaled units; a notch ≈ 15px. Positive
// GameStream = up (vertical), negative on the Wayland axis; but = RIGHT
// (horizontal), already positive there (moonlight-qt/Sunshine pass
// horizontal through unnegated) — only the vertical axis flips.
let notches = event.x as f64 / 120.0;
let sign = if event.code == SCROLL_HORIZONTAL {
1.0
} else {
-1.0
};
self.pointer.axis_source(wl_pointer::AxisSource::Wheel);
self.pointer.axis(t, axis, sign * notches * 15.0);
self.pointer.frame();
}
InputKind::KeyDown | InputKind::KeyUp => {
let down = event.kind == InputKind::KeyDown;
if let Some(evdev) = vk_to_evdev(event.code as u8) {
self.keyboard.key(t, evdev as u32, if down { 1 } else { 0 });
self.send_modifiers(evdev, down);
} else {
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
}
}
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
}
// Surface protocol errors / disconnects, then push the batch to the compositor.
self.queue
.dispatch_pending(&mut self.globals)
.context("wayland dispatch")?;
self.conn.flush().context("wayland flush")?;
Ok(())
}
}
/// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd).
fn memfd_with(s: &str) -> Result<std::fs::File> {
let name = b"punktfunk-keymap\0";
let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) };
if fd < 0 {
bail!("memfd_create failed: {}", std::io::Error::last_os_error());
}
let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
f.write_all(s.as_bytes()).context("write keymap")?;
f.write_all(&[0]).context("write keymap NUL")?;
Ok(f)
}