Compare commits
5 Commits
5a2e07e865
...
a4833e4780
| Author | SHA1 | Date | |
|---|---|---|---|
| a4833e4780 | |||
| 4e79e6cdad | |||
| f74bc4a3f1 | |||
| 8e18d01af5 | |||
| 3477cbe7ce |
@@ -19,6 +19,12 @@ data class Settings(
|
||||
val micEnabled: Boolean = false,
|
||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||
val statsHudEnabled: Boolean = true,
|
||||
/**
|
||||
* Touch input model. `true` (default) = trackpad: the cursor stays put on touch-down and moves
|
||||
* by the finger's relative delta (swipe to nudge, lift and re-swipe to walk it across), tap to
|
||||
* click where it is. `false` = direct pointing: the cursor jumps to the finger (the old behaviour).
|
||||
*/
|
||||
val trackpadMode: Boolean = true,
|
||||
)
|
||||
|
||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||
@@ -35,6 +41,7 @@ class SettingsStore(context: Context) {
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -47,6 +54,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.putBoolean(K_TRACKPAD, s.trackpadMode)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -59,6 +67,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,16 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Pointer") {
|
||||
ToggleRow(
|
||||
title = "Trackpad mode",
|
||||
subtitle = "Relative cursor like a laptop touchpad — swipe to nudge, tap to click. " +
|
||||
"Off = the cursor jumps to your finger.",
|
||||
checked = s.trackpadMode,
|
||||
onCheckedChange = { on -> update(s.copy(trackpadMode = on)) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Overlay") {
|
||||
ToggleRow(
|
||||
title = "Stats overlay",
|
||||
|
||||
@@ -41,6 +41,7 @@ import io.unom.punktfunk.kit.NativeBridge
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
|
||||
@@ -50,6 +51,15 @@ private const val TAP_SLOP = 12f
|
||||
private const val TAP_DRAG_MS = 250L
|
||||
private const val SCROLL_DIV = 4f
|
||||
|
||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||
// px/ms, capped at ACCEL_MAX (so a fast swipe can't fling the cursor uncontrollably).
|
||||
private const val POINTER_SENS = 1.3f
|
||||
private const val ACCEL_GAIN = 0.6f
|
||||
private const val ACCEL_SPEED_FLOOR = 0.3f
|
||||
private const val ACCEL_MAX = 3.0f
|
||||
|
||||
@Composable
|
||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
@@ -68,8 +78,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
|
||||
// drains+resets the native window so it never grows unbounded even while the overlay is hidden);
|
||||
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var showStats by remember { mutableStateOf(SettingsStore(context).load().statsHudEnabled) }
|
||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
val trackpad = initialSettings.trackpadMode
|
||||
LaunchedEffect(handle) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
@@ -145,13 +158,18 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
}
|
||||
// Touch → mouse, absolute "direct pointing" like the Apple client: the host cursor follows
|
||||
// your finger (MouseMoveAbs, host-normalized against the overlay size — which fills the video,
|
||||
// so finger position maps straight onto the remote screen). Gestures: tap = left click;
|
||||
// two-finger tap = right click; two-finger drag = scroll; tap-then-press-and-drag = left-drag
|
||||
// (text selection / moving windows); three-finger tap = toggle the stats HUD.
|
||||
// Touch → mouse. Two models, chosen by the Trackpad-mode setting:
|
||||
// • trackpad (default): the cursor STAYS where it is on touch-down and moves by the finger's
|
||||
// relative delta (MouseMove) with mild pointer acceleration — swipe to nudge, lift and
|
||||
// re-swipe to walk it across, tap to click where it is. This is what makes the cursor
|
||||
// reachable on a small screen.
|
||||
// • direct (opt-out): the cursor jumps to the finger and follows it (MouseMoveAbs,
|
||||
// host-normalized against the overlay size), the old "direct pointing" behaviour.
|
||||
// Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
// two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
// windows); three-finger tap = toggle the stats HUD.
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle) {
|
||||
Modifier.fillMaxSize().pointerInput(handle, trackpad) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
var lastTapY = 0f
|
||||
@@ -176,7 +194,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
|
||||
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
|
||||
lastTapUp = 0L // consume the arming either way
|
||||
moveAbs(startX, startY) // cursor jumps to the finger immediately
|
||||
// Direct mode jumps the cursor to the finger; trackpad mode leaves it put (the
|
||||
// whole point — you nudge it with swipes instead).
|
||||
if (!trackpad) moveAbs(startX, startY)
|
||||
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
|
||||
var moved = false
|
||||
@@ -185,6 +205,14 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
var prevCx = startX
|
||||
var prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
// Trackpad relative-motion state: the tracked finger, its last position/time, and
|
||||
// the sub-pixel remainder so a slow drag isn't lost to Int truncation.
|
||||
var trackId = down.id
|
||||
var prevX = startX
|
||||
var prevY = startY
|
||||
var prevT = down.uptimeMillis
|
||||
var accX = 0f
|
||||
var accY = 0f
|
||||
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
@@ -217,15 +245,46 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
moved = true
|
||||
}
|
||||
} else if (!scrolling) {
|
||||
// One finger → the cursor follows it (skipped once a gesture turned into
|
||||
// a scroll, so dropping back to one finger doesn't jerk the cursor).
|
||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||
// back to one finger doesn't jerk the cursor).
|
||||
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
|
||||
if (abs(p.position.x - startX) > TAP_SLOP ||
|
||||
abs(p.position.y - startY) > TAP_SLOP
|
||||
) {
|
||||
moved = true
|
||||
}
|
||||
moveAbs(p.position.x, p.position.y)
|
||||
if (trackpad) {
|
||||
// Relative: move by the finger delta × (sensitivity × acceleration),
|
||||
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
|
||||
// if the tracked finger changed, so lifting one of several fingers
|
||||
// never jumps the cursor.
|
||||
if (p.id != trackId) {
|
||||
trackId = p.id
|
||||
prevX = p.position.x
|
||||
prevY = p.position.y
|
||||
prevT = p.uptimeMillis
|
||||
}
|
||||
val dx = p.position.x - prevX
|
||||
val dy = p.position.y - prevY
|
||||
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
|
||||
prevX = p.position.x
|
||||
prevY = p.position.y
|
||||
prevT = p.uptimeMillis
|
||||
val speed = hypot(dx, dy) / dt // finger px per ms
|
||||
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
|
||||
.coerceAtMost(ACCEL_MAX)
|
||||
accX += dx * POINTER_SENS * accel
|
||||
accY += dy * POINTER_SENS * accel
|
||||
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
|
||||
val outY = accY.toInt()
|
||||
if (outX != 0 || outY != 0) {
|
||||
NativeBridge.nativeSendPointerMove(handle, outX, outY)
|
||||
accX -= outX
|
||||
accY -= outY
|
||||
}
|
||||
} else {
|
||||
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
|
||||
}
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
@@ -239,7 +298,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||
}
|
||||
else -> { // tap → left click, and arm tap-and-drag
|
||||
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
||||
lastTapUp = upTime
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
//! Android audio playback (android-only): pull Opus packets from the connector, decode to
|
||||
//! interleaved f32 stereo, and feed AAudio (LowLatency) via its realtime data callback through a
|
||||
//! jitter ring. Mirrors [`crate::decode`]: one thread we own (the Opus decode producer) plus a
|
||||
//! shutdown flag; the realtime callback thread is owned by AAudio. Ring logic ported from
|
||||
//! `punktfunk-client-linux/src/audio.rs` (prime ~3 quanta, drop-oldest cap, re-prime on drain).
|
||||
//! shutdown flag; the realtime callback thread is owned by AAudio.
|
||||
//!
|
||||
//! The ring started as a port of `punktfunk-client-linux/src/audio.rs`, but AAudio — unlike
|
||||
//! PipeWire, which adaptively rate-matches the stream and absorbs a shallow buffer — hands us a raw
|
||||
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
|
||||
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
|
||||
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
|
||||
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
|
||||
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
|
||||
//! grown on XRuns (Google's anti-glitch technique).
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
|
||||
@@ -13,7 +22,7 @@ use punktfunk_core::error::PunktfunkError;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
|
||||
use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -24,6 +33,29 @@ const RING_CHUNKS: usize = 64;
|
||||
/// Opus decode scratch: worst-case 120 ms stereo frame (5760 samples/ch × 2 ch).
|
||||
const PCM_SCRATCH: usize = 5760 * CHANNELS;
|
||||
|
||||
// --- Jitter-ring depths, in interleaved-f32 samples (all expressed in ms via `MS`). -----------
|
||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||
/// Interleaved f32 samples per millisecond (48 kHz × 2 ch).
|
||||
const MS: usize = (SAMPLE_RATE as usize / 1000) * CHANNELS; // 96
|
||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||
const PRIME_FLOOR: usize = 40 * MS;
|
||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||
const PRIME_CEIL: usize = 80 * MS;
|
||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||
/// without overflowing.
|
||||
const JITTER_HEADROOM: usize = 80 * MS;
|
||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||
const HARD_CAP: usize = 150 * MS;
|
||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||
const XRUN_CHECK_EVERY: u32 = 128;
|
||||
|
||||
/// Diagnostics — written by the decode thread + the realtime callback, logged periodically. The
|
||||
/// audio analogue of the video `fed`/`rendered` counters (we can't "screenshot" sound).
|
||||
#[derive(Default)]
|
||||
@@ -47,22 +79,41 @@ impl AudioPlayback {
|
||||
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
|
||||
let counters = Arc::new(Counters::default());
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
// Recycle free-list: drained PCM buffers go BACK to the decode thread to be refilled, so the
|
||||
// realtime callback never frees heap (Android's Scudo allocator has unbounded free() tail
|
||||
// latency — a free on the audio thread is an XRun = a click) and the decode thread rarely
|
||||
// allocates. Same depth as the data channel.
|
||||
let (free_tx, free_rx) = sync_channel::<Vec<f32>>(RING_CHUNKS);
|
||||
|
||||
// Realtime consumer state, owned by the callback (FnMut) — no lock: AAudio calls it from a
|
||||
// single high-priority thread, and the decode thread only touches `tx`.
|
||||
// single high-priority thread, and the decode thread only touches `tx`/`free_rx`.
|
||||
let cb_counters = counters.clone();
|
||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(PCM_SCRATCH);
|
||||
// Pre-reserve the ring so `extend` never reallocates on the realtime thread. Worst transient
|
||||
// before the trim below = the hard cap plus one full channel of 5 ms (480-f32) frames — the
|
||||
// punktfunk protocol always sends 5 ms Opus frames (host `audio_thread`); a larger frame
|
||||
// would force a one-time realloc, asserted (not silently corrupted) in `decode_loop`.
|
||||
let mut ring: VecDeque<f32> = VecDeque::with_capacity(HARD_CAP + RING_CHUNKS * 5 * MS);
|
||||
let mut primed = false;
|
||||
let callback = move |_s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
let want = num_frames as usize * CHANNELS;
|
||||
// SAFETY: AAudio provides `num_frames * channel_count` F32 slots at `data`.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(data as *mut f32, want) };
|
||||
while let Ok(chunk) = rx.try_recv() {
|
||||
ring.extend(chunk);
|
||||
// Drain decoded chunks into the ring WITHOUT freeing on the RT thread: `drain(..)` empties
|
||||
// each Vec but keeps its capacity, then the empty buffer is handed back for reuse. The
|
||||
// only RT-thread free is the rare case where the recycle channel is momentarily full.
|
||||
while let Ok(mut chunk) = rx.try_recv() {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// Prime to ~3 quanta (15 ms; floor 15 ms / ceiling 200 ms); drop OLDEST above the cap.
|
||||
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
|
||||
while ring.len() > target.max(want) + want {
|
||||
// Jitter buffer: prime to ~40 ms (PRIME_FLOOR) before playing and after a sustained drain;
|
||||
// drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst `want` (tiny
|
||||
// on the LowLatency MMAP path) so the depth doesn't collapse to a single quantum.
|
||||
let target = (3 * want).clamp(PRIME_FLOOR, PRIME_CEIL);
|
||||
let hard_cap = (target + JITTER_HEADROOM).min(HARD_CAP);
|
||||
while ring.len() > hard_cap {
|
||||
ring.pop_front();
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
@@ -79,12 +130,34 @@ impl AudioPlayback {
|
||||
out.fill(0.0);
|
||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// Re-prime only after a RUN of empty callbacks, not a single transient one — otherwise
|
||||
// every momentary drain costs a fresh 40 ms silence (the old behaviour, self-inflicted
|
||||
// crackle on any jitter spike).
|
||||
if ring.is_empty() {
|
||||
primed = false; // re-prime after a genuine drain (avoids sustained crackle on loss)
|
||||
empties += 1;
|
||||
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||
primed = false;
|
||||
}
|
||||
} else {
|
||||
empties = 0;
|
||||
}
|
||||
cb_counters
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the HW
|
||||
// buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are both
|
||||
// callback-safe / non-blocking, and set clamps to capacity so it self-limits. Throttled.
|
||||
cb_count = cb_count.wrapping_add(1);
|
||||
if cb_count % XRUN_CHECK_EVERY == 0 {
|
||||
let xr = s.x_run_count();
|
||||
if xr > last_xrun {
|
||||
last_xrun = xr;
|
||||
let burst = s.frames_per_burst().max(1);
|
||||
let grown =
|
||||
(s.buffer_size_in_frames() + burst).min(s.buffer_capacity_in_frames());
|
||||
let _ = s.set_buffer_size_in_frames(grown);
|
||||
}
|
||||
}
|
||||
AudioCallbackResult::Continue
|
||||
};
|
||||
|
||||
@@ -109,19 +182,31 @@ impl AudioPlayback {
|
||||
log::error!("audio: request_start: {e}");
|
||||
return None;
|
||||
}
|
||||
// Lift the AAudio HW buffer off its brittle ~2-burst LowLatency default so a single late
|
||||
// callback doesn't immediately underrun; the in-callback XRun loop grows it further if the
|
||||
// device still glitches. set_buffer_size_in_frames clamps to capacity.
|
||||
let burst = stream.frames_per_burst().max(1);
|
||||
let _ =
|
||||
stream.set_buffer_size_in_frames((burst * 3).min(stream.buffer_capacity_in_frames()));
|
||||
// perf != LowLatency or rate != 48000 means AAudio silently fell to a resampled legacy path
|
||||
// (different burst behaviour) — surface it so the field can tell that apart from plain jitter.
|
||||
log::info!(
|
||||
"audio: AAudio started rate={} ch={} fmt={:?} burst={}",
|
||||
"audio: AAudio started rate={} ch={} fmt={:?} perf={:?} share={:?} burst={} buf={}/{}",
|
||||
stream.sample_rate(),
|
||||
stream.channel_count(),
|
||||
stream.format(),
|
||||
stream.performance_mode(),
|
||||
stream.sharing_mode(),
|
||||
stream.frames_per_burst(),
|
||||
stream.buffer_size_in_frames(),
|
||||
stream.buffer_capacity_in_frames(),
|
||||
);
|
||||
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let sd = shutdown.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-audio".into())
|
||||
.spawn(move || decode_loop(client, tx, sd, counters))
|
||||
.spawn(move || decode_loop(client, tx, free_rx, sd, counters))
|
||||
.ok();
|
||||
|
||||
Some(AudioPlayback {
|
||||
@@ -143,9 +228,12 @@ impl Drop for AudioPlayback {
|
||||
}
|
||||
|
||||
/// Producer: `next_audio` → Opus `decode_float` → push interleaved f32 into the ring channel.
|
||||
/// Buffers come from (and return to) the realtime callback's recycle free-list so the steady state
|
||||
/// is allocation-free on both threads.
|
||||
fn decode_loop(
|
||||
client: Arc<NativeClient>,
|
||||
tx: SyncSender<Vec<f32>>,
|
||||
free_rx: Receiver<Vec<f32>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
counters: Arc<Counters>,
|
||||
) {
|
||||
@@ -166,8 +254,22 @@ fn decode_loop(
|
||||
for &s in &pcm[..n] {
|
||||
window_peak = window_peak.max(s.abs());
|
||||
}
|
||||
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32)
|
||||
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
||||
// future host frame-size change here in debug, not as a silent audio glitch.
|
||||
debug_assert!(
|
||||
n <= 5 * MS,
|
||||
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
||||
);
|
||||
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
match tx.try_send(pcm[..n].to_vec()) {
|
||||
// Reuse a recycled buffer if the callback handed one back; only allocate when the
|
||||
// free-list is momentarily empty (startup / after a backpressure drop).
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(PCM_SCRATCH));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..n]);
|
||||
match tx.try_send(buf) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="fake_input">
|
||||
<copyright>
|
||||
SPDX-FileCopyrightText: 2015 Martin Gräßlin
|
||||
SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
</copyright>
|
||||
<interface name="org_kde_kwin_fake_input" version="4">
|
||||
<description summary="Fake input manager">
|
||||
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.
|
||||
</description>
|
||||
<request name="authenticate">
|
||||
<description summary="Information about the application requesting fake input">
|
||||
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.
|
||||
</description>
|
||||
<arg name="application" type="string" summary="user visible name of the application requesting fake input"/>
|
||||
<arg name="reason" type="string" summary="reason of why fake input is requested"/>
|
||||
</request>
|
||||
<request name="pointer_motion">
|
||||
<description summary="pointer motion event"/>
|
||||
<arg name="delta_x" type="fixed" summary="X delta of the relative pointer motion"/>
|
||||
<arg name="delta_y" type="fixed" summary="Y delta of the relative pointer motion"/>
|
||||
</request>
|
||||
<request name="button">
|
||||
<description summary="pointer button event"/>
|
||||
<arg name="button" type="uint" summary="evdev button code"/>
|
||||
<arg name="state" type="uint" summary="button state, 0 released, 1 pressed"/>
|
||||
</request>
|
||||
<request name="axis">
|
||||
<description summary="pointer axis (scroll) event"/>
|
||||
<arg name="axis" type="uint" summary="wl_pointer.axis (0 vertical, 1 horizontal)"/>
|
||||
<arg name="value" type="fixed" summary="axis value"/>
|
||||
</request>
|
||||
<request name="touch_down" since="2">
|
||||
<description summary="touch down event"/>
|
||||
<arg name="id" type="uint" summary="unique id of this touch point; must not be reused until up"/>
|
||||
<arg name="x" type="fixed" summary="x coordinate in global compositor space"/>
|
||||
<arg name="y" type="fixed" summary="y coordinate in global compositor space"/>
|
||||
</request>
|
||||
<request name="touch_motion" since="2">
|
||||
<description summary="touch motion event"/>
|
||||
<arg name="id" type="uint" summary="unique id of an existing touch point"/>
|
||||
<arg name="x" type="fixed" summary="x coordinate in global compositor space"/>
|
||||
<arg name="y" type="fixed" summary="y coordinate in global compositor space"/>
|
||||
</request>
|
||||
<request name="touch_up" since="2">
|
||||
<description summary="touch up event"/>
|
||||
<arg name="id" type="uint" summary="unique id of an existing touch point"/>
|
||||
</request>
|
||||
<request name="touch_cancel" since="2">
|
||||
<description summary="cancel all current touch points"/>
|
||||
</request>
|
||||
<request name="touch_frame" since="2">
|
||||
<description summary="end a set of touch events (atomic frame)"/>
|
||||
</request>
|
||||
<request name="pointer_motion_absolute" since="3">
|
||||
<description summary="absolute pointer motion event"/>
|
||||
<arg name="x" type="fixed" summary="x coordinate in global compositor space"/>
|
||||
<arg name="y" type="fixed" summary="y coordinate in global compositor space"/>
|
||||
</request>
|
||||
<request name="keyboard_key" since="4">
|
||||
<description summary="keyboard key event"/>
|
||||
<arg name="button" type="uint" summary="evdev key code"/>
|
||||
<arg name="state" type="uint" summary="key state, 0 released, 1 pressed"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -106,7 +106,10 @@ fn capture_thread(
|
||||
}
|
||||
let res = (|| -> Result<()> {
|
||||
// Loopback = capture the RENDER endpoint: get the default render device, but open a CAPTURE
|
||||
// client with loopback=true over it.
|
||||
// client with loopback=true over it. NOTE: the virtual mic (`super::wasapi_mic`) is guarded
|
||||
// to NEVER target this same endpoint — otherwise the client's injected mic would be captured
|
||||
// here and streamed back to the client (infinite echo). Keep that guard in sync if this
|
||||
// device selection ever changes.
|
||||
let device = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Render)
|
||||
|
||||
@@ -5,8 +5,18 @@
|
||||
//!
|
||||
//! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`):
|
||||
//! "Steam Streaming Microphone" (ships with Steam Remote Play — exactly this purpose), VB-Audio
|
||||
//! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none is present we return an
|
||||
//! error with install guidance and the host runs without mic passthrough.
|
||||
//! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none is present we
|
||||
//! auto-install the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we
|
||||
//! return an error with install guidance and the host runs without mic passthrough.
|
||||
//!
|
||||
//! **Anti-echo guard (the whole point of this being non-trivial).** The desktop-audio plane
|
||||
//! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback
|
||||
//! captures the *mixed* output of an endpoint — i.e. everything any app renders to it, including
|
||||
//! what THIS module writes. So if the virtual-mic target is the same device the loopback captures,
|
||||
//! the client's uplinked mic is captured straight back into the host→client audio stream: an
|
||||
//! infinite echo. [`find_device`] therefore **excludes the default render endpoint** from the
|
||||
//! candidates — the mic is guaranteed to land on a different device. (Linux gets this for free: its
|
||||
//! mic is a dedicated `Audio/Source` node, structurally separate from the monitored sink.)
|
||||
//!
|
||||
//! `push` enqueues decoded interleaved-f32 PCM into a bounded ring (drop-oldest beyond ~80 ms so mic
|
||||
//! latency stays bounded); a dedicated COM-apartment thread renders it event-driven, filling silence
|
||||
@@ -113,8 +123,23 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the virtual-mic target among render endpoints by friendly-name. Logs all candidates so a
|
||||
/// missing device is diagnosable.
|
||||
/// The endpoint ID of the device the desktop-audio loopback records (the **default render
|
||||
/// endpoint**, see [`super::wasapi_cap`]). The virtual mic must never target this device — injecting
|
||||
/// there echoes the client's mic back into the host→client audio stream. `None` if it can't be
|
||||
/// resolved (then [`find_device`] can't prove a candidate is safe and falls back to name-only
|
||||
/// matching — no worse than before the guard existed).
|
||||
fn default_render_id() -> Option<String> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.ok()?
|
||||
.get_default_device(&Direction::Render)
|
||||
.ok()?
|
||||
.get_id()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Resolve the virtual-mic target among render endpoints by friendly-name, **excluding the endpoint
|
||||
/// the loopback captures** (the [`default_render_id`] anti-echo guard). Logs all candidates so a
|
||||
/// missing/skipped device is diagnosable.
|
||||
fn find_device() -> Result<wasapi::Device> {
|
||||
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
|
||||
let collection = enumerator
|
||||
@@ -124,8 +149,11 @@ fn find_device() -> Result<wasapi::Device> {
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
// The device the loopback captures — a name match on it is rejected below (would echo).
|
||||
let loopback_id = default_render_id();
|
||||
let mut names = Vec::new();
|
||||
let mut found = None;
|
||||
let mut skipped_loopback = false;
|
||||
for i in 0..n {
|
||||
let Ok(dev) = collection.get_device_at_index(i) else {
|
||||
continue;
|
||||
@@ -137,16 +165,37 @@ fn find_device() -> Result<wasapi::Device> {
|
||||
None => CANDIDATES.iter().any(|c| lname.contains(c)),
|
||||
};
|
||||
if hit && found.is_none() {
|
||||
found = Some(dev);
|
||||
// Anti-echo guard: never inject into the endpoint the loopback captures.
|
||||
let is_loopback = match (dev.get_id().ok(), loopback_id.as_deref()) {
|
||||
(Some(id), Some(lb)) => id == lb,
|
||||
_ => false,
|
||||
};
|
||||
if is_loopback {
|
||||
skipped_loopback = true;
|
||||
tracing::warn!(device = %name,
|
||||
"virtual-mic candidate is the loopback (default render) endpoint — skipping; \
|
||||
injecting there would echo the client's mic into the desktop-audio stream");
|
||||
} else {
|
||||
found = Some(dev);
|
||||
}
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
found.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no virtual-mic device among render endpoints {names:?}. Install VB-Audio Virtual Cable \
|
||||
or enable Steam Remote Play's microphone (Steam Streaming Microphone), or set \
|
||||
PUNKTFUNK_MIC_DEVICE=<friendly-name substring>."
|
||||
)
|
||||
if skipped_loopback {
|
||||
anyhow!(
|
||||
"the only virtual-mic candidate among render endpoints {names:?} is the default \
|
||||
playback device the host loopback-captures — injecting there would echo the mic \
|
||||
back to the client. Add a SEPARATE virtual audio device for the mic (e.g. the Steam \
|
||||
Streaming Microphone) or set a different default playback device, then reconnect."
|
||||
)
|
||||
} else {
|
||||
anyhow!(
|
||||
"no virtual-mic device among render endpoints {names:?}. Install VB-Audio Virtual \
|
||||
Cable or enable Steam Remote Play's microphone (Steam Streaming Microphone), or set \
|
||||
PUNKTFUNK_MIC_DEVICE=<friendly-name substring>."
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,15 +205,15 @@ fn find_or_install_device() -> Result<wasapi::Device> {
|
||||
match find_device() {
|
||||
Ok(d) => Ok(d),
|
||||
Err(e) => {
|
||||
tracing::info!("no virtual mic device present — attempting auto-install");
|
||||
// SAFETY: `try_install_virtual_mic` is `unsafe` only because it `LoadLibraryExW`s
|
||||
tracing::info!("no usable virtual mic device present — attempting auto-install");
|
||||
// SAFETY: `install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s
|
||||
// `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer;
|
||||
// calling it imposes no extra precondition here (it takes no args and aliases nothing).
|
||||
// Its internal contract holds: the `DiInstall` type matches the documented
|
||||
// `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a
|
||||
// NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
|
||||
// dedicated mic thread.
|
||||
if unsafe { try_install_virtual_mic() } {
|
||||
if unsafe { install_steam_audio_pair() } {
|
||||
find_device()
|
||||
} else {
|
||||
Err(e)
|
||||
@@ -173,13 +222,26 @@ fn find_or_install_device() -> Result<wasapi::Device> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: install a virtual mic device so one exists without the user installing anything.
|
||||
/// Mirrors Apollo's Steam Streaming Speakers install — Steam Remote Play ships
|
||||
/// `SteamStreamingMicrophone.inf` next to the speakers INF, so install it via `DiInstallDriverW`
|
||||
/// (loaded from `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). Needs admin (the
|
||||
/// host runs as SYSTEM). Returns true on success; false (no-op) if Steam isn't installed (INF absent),
|
||||
/// the install is denied, or `PUNKTFUNK_NO_MIC_INSTALL` is set.
|
||||
unsafe fn try_install_virtual_mic() -> bool {
|
||||
/// Best-effort: install BOTH Steam Streaming audio devices (the "Steam pair") so mic passthrough
|
||||
/// works out of the box and the host has a desktop-audio sink distinct from the mic. Steam Remote
|
||||
/// Play ships `SteamStreamingMicrophone.inf` + `SteamStreamingSpeakers.inf`: the microphone gives the
|
||||
/// virtual mic a target whose **capture** endpoint apps record from, and the speakers give a
|
||||
/// **render** endpoint a headless box can loopback-capture that is NOT the mic — so the loopback and
|
||||
/// the mic land on different devices and never echo (see [`find_device`]). Returns true if either
|
||||
/// installed. No-op when Steam isn't installed (INFs absent), the install is denied (needs admin —
|
||||
/// the host runs as SYSTEM), or `PUNKTFUNK_NO_MIC_INSTALL` is set.
|
||||
unsafe fn install_steam_audio_pair() -> bool {
|
||||
// Microphone first (the mic's actual target); speakers second (the distinct desktop-audio sink).
|
||||
let mic = try_install_steam_audio("SteamStreamingMicrophone.inf");
|
||||
let spk = try_install_steam_audio("SteamStreamingSpeakers.inf");
|
||||
mic || spk
|
||||
}
|
||||
|
||||
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
||||
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
||||
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
||||
/// per-arch `drivers\Windows10\{arch}\` directory.
|
||||
unsafe fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
use windows::core::{s, w, PCWSTR};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
|
||||
@@ -197,12 +259,11 @@ unsafe fn try_install_virtual_mic() -> bool {
|
||||
let subdir = "arm64";
|
||||
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||
let subdir = "x86";
|
||||
let template: Vec<u16> = format!(
|
||||
"%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\{subdir}\\SteamStreamingMicrophone.inf"
|
||||
)
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let template: Vec<u16> =
|
||||
format!("%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\{subdir}\\{inf_name}")
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut path = vec![0u16; 1024];
|
||||
let n = ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice()));
|
||||
if n == 0 || n as usize > path.len() {
|
||||
@@ -210,7 +271,7 @@ unsafe fn try_install_virtual_mic() -> bool {
|
||||
}
|
||||
|
||||
let Ok(newdev) = LoadLibraryExW(w!("newdev.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) else {
|
||||
tracing::warn!("could not load newdev.dll — virtual-mic auto-install unavailable");
|
||||
tracing::warn!("could not load newdev.dll — Steam-audio auto-install unavailable");
|
||||
return false;
|
||||
};
|
||||
let Some(addr) = GetProcAddress(newdev, s!("DiInstallDriverW")) else {
|
||||
@@ -226,13 +287,17 @@ unsafe fn try_install_virtual_mic() -> bool {
|
||||
std::ptr::null_mut(),
|
||||
) != 0;
|
||||
if ok {
|
||||
tracing::info!("installed the Steam Streaming Microphone virtual device");
|
||||
tracing::info!(
|
||||
inf = inf_name,
|
||||
"installed a Steam Streaming virtual audio device"
|
||||
);
|
||||
std::thread::sleep(Duration::from_secs(5)); // let the audio subsystem register the endpoint
|
||||
} else {
|
||||
let err = windows::Win32::Foundation::GetLastError();
|
||||
tracing::info!(
|
||||
inf = inf_name,
|
||||
?err,
|
||||
"no virtual mic auto-installed (Steam absent / not admin) — see manual-install guidance"
|
||||
"Steam-audio device not auto-installed (Steam absent / not admin) — see install guidance"
|
||||
);
|
||||
}
|
||||
ok
|
||||
|
||||
@@ -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<Box<dyn InputInjector>> {
|
||||
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<Box<dyn InputInjector>> {
|
||||
/// 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")]
|
||||
|
||||
@@ -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<FakeInput>,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
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<FakeInput, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &FakeInput,
|
||||
_: <FakeInput as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KwinFakeInjector {
|
||||
conn: Connection,
|
||||
queue: EventQueue<State>,
|
||||
state: State,
|
||||
fake: FakeInput,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,14 @@
|
||||
//! node for it. The node lives on the user's default PipeWire daemon, so [`VirtualOutput::remote_fd`]
|
||||
//! is `None` and capture connects to that daemon directly.
|
||||
//!
|
||||
//! Requirements: KWin must expose the privileged `zkde_screencast` global — a real Plasma session
|
||||
//! authorizes it for its own clients; the headless test exposes it to bare clients via
|
||||
//! Requirements: KWin must expose the privileged `zkde_screencast` global. It is a *restricted*
|
||||
//! protocol — KWin advertises it only to a client whose installed `.desktop` lists it under
|
||||
//! `X-KDE-Wayland-Interfaces` (KWin maps the connecting client to a `.desktop` by resolving
|
||||
//! `/proc/<pid>/exe` against `Exec=`, then caches the grant per-executable for the session's life).
|
||||
//! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship
|
||||
//! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`,
|
||||
//! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first
|
||||
//! connects. The headless test path instead exposes it to bare clients via
|
||||
//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
|
||||
//! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin
|
||||
//! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with
|
||||
@@ -406,9 +412,11 @@ pub fn probe() -> Result<()> {
|
||||
queue.roundtrip(&mut state).context("registry roundtrip")?;
|
||||
if state.screencast.is_none() {
|
||||
bail!(
|
||||
"KWin is up but does not (yet) expose zkde_screencast_unstable_v1 — needs a real \
|
||||
KDE session (or KWIN_WAYLAND_NO_PERMISSION_CHECKS=1), and KWin ≥ 6.5.6 for the \
|
||||
headless virtual output"
|
||||
"KWin is up but does not expose zkde_screencast_unstable_v1 to this client — KWin gates \
|
||||
it on the host's .desktop X-KDE-Wayland-Interfaces (install \
|
||||
io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \
|
||||
re-reads it — the grant is cached per-exe on first connect), or set \
|
||||
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@@ -437,8 +445,9 @@ fn run(
|
||||
|
||||
let screencast = state.screencast.clone().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"KWin does not expose zkde_screencast_unstable_v1 (need a real KDE session, or run \
|
||||
KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test)"
|
||||
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -86,6 +86,12 @@ package_punktfunk-host() {
|
||||
install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \
|
||||
"$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
# KWin Desktop-mode authorization: non-launcher .desktop whose X-KDE-Wayland-Interfaces lets the
|
||||
# host bind KWin's restricted zkde_screencast (virtual output) + fake_input globals on an
|
||||
# interactive Plasma session. Must ship with the host (KWin caches the per-exe grant on first
|
||||
# connect). See the file's header comment.
|
||||
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
||||
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||
# headless session helpers + env templates + OpenAPI doc
|
||||
install -Dm0755 "$R/scripts/headless/run-headless-kde.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-kde.sh"
|
||||
install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh"
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot setup so the punktfunk host can stream the Bazzite KDE *Desktop* session (KWin virtual
|
||||
# output at the client's resolution). Run ONCE as the streaming user (no root needed). Gaming Mode
|
||||
# (gamescope) needs none of this — it auto-attaches. Idempotent: safe to re-run.
|
||||
# One-shot setup so the punktfunk host can INJECT INPUT while streaming the Bazzite KDE *Desktop*
|
||||
# session. Run ONCE as the streaming user (no root needed). Gaming Mode (gamescope) needs none of
|
||||
# this — it auto-attaches. Idempotent: safe to re-run.
|
||||
#
|
||||
# bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
|
||||
#
|
||||
# Two things a normal KDE login lacks that the headless host needs:
|
||||
# 1. KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 — so KWin exposes the privileged `zkde_screencast`
|
||||
# virtual-output protocol to the host (an external client) at all.
|
||||
# 2. The `kde-authorized` RemoteDesktop grant — so libei input setup auto-approves instead of
|
||||
# popping an "Allow remote control?" dialog the headless host can't answer.
|
||||
# After running, log out + back into the KDE Desktop session once (or reboot) so KWin restarts
|
||||
# with the flag. Gaming Mode is unaffected.
|
||||
# The VIRTUAL OUTPUT (video) needs no setup: the host package ships io.unom.Punktfunk.Host.desktop,
|
||||
# whose X-KDE-Wayland-Interfaces grants the host KWin's restricted zkde_screencast protocol on a
|
||||
# normal interactive Plasma session — least-privilege (only the host, only that interface), the same
|
||||
# mechanism krfb/krdp use. No session-wide KWIN_WAYLAND_NO_PERMISSION_CHECKS hack is needed. KWin
|
||||
# caches the grant per-executable on first connect, so after a FRESH host install log out + back into
|
||||
# the Desktop session once so KWin re-reads the file.
|
||||
#
|
||||
# The one thing a normal KDE login still lacks is the `kde-authorized` RemoteDesktop grant — so the
|
||||
# host's libei input setup auto-approves instead of popping an "Allow remote control?" dialog the
|
||||
# headless host can't answer. That's what this script seeds.
|
||||
set -euo pipefail
|
||||
|
||||
GRANT_SRC="${PUNKTFUNK_GRANT_SRC:-/usr/share/punktfunk/headless/kde-authorized}"
|
||||
ENVD="$HOME/.config/environment.d/10-punktfunk-kwin.conf"
|
||||
GRANT_DST="$HOME/.local/share/flatpak/db/kde-authorized"
|
||||
# Older versions of this script wrote a session-wide KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 env file to
|
||||
# unlock screencast. The shipped .desktop replaces it; remove the stale, over-broad override.
|
||||
STALE_ENVD="$HOME/.config/environment.d/10-punktfunk-kwin.conf"
|
||||
|
||||
echo "punktfunk: KDE Desktop-mode setup"
|
||||
echo "punktfunk: KDE Desktop-mode input setup"
|
||||
|
||||
# 1. KWin permission-check bypass (persistent, picked up by the next KDE session via systemd).
|
||||
mkdir -p "$(dirname "$ENVD")"
|
||||
cat > "$ENVD" <<'EOF'
|
||||
# punktfunk: let the streaming host bind KWin's privileged zkde_screencast (virtual output).
|
||||
# A dedicated streaming box; this relaxes KWin's Wayland permission checks for the desktop path.
|
||||
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1
|
||||
EOF
|
||||
echo " wrote $ENVD"
|
||||
if [[ -f "$STALE_ENVD" ]] && grep -q KWIN_WAYLAND_NO_PERMISSION_CHECKS "$STALE_ENVD" 2>/dev/null; then
|
||||
rm -f "$STALE_ENVD"
|
||||
echo " removed stale $STALE_ENVD (screencast is now granted via the shipped .desktop)"
|
||||
fi
|
||||
|
||||
# 2. RemoteDesktop portal grant for headless libei input (never clobber an existing one).
|
||||
# RemoteDesktop portal grant for headless libei input (never clobber an existing one).
|
||||
if [[ -s "$GRANT_DST" ]]; then
|
||||
echo " grant DB already present ($GRANT_DST) — leaving it"
|
||||
elif [[ -s "$GRANT_SRC" ]]; then
|
||||
@@ -44,5 +45,5 @@ else
|
||||
echo " WARN: grant source not found at $GRANT_SRC — input will need a manual portal approval" >&2
|
||||
fi
|
||||
|
||||
echo "punktfunk: done. Log out + back into the KDE Desktop session (or reboot) so KWin restarts"
|
||||
echo " with the flag, then connect a client while in Desktop Mode."
|
||||
echo "punktfunk: done. On a fresh host install, log out + back into the KDE Desktop session once"
|
||||
echo " (so KWin authorizes the host's virtual output), then connect a client in Desktop Mode."
|
||||
|
||||
@@ -50,6 +50,13 @@ sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
|
||||
install -Dm0644 scripts/punktfunk-kde-session.service "$STAGE/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk-host/headless/run-headless-kde.sh#' \
|
||||
"$STAGE/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
|
||||
# KWin Desktop-mode authorization: non-launcher .desktop whose X-KDE-Wayland-Interfaces lets the
|
||||
# host bind KWin's restricted zkde_screencast (virtual output) + fake_input globals on an
|
||||
# interactive Plasma session. Must ship with the host — KWin caches the per-exe grant on first
|
||||
# connect, so it has to be present before the host ever connects. See the file's header comment.
|
||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
||||
"$STAGE/usr/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
|
||||
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
|
||||
install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Punktfunk Host
|
||||
Comment=punktfunk streaming host — KWin virtual-output / input authorization
|
||||
Exec=/usr/bin/punktfunk-host
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
# This file is NOT a launcher — it exists so KWin authorizes the host to bind its restricted
|
||||
# Wayland globals when streaming the *Desktop* (KWin) session. KWin maps a connecting client to a
|
||||
# .desktop by resolving /proc/<pid>/exe against `Exec` (hence the absolute /usr/bin path), then
|
||||
# grants only the interfaces listed here (the same mechanism krfb-virtualmonitor / krdpserver use):
|
||||
# * zkde_screencast_unstable_v1 — create the per-session virtual output at the client's mode.
|
||||
# * org_kde_kwin_fake_input — inject input directly (no RemoteDesktop portal dialog).
|
||||
# Comma-separated, per KWin's parser. Without this file KWin never advertises these to the host and
|
||||
# desktop-mode streaming fails with "KWin does not expose zkde_screencast_unstable_v1". Gaming Mode
|
||||
# (gamescope) does not use this path. NOTE: KWin caches the per-executable grant on first connect,
|
||||
# so this must be installed *before* the host first connects (a package install satisfies that; an
|
||||
# already-running KWin session needs a re-login to pick it up).
|
||||
X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,org_kde_kwin_fake_input
|
||||
@@ -196,6 +196,14 @@ sed -i 's#%h/punktfunk/target/release/punktfunk-host#%{_bindir}/punktfunk-host#'
|
||||
install -Dm0644 scripts/punktfunk-kde-session.service %{buildroot}%{_userunitdir}/punktfunk-kde-session.service
|
||||
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#%{_datadir}/%{name}/headless/run-headless-kde.sh#' %{buildroot}%{_userunitdir}/punktfunk-kde-session.service
|
||||
|
||||
# KWin authorization for Desktop-mode (KWin) streaming: a non-launcher .desktop whose
|
||||
# X-KDE-Wayland-Interfaces grants the host the restricted zkde_screencast (virtual output) +
|
||||
# fake_input globals on an interactive Plasma session. Must ship with the host so it is present
|
||||
# before the host first connects (KWin caches the per-exe grant). Replaces the old manual
|
||||
# KWIN_WAYLAND_NO_PERMISSION_CHECKS hack for the screencast permission.
|
||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
||||
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
|
||||
|
||||
# --- client subpackage ---
|
||||
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
|
||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
|
||||
@@ -221,7 +229,8 @@ install -Dm0644 scripts/headless/punktfunk-sink.conf %{buildroot}%{_datadir}/%
|
||||
install -Dm0644 scripts/host.env.example %{buildroot}%{_datadir}/%{name}/host.env.example
|
||||
install -Dm0644 packaging/bazzite/host.env %{buildroot}%{_datadir}/%{name}/host.env.bazzite
|
||||
install -Dm0644 packaging/kde/host.env %{buildroot}%{_datadir}/%{name}/host.env.kde
|
||||
# Bazzite KDE Desktop-mode one-shot setup (KWIN_WAYLAND_NO_PERMISSION_CHECKS + RemoteDesktop grant).
|
||||
# Bazzite KDE Desktop-mode one-shot setup (seeds the RemoteDesktop grant for libei input; the
|
||||
# screencast/virtual-output grant ships as io.unom.Punktfunk.Host.desktop, installed above).
|
||||
install -d %{buildroot}%{_datadir}/%{name}/bazzite
|
||||
install -Dm0755 packaging/bazzite/kde-desktop-setup.sh %{buildroot}%{_datadir}/%{name}/bazzite/kde-desktop-setup.sh
|
||||
install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name}/openapi.json
|
||||
@@ -252,6 +261,7 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||
%{_userunitdir}/punktfunk-host.service
|
||||
%{_userunitdir}/punktfunk-kde-session.service
|
||||
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
|
||||
%dir %{_datadir}/%{name}
|
||||
%{_datadir}/%{name}/*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user